zoukankan      html  css  js  c++  java
  • Redux异步解决方案之Redux-Thunk原理及源码解析

    前段时间,我们写了一篇Redux源码分析的文章,也分析了跟React连接的库React-Redux的源码实现。但是在Redux的生态中还有一个很重要的部分没有涉及到,那就是Redux的异步解决方案。本文会讲解Redux官方实现的异步解决方案----Redux-Thunk,我们还是会从基本的用法入手,再到原理解析,然后自己手写一个Redux-Thunk来替换它,也就是源码解析。

    Redux-Thunk和前面写过的ReduxReact-Redux其实都是Redux官方团队的作品,他们的侧重点各有不同:

    Redux:是核心库,功能简单,只是一个单纯的状态机,但是蕴含的思想不简单,是传说中的“百行代码,千行文档”。

    React-Redux:是跟React的连接库,当Redux状态更新的时候通知React更新组件。

    Redux-Thunk:提供Redux的异步解决方案,弥补Redux功能的不足。

    本文手写代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/React/redux-thunk/src/myThunk.js

    基本用法

    还是以我们之前的那个计数器作为例子,为了让计数器+1,我们会发出一个action,像这样:

    function increment() {
      return {
        type: 'INCREMENT'
      }
    };
    
    store.dispatch(increment());
    

    原始的Redux里面,action creator必须返回plain object,而且必须是同步的。但是我们的应用里面经常会有定时器,网络请求等等异步操作,使用Redux-Thunk就可以发出异步的action

    function increment() {
      return {
        type: 'INCREMENT'
      }
    };
    
    // 异步action creator
    function incrementAsync() {
      return (dispatch) => {
        setTimeout(() => {
          dispatch(increment());
        }, 1000);
      }
    }
    
    // 使用了Redux-Thunk后dispatch不仅仅可以发出plain object,还可以发出这个异步的函数
    store.dispatch(incrementAsync());
    

    下面再来看个更实际点的例子,也是官方文档中的例子:

    import { createStore, applyMiddleware } from 'redux';
    import thunk from 'redux-thunk';
    import rootReducer from './reducers';
    
    // createStore的时候传入thunk中间件
    const store = createStore(rootReducer, applyMiddleware(thunk));
    
    // 发起网络请求的方法
    function fetchSecretSauce() {
      return fetch('https://www.baidu.com/s?wd=Secret%20Sauce');
    }
    
    // 下面两个是普通的action
    function makeASandwich(forPerson, secretSauce) {
      return {
        type: 'MAKE_SANDWICH',
        forPerson,
        secretSauce,
      };
    }
    
    function apologize(fromPerson, toPerson, error) {
      return {
        type: 'APOLOGIZE',
        fromPerson,
        toPerson,
        error,
      };
    }
    
    // 这是一个异步action,先请求网络,成功就makeASandwich,失败就apologize
    function makeASandwichWithSecretSauce(forPerson) {
      return function (dispatch) {
        return fetchSecretSauce().then(
          (sauce) => dispatch(makeASandwich(forPerson, sauce)),
          (error) => dispatch(apologize('The Sandwich Shop', forPerson, error)),
        );
      };
    }
    
    // 最终dispatch的是异步action makeASandwichWithSecretSauce
    store.dispatch(makeASandwichWithSecretSauce('Me'));
    
    

    为什么要用Redux-Thunk

    在继续深入源码前,我们先来思考一个问题,为什么我们要用Redux-Thunk,不用它行不行?再仔细看看Redux-Thunk的作用:

    // 异步action creator
    function incrementAsync() {
      return (dispatch) => {
        setTimeout(() => {
          dispatch(increment());
        }, 1000);
      }
    }
    
    store.dispatch(incrementAsync());
    

    他仅仅是让dispath多支持了一种类型,就是函数类型,在使用Redux-Thunk前我们dispatchaction必须是一个纯对象(plain object),使用了Redux-Thunk后,dispatch可以支持函数,这个函数会传入dispatch本身作为参数。但是其实我们不使用Redux-Thunk也可以达到同样的效果,比如上面代码我完全可以不要外层的incrementAsync,直接这样写:

    setTimeout(() => {
      store.dispatch(increment());
    }, 1000);
    

    这样写同样可以在1秒后发出增加的action,而且代码还更简单,那我们为什么还要用Redux-Thunk呢,他存在的意义是什么呢?stackoverflow对这个问题有一个很好的回答,而且是官方推荐的解释。我再写一遍也不会比他写得更好,所以我就直接翻译了:

    ----翻译从这里开始----

    不要觉得一个库就应该规定了所有事情!如果你想用JS处理一个延时任务,直接用setTimeout就好了,即使你使用了Redux也没啥区别。Redux确实提供了另一种处理异步任务的机制,但是你应该用它来解决你很多重复代码的问题。如果你没有太多重复代码,使用语言原生方案其实是最简单的方案。

    直接写异步代码

    到目前为止这是最简单的方案,Redux也不需要特殊的配置:

    store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
    setTimeout(() => {
      store.dispatch({ type: 'HIDE_NOTIFICATION' })
    }, 5000)
    

    (译注:这段代码的功能是显示一个通知,5秒后自动消失,也就是我们经常使用的toast效果,原作者一直以这个为例。)

    相似的,如果你是在一个连接了Redux组件中使用:

    this.props.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
    setTimeout(() => {
      this.props.dispatch({ type: 'HIDE_NOTIFICATION' })
    }, 5000)
    

    唯一的区别就是连接组件一般不需要直接使用store,而是将dispatch或者action creator作为props注入,这两种方式对我们都没区别。

    如果你不想写重复的action名字,你可以将这两个action抽取成action creator而不是直接dispatch一个对象:

    // actions.js
    export function showNotification(text) {
      return { type: 'SHOW_NOTIFICATION', text }
    }
    export function hideNotification() {
      return { type: 'HIDE_NOTIFICATION' }
    }
    
    // component.js
    import { showNotification, hideNotification } from '../actions'
    
    this.props.dispatch(showNotification('You just logged in.'))
    setTimeout(() => {
      this.props.dispatch(hideNotification())
    }, 5000)
    

    或者你已经通过connect()注入了这两个action creator

    this.props.showNotification('You just logged in.')
    setTimeout(() => {
      this.props.hideNotification()
    }, 5000)
    

    到目前为止,我们没有使用任何中间件或者其他高级技巧,但是我们同样实现了异步任务的处理。

    提取异步的Action Creator

    使用上面的方式在简单场景下可以工作的很好,但是你可能已经发现了几个问题:

    1. 每次你想显示toast的时候,你都得把这一大段代码抄过来抄过去。
    2. 现在的toast没有id,这可能会导致一种竞争的情况:如果你连续快速的显示两次toast,当第一次的结束时,他会dispatchHIDE_NOTIFICATION,这会错误的导致第二个也被关掉。

    为了解决这两个问题,你可能需要将toast的逻辑抽取出来作为一个方法,大概长这样:

    // actions.js
    function showNotification(id, text) {
      return { type: 'SHOW_NOTIFICATION', id, text }
    }
    function hideNotification(id) {
      return { type: 'HIDE_NOTIFICATION', id }
    }
    
    let nextNotificationId = 0
    export function showNotificationWithTimeout(dispatch, text) {
      // 给通知分配一个ID可以让reducer忽略非当前通知的HIDE_NOTIFICATION
      // 而且我们把计时器的ID记录下来以便于后面用clearTimeout()清除计时器
      const id = nextNotificationId++
      dispatch(showNotification(id, text))
    
      setTimeout(() => {
        dispatch(hideNotification(id))
      }, 5000)
    }
    

    现在你的组件可以直接使用showNotificationWithTimeout,再也不用抄来抄去了,也不用担心竞争问题了:

    // component.js
    showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
    
    // otherComponent.js
    showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')  
    

    但是为什么showNotificationWithTimeout()要接收dispatch作为第一个参数呢?因为他需要将action发给store。一般组件是可以拿到dispatch的,为了让外部方法也能dispatch,我们需要给他dispath作为参数。

    如果你有一个单例的store,你也可以让showNotificationWithTimeout直接引入这个store然后dispatch action

    // store.js
    export default createStore(reducer)
    
    // actions.js
    import store from './store'
    
    // ...
    
    let nextNotificationId = 0
    export function showNotificationWithTimeout(text) {
      const id = nextNotificationId++
      store.dispatch(showNotification(id, text))
    
      setTimeout(() => {
        store.dispatch(hideNotification(id))
      }, 5000)
    }
    
    // component.js
    showNotificationWithTimeout('You just logged in.')
    
    // otherComponent.js
    showNotificationWithTimeout('You just logged out.') 
    

    这样做看起来不复杂,也能达到效果,但是我们不推荐这种做法!主要原因是你的store必须是单例的,这让Server Render实现起来很麻烦。在Server端,你会希望每个请求都有自己的store,比便于不同的用户可以拿到不同的预加载内容。

    一个单例的store也让单元测试很难写。测试action creator的时候你很难mock store,因为他引用了一个具体的真实的store。你甚至不能从外部重置store状态。

    所以从技术上来说,你可以从一个module导出单例的store,但是我们不鼓励这样做。除非你确定加肯定你以后都不会升级Server Render。所以我们还是回到前面一种方案吧:

    // actions.js
    
    // ...
    
    let nextNotificationId = 0
    export function showNotificationWithTimeout(dispatch, text) {
      const id = nextNotificationId++
      dispatch(showNotification(id, text))
    
      setTimeout(() => {
        dispatch(hideNotification(id))
      }, 5000)
    }
    
    // component.js
    showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
    
    // otherComponent.js
    showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')  
    

    这个方案就可以解决重复代码和竞争问题。

    Thunk中间件

    对于简单项目,上面的方案应该已经可以满足需求了。

    但是对于大型项目,你可能还是会觉得这样使用并不方便。

    比如,似乎我们必须将dispatch作为参数传递,这让我们分隔容器组件和展示组件变得更困难,因为任何发出异步Redux action的组件都必须接收dispatch作为参数,这样他才能将它继续往下传。你也不能仅仅使用connect()来绑定action creator,因为showNotificationWithTimeout()并不是一个真正的action creator,他返回的也不是Redux action

    还有个很尴尬的事情是,你必须记住哪个action cerator是同步的,比如showNotification,哪个是异步的辅助方法,比如showNotificationWithTimeout。这两个的用法是不一样的,你需要小心的不要传错了参数,也不要混淆了他们。

    这就是我们为什么需要找到一个“合法”的方法给辅助方法提供dispatch参数,并且帮助Redux区分出哪些是异步的action creator,好特殊处理他们

    如果你的项目中面临着类似的问题,欢迎使用Redux Thunk中间件。

    简单来说,React Thunk告诉Redux怎么去区分这种特殊的action----他其实是个函数:

    import { createStore, applyMiddleware } from 'redux'
    import thunk from 'redux-thunk'
    
    const store = createStore(
      reducer,
      applyMiddleware(thunk)
    )
    
    // 这个是普通的纯对象action
    store.dispatch({ type: 'INCREMENT' })
    
    // 但是有了Thunk,他就可以识别函数了
    store.dispatch(function (dispatch) {
      // 这个函数里面又可以dispatch很多action
      dispatch({ type: 'INCREMENT' })
      dispatch({ type: 'INCREMENT' })
      dispatch({ type: 'INCREMENT' })
    
      setTimeout(() => {
        // 异步的dispatch也可以
        dispatch({ type: 'DECREMENT' })
      }, 1000)
    })
    
    

    如果你使用了这个中间件,而且你dispatch的是一个函数,React Thunk会自己将dispatch作为参数传进去。而且他会将这些函数action“吃了”,所以不用担心你的reducer会接收到奇怪的函数参数。你的reducer只会接收到纯对象action,无论是直接发出的还是前面那些异步函数发出的。

    这个看起来好像也没啥大用,对不对?在当前这个例子确实是的!但是他让我们可以像定义一个普通的action creator那样去定义showNotificationWithTimeout

    // actions.js
    function showNotification(id, text) {
      return { type: 'SHOW_NOTIFICATION', id, text }
    }
    function hideNotification(id) {
      return { type: 'HIDE_NOTIFICATION', id }
    }
    
    let nextNotificationId = 0
    export function showNotificationWithTimeout(text) {
      return function (dispatch) {
        const id = nextNotificationId++
        dispatch(showNotification(id, text))
    
        setTimeout(() => {
          dispatch(hideNotification(id))
        }, 5000)
      }
    }
    

    注意这里的showNotificationWithTimeout跟我们前面的那个看起来非常像,但是他并不需要接收dispatch作为第一个参数。而是返回一个函数来接收dispatch作为第一个参数。

    那在我们的组件中怎么使用这个函数呢,我们当然可以这样写:

    // component.js
    showNotificationWithTimeout('You just logged in.')(this.props.dispatch)
    

    这样我们直接调用了异步的action creator来得到内层的函数,这个函数需要dispatch做为参数,所以我们给了他dispatch参数。

    然而这样使用岂不是更尬,还不如我们之前那个版本的!我们为啥要这么干呢?

    我之前就告诉过你:只要使用了Redux Thunk,如果你想dispatch一个函数,而不是一个纯对象,这个中间件会自己帮你调用这个函数,而且会将dispatch作为第一个参数传进去。

    所以我们可以直接这样干:

    // component.js
    this.props.dispatch(showNotificationWithTimeout('You just logged in.'))
    

    最后,对于组件来说,dispatch一个异步的action(其实是一堆普通action)看起来和dispatch一个普通的同步action看起来并没有啥区别。这是个好现象,因为组件就不应该关心那些动作到底是同步的还是异步的,我们已经将它抽象出来了。

    注意因为我们已经教了Redux怎么区分这些特殊的action creator(我们称之为thunk action creator),现在我们可以在任何普通的action creator的地方使用他们了。比如,我们可以直接在connect()中使用他们:

    // actions.js
    
    function showNotification(id, text) {
      return { type: 'SHOW_NOTIFICATION', id, text }
    }
    function hideNotification(id) {
      return { type: 'HIDE_NOTIFICATION', id }
    }
    
    let nextNotificationId = 0
    export function showNotificationWithTimeout(text) {
      return function (dispatch) {
        const id = nextNotificationId++
        dispatch(showNotification(id, text))
    
        setTimeout(() => {
          dispatch(hideNotification(id))
        }, 5000)
      }
    }
    
    // component.js
    
    import { connect } from 'react-redux'
    
    // ...
    
    this.props.showNotificationWithTimeout('You just logged in.')
    
    // ...
    
    export default connect(
      mapStateToProps,
      { showNotificationWithTimeout }
    )(MyComponent)
    

    在Thunk中读取State

    通常来说,你的reducer会包含计算新的state的逻辑,但是reducer只有当你dispatchaction才会触发。如果你在thunk action creator中有一个副作用(比如一个API调用),某些情况下,你不想发出这个action该怎么办呢?

    如果没有Thunk中间件,你需要在组件中添加这个逻辑:

    // component.js
    if (this.props.areNotificationsEnabled) {
      showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
    }
    

    但是我们提取action creator的目的就是为了集中这些在各个组件中重复的逻辑。幸运的是,Redux Thunk提供了一个读取当前store state的方法。那就是除了传入dispatch参数外,他还会传入getState作为第二个参数,这样thunk就可以读取store的当前状态了。

    let nextNotificationId = 0
    export function showNotificationWithTimeout(text) {
      return function (dispatch, getState) {
        // 不像普通的action cerator,这里我们可以提前退出
        // Redux不关心这里的返回值,没返回值也没关系
        if (!getState().areNotificationsEnabled) {
          return
        }
    
        const id = nextNotificationId++
        dispatch(showNotification(id, text))
    
        setTimeout(() => {
          dispatch(hideNotification(id))
        }, 5000)
      }
    }
    

    但是不要滥用这种方法!如果你需要通过检查缓存来判断是否发起API请求,这种方法就很好,但是将你整个APP的逻辑都构建在这个基础上并不是很好。如果你只是用getState来做条件判断是否要dispatch action,你可以考虑将这些逻辑放到reducer里面去。

    下一步

    现在你应该对thunk的工作原理有了一个基本的概念,如果你需要更多的例子,可以看这里:https://redux.js.org/introduction/examples#async

    你可能会发现很多例子都返回了Promise,这个不是必须的,但是用起来却很方便。Redux并不关心你的thunk返回了什么值,但是他会将这个值通过外层的dispatch()返回给你。这就是为什么你可以在thunk中返回一个Promise并且等他完成:

    dispatch(someThunkReturningPromise()).then(...)
    

    另外你还可以将一个复杂的thunk action creator拆分成几个更小的thunk action creator。这是因为thunk提供的dispatch也可以接收thunk,所以你可以一直嵌套的dispatch thunk。而且结合Promise的话可以更好的控制异步流程。

    在一些更复杂的应用中,你可能会发现你的异步控制流程通过thunk很难表达。比如,重试失败的请求,使用token进行重新授权认证,或者在一步一步的引导流程中,使用这种方式可能会很繁琐,而且容易出错。如果你有这些需求,你可以考虑下一些更高级的异步流程控制库,比如Redux Saga或者Redux Loop。可以看看他们,评估下,哪个更适合你的需求,选一个你最喜欢的。

    最后,不要使用任何库(包括thunk)如果你没有真实的需求。记住,我们的实现都是要看需求的,也许你的需求这个简单的方案就能满足:

    store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
    setTimeout(() => {
      store.dispatch({ type: 'HIDE_NOTIFICATION' })
    }, 5000)
    

    不要跟风尝试,除非你知道你为什么需要这个!

    ----翻译到此结束----

    StackOverflow的大神Dan Abramov对这个问题的回答实在太细致,太到位了,以致于我看了之后都不敢再写这个原因了,以此翻译向大神致敬,再贴下这个回答的地址:https://stackoverflow.com/questions/35411423/how-to-dispatch-a-redux-action-with-a-timeout/35415559#35415559

    PS: Dan Abramov是Redux生态的核心作者,这几篇文章讲的ReduxReact-ReduxRedux-Thunk都是他的作品。

    源码解析

    上面关于原因的翻译其实已经将Redux适用的场景和原理讲的很清楚了,下面我们来看看他的源码,自己仿写一个来替换他。照例我们先来分析下要点:

    1. Redux-Thunk是一个Redux中间件,所以他遵守Redux中间件的范式。
    2. thunk是一个可以dispatch的函数,所以我们需要改写dispatch让他接受函数参数。

    Redux中间件范式

    在我前面那篇讲Redux源码的文章讲过中间件的范式以及Redux中这块源码是怎么实现的,没看过或者忘了的朋友可以再去看看。我这里再简单提一下,一个Redux中间件结构大概是这样:

    function logger(store) {
      return function(next) {
        return function(action) {
          console.group(action.type);
          console.info('dispatching', action);
          let result = next(action);
          console.log('next state', store.getState());
          console.groupEnd();
          return result
        }
      }
    }
    

    这里注意几个要点:

    1. 一个中间件接收store作为参数,会返回一个函数
    2. 返回的这个函数接收老的dispatch函数作为参数(也就是代码中的next),会返回一个新的函数
    3. 返回的新函数就是新的dispatch函数,这个函数里面可以拿到外面两层传进来的store和老dispatch函数

    仿照这个范式,我们来写一下thunk中间件的结构:

    function thunk(store) {
      return function (next) {
        return function (action) {
          // 先直接返回原始结果
          let result = next(action);
          return result
        }
      }
    }
    

    处理thunk

    根据我们前面讲的,thunk是一个函数,接收dispatch getState两个参数,所以我们应该将thunk拿出来运行,然后给他传入这两个参数,再将它的返回值直接返回就行。

    function thunk(store) {
      return function (next) {
        return function (action) {
          // 从store中解构出dispatch, getState
          const { dispatch, getState } = store;
    
          // 如果action是函数,将它拿出来运行,参数就是dispatch和getState
          if (typeof action === 'function') {
            return action(dispatch, getState);
          }
    
          // 否则按照普通action处理
          let result = next(action);
          return result
        }
      }
    }
    

    接收额外参数withExtraArgument

    Redux-Thunk还提供了一个API,就是你在使用applyMiddleware引入的时候,可以使用withExtraArgument注入几个自定义的参数,比如这样:

    const api = "http://www.example.com/sandwiches/";
    const whatever = 42;
    
    const store = createStore(
      reducer,
      applyMiddleware(thunk.withExtraArgument({ api, whatever })),
    );
    
    function fetchUser(id) {
      return (dispatch, getState, { api, whatever }) => {
        // 现在你可以使用这个额外的参数api和whatever了
      };
    }
    

    这个功能要实现起来也很简单,在前面的thunk函数外面再包一层就行:

    // 外面再包一层函数createThunkMiddleware接收额外的参数
    function createThunkMiddleware(extraArgument) {
      return function thunk(store) {
        return function (next) {
          return function (action) {
            const { dispatch, getState } = store;
    
            if (typeof action === 'function') {
              // 这里执行函数时,传入extraArgument
              return action(dispatch, getState, extraArgument);  
            }
    
            let result = next(action);
            return result
          }
        }
      }
    }
    

    然后我们的thunk中间件其实相当于没传extraArgument

    const thunk = createThunkMiddleware();
    

    而暴露给外面的withExtraArgument函数就直接是createThunkMiddleware了:

    thunk.withExtraArgument = createThunkMiddleware;
    

    源码解析到此结束。啥,这就完了?是的,这就完了!Redux-Thunk就是这么简单,虽然背后的思想比较复杂,但是代码真的只有14行!我当时也震惊了,来看看官方源码吧:

    function createThunkMiddleware(extraArgument) {
      return ({ dispatch, getState }) => (next) => (action) => {
        if (typeof action === 'function') {
          return action(dispatch, getState, extraArgument);
        }
    
        return next(action);
      };
    }
    
    const thunk = createThunkMiddleware();
    thunk.withExtraArgument = createThunkMiddleware;
    
    export default thunk;
    

    总结

    1. 如果说Redux是“百行代码,千行文档”,那Redux-Thunk就是“十行代码,百行思想”。
    2. Redux-Thunk最主要的作用是帮你给异步action传入dispatch,这样你就不用从调用的地方手动传入dispatch,从而实现了调用的地方和使用的地方的解耦。
    3. ReduxRedux-Thunk让我深深体会到什么叫“编程思想”,编程思想可以很复杂,但是实现可能并不复杂,但是却非常有用。
    4. 在我们评估是否要引入一个库时最好想清楚我们为什么要引入这个库,是否有更简单的方案。

    本文手写代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/React/redux-thunk/src/myThunk.js

    参考资料

    Redux-Thunk文档:https://github.com/reduxjs/redux-thunk

    Redux-Thunk源码: https://github.com/reduxjs/redux-thunk/blob/master/src/index.js

    Dan Abramov在StackOverflow上的回答: https://stackoverflow.com/questions/35411423/how-to-dispatch-a-redux-action-with-a-timeout/35415559#35415559

    文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

    作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

    作者掘金文章汇总:https://juejin.im/post/5e3ffc85518825494e2772fd

    我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢迎关注~

    QRCode

  • 相关阅读:
    深入理解 Spring 事务原理
    spring+springMVC,声明式事务失效,原因以及解决办法
    spring 中常用的两种事务配置方式以及事务的传播性、隔离级别
    【Spring aop】Spring aop的XML和注解的两种配置实现
    Java消息队列--ActiveMq 实战
    【Android】Android开发实现进度条效果,SeekBar的简单使用。音量,音乐播放进度,视频播放进度等
    【Android】Android开发实现带有反弹效果,仿IOS反弹scrollview详解教程
    【Android】安卓开发之activity如何传值到fragment,activity与fragment传值
    【Android】Android开发初学者实现拨打电话的功能,拨打电话app小demo实现
    【Android】SlidingTabLayout实现标题栏,教你制作title标题 简单易学。
  • 原文地址:https://www.cnblogs.com/dennisj/p/13637411.html
Copyright © 2011-2022 走看看