你好,我是若川。这是
学习源码总体架构系列
第八篇。总体架构这词语好像有点大,姑且就算是源码总体结构吧,主要就是学习是代码总体结构,不深究其余不是主线的具体函数的实现。本篇文章学习的是实际仓库的代码。html
要是有人说到怎么读源码,正在读文章的你能推荐个人源码系列文章,那真是太好了。前端
学习源码总体架构系列
文章以下:vue
1.学习 jQuery 源码总体架构,打造属于本身的 js 类库
2.学习 underscore 源码总体架构,打造属于本身的函数式编程类库
3.学习 lodash 源码总体架构,打造属于本身的函数式编程类库
4.学习 sentry 源码总体架构,打造属于本身的前端异常监控SDK
5.学习 vuex 源码总体架构,打造属于本身的状态管理库
6.学习 axios 源码总体架构,打造属于本身的请求库
7.学习 koa 源码的总体架构,浅析koa洋葱模型原理和co原理
8.学习 redux 源码总体架构,深刻理解 redux 及其中间件原理
react
感兴趣的读者能够点击阅读。
其余源码计划中的有:express
、vue-rotuer
、react-redux
等源码,不知什么时候能写完(哭泣),欢迎持续关注我(若川)。ios
源码类文章,通常阅读量不高。已经有能力看懂的,本身就看了。不想看,不敢看的就不会去看源码。
因此个人文章,尽可能写得让想看源码又不知道怎么看的读者能看懂。git
阅读本文你将学到:github
git subtree
管理子仓库- 如何学习
redux
源码redux
中间件原理redux
各个API
的实现vuex
和redux
的对比- 等等
把个人redux
源码仓库 git clone https://github.com/lxchuan12/redux-analysis.git
克隆下来,顺便star
一下个人redux源码学习仓库^_^。跟着文章节奏调试和示例代码调试,用chrome
动手调试印象更加深入。文章长段代码不用细看,能够调试时再细看。看这类源码文章百遍,可能不如本身多调试几遍。也欢迎加我微信交流ruochuan12
。面试
写了不少源码文章,vuex
、axios
、koa
等都是使用新的仓库克隆一份源码在本身仓库中。 虽然电脑能够拉取最新代码,看到原做者的git信息。但上传到github
后。读者却看不到原仓库做者的git
信息了。因而我找到了git submodules
方案,但并非很适合。再后来发现了git subtree
。vue-router
简单说下 npm package
和git subtree
的区别。 npm package
是单向的。git subtree
则是双向的。vuex
具体能够查看这篇文章@德来(原有赞大佬):用 Git Subtree 在多个 Git 项目间双向同步子项目,附简明使用手册
学会了git subtree
后,我新建了redux-analysis
项目后,把redux
源码4.x
(截止至2020年06月13日,4.x
分支最新版本是4.0.5
,master
分支是ts
,文章中暂不想让一些不熟悉ts
的读者看不懂)分支克隆到了个人项目里的一个子项目,得以保留git
信息。
对应命令则是:
git subtree add --prefix=redux https://github.com/reduxjs/redux.git 4.x
复制代码
以前,我在知乎回答了一个问题若川:一年内的前端看不懂前端框架源码怎么办? 推荐了一些资料,阅读量还不错,你们有兴趣能够看看。主要有四点:
1.借助调试
2.搜索查阅相关高赞文章
3.把不懂的地方记录下来,查阅相关文档
4.总结
看源码调试很重要,因此个人每篇源码文章都详细描述(也许有人看来是比较啰嗦...)如何调试源码。
断点调试要领:
赋值语句能够一步按F10
跳过,看返回值便可,后续详细再看。
函数执行须要断点按F11
跟着看,也能够结合注释和上下文倒推这个函数作了什么。
有些不须要细看的,直接按F8
走向下一个断点
刷新从新调试按F5
调试源码前,先简单看看 redux
的工做流程,有个大概印象。
修改rollup.config.js
文件,output
输出的配置生成sourcemap
。
// redux/rollup.config.js 有些省略 const sourcemap = { sourcemap: true, }; output: { // ... ...sourcemap, } 复制代码
安装依赖
git clone http://github.com/lxchuan12/redux-analysis.git cd redux-analysi/redux npm i npm run build # 编译结束后会生成 sourcemap .map格式的文件到 dist、es、lib 目录下。 复制代码
仔细看看redux/examples
目录和redux/README
。
这时我在根路径下,新建文件夹examples
,把原生js
写的计数器redux/examples/counter-vanilla/index.html
,复制到examples/index.html
。同时把打包后的包含sourcemap
的redux/dist
目录,复制到examples/dist
目录。
修改index.html
的script
的redux.js
文件为dist中的路径
。
为了便于区分和调试后续
html
文件,我把index.html
重命名为index.1.redux.getState.dispatch.html
。
# redux-analysis 根目录 # 安装启动服务的npm包 npm i -g http-server cd examples hs -p 5000 复制代码
就能够开心的调试啦。能够直接克隆个人项目git clone http://github.com/lxchuan12/redux-analysis.git
。本地调试,动手实践,容易消化吸取。
接着咱们来看examples/index.1.redux.getState.dispatch.html
文件。先看html
部分。只是写了几个 button
,比较简单。
<div> <p> Clicked: <span id="value">0</span> times <button id="increment">+</button> <button id="decrement">-</button> <button id="incrementIfOdd">Increment if odd</button> <button id="incrementAsync">Increment async</button> </p> </div> 复制代码
js部分
,也比较简单。声明了一个counter
函数,传递给Redux.createStore(counter)
,获得结果store
,而store
是个对象。render
方法渲染数字到页面。用store.subscribe(render)
订阅的render
方法。还有store.dispatch({type: 'INCREMENT' })
方法,调用store.dispatch
时会触发render
方法。这样就实现了一个计数器。
function counter(state, action) { if (typeof state === 'undefined') { return 0 } switch (action.type) { case 'INCREMENT': return state + 1 case 'DECREMENT': return state - 1 default: return state } } var store = Redux.createStore(counter) var valueEl = document.getElementById('value') function render() { valueEl.innerHTML = store.getState().toString() } render() store.subscribe(render) document.getElementById('increment') .addEventListener('click', function () { store.dispatch({ type: 'INCREMENT' }) }) // 省略部分暂时无效代码... 复制代码
思考:看了这段代码,你会在哪打断点来调试呢。
// 四处能够断点来看 // 1. var store = Redux.createStore(counter) // 2. function render() { valueEl.innerHTML = store.getState().toString() } render() // 3. store.subscribe(render) // 4. store.dispatch({ type: 'INCREMENT' }) 复制代码
图中的右边Scope
,有时须要关注下,会显示闭包、全局环境、当前环境等变量,还能够显示函数等具体代码位置,能帮助本身理解代码。
断点调试,按F5
刷新页面后,按F8
,把鼠标放在Redux
和store
上。
能够看到Redux
上有好几个方法。分别是:
store.dispatch
函数,dispatch
时,能够串联执行全部中间件。react-redux
。reducers
,返回一个总的reducer
函数。store
对象再看store
也有几个方法。分别是:
subscribe
收集的函数,依次遍历执行dispatch
依次执行。返回一个取消订阅的函数,能够取消订阅监听。createStore
函数内部闭包的对象。redux
开发者工具,对比当前和上一次操做的异同。有点相似时间穿梭功能。也就是官方文档redux.org.js上的 API
。
暂时不去深究每个API
的实现。从新按F5
刷新页面,断点到var store = Redux.createStore(counter)
。一直按F11
,先走一遍主流程。
createStore
函数结构是这样的,是否是看起来很简单,最终返回对象store
,包含dispatch
、subscribe
、getState
、replaceReducer
等方法。
// 省略了若干代码 export default function createStore(reducer, preloadedState, enhancer) { // 省略参数校验和替换 // 当前的 reducer 函数 let currentReducer = reducer // 当前state let currentState = preloadedState // 当前的监听数组函数 let currentListeners = [] // 下一个监听数组函数 let nextListeners = currentListeners // 是否正在dispatch中 let isDispatching = false function ensureCanMutateNextListeners() { if (nextListeners === currentListeners) { nextListeners = currentListeners.slice() } } function getState() { return currentState } function subscribe(listener) {} function dispatch(action) {} function replaceReducer(nextReducer) {} function observable() {} // ActionTypes.INIT @@redux/INITu.v.d.u.6.r dispatch({ type: ActionTypes.INIT }) return { dispatch, subscribe, getState, replaceReducer, [$observable]: observable } } 复制代码
function dispatch(action) { // 判断action是不是对象,不是则报错 if (!isPlainObject(action)) { throw new Error( 'Actions must be plain objects. ' + 'Use custom middleware for async actions.' ) } // 判断action.type 是否存在,没有则报错 if (typeof action.type === 'undefined') { throw new Error( 'Actions may not have an undefined "type" property. ' + 'Have you misspelled a constant?' ) } // 不是则报错 if (isDispatching) { throw new Error('Reducers may not dispatch actions.') } try { isDispatching = true currentState = currentReducer(currentState, action) } finally { // 调用完后置为 false isDispatching = false } // 把 收集的函数拿出来依次调用 const listeners = (currentListeners = nextListeners) for (let i = 0; i < listeners.length; i++) { const listener = listeners[i] listener() } // 最终返回 action return action } 复制代码
var store = Redux.createStore(counter) 复制代码
上文调试完了这句。
继续按F11
调试。
function render() { valueEl.innerHTML = store.getState().toString() } render() 复制代码
getState
函数实现比较简单。
function getState() { // 判断正在dispatch中,则报错 if (isDispatching) { throw new Error( 'You may not call store.getState() while the reducer is executing. ' + 'The reducer has already received the state as an argument. ' + 'Pass it down from the top reducer instead of reading it from the store.' ) } // 返回当前的state return currentState } 复制代码
订阅监听函数,存放在数组中,store.dispatch(action)
时遍历执行。
function subscribe(listener) { // 订阅参数校验不是函数报错 if (typeof listener !== 'function') { throw new Error('Expected the listener to be a function.') } // 正在dispatch中,报错 if (isDispatching) { throw new Error( 'You may not call store.subscribe() while the reducer is executing. ' + 'If you would like to be notified after the store has been updated, subscribe from a ' + 'component and invoke store.getState() in the callback to access the latest state. ' + 'See https://redux.js.org/api-reference/store#subscribelistener for more details.' ) } // 订阅为 true let isSubscribed = true ensureCanMutateNextListeners() nextListeners.push(listener) // 返回一个取消订阅的函数 return function unsubscribe() { if (!isSubscribed) { return } // 正在dispatch中,则报错 if (isDispatching) { throw new Error( 'You may not unsubscribe from a store listener while the reducer is executing. ' + 'See https://redux.js.org/api-reference/store#subscribelistener for more details.' ) } // 订阅为 false isSubscribed = false ensureCanMutateNextListeners() // 找到当前监听函数 const index = nextListeners.indexOf(listener) // 在数组中删除 nextListeners.splice(index, 1) currentListeners = null } } 复制代码
到这里,咱们就调试学习完了Redux.createSotre
、store.dispatch
、store.getState
、store.subscribe
的源码。
接下来,咱们写个中间件例子,来调试中间件相关源码。
中间件是重点,面试官也常常问这类问题。
为了调试Redux.applyMiddleware(...middlewares)
,我在examples/js/middlewares.logger.example.js
写一个简单的logger
例子。分别有三个logger1
,logger2
,logger3
函数。因为都是相似,因此我在这里只展现logger1
函数。
// examples/js/middlewares.logger.example.js function logger1({ getState }) { return next => action => { console.log('will dispatch--1--next, action:', next, action) // Call the next dispatch method in the middleware chain. const returnValue = next(action) console.log('state after dispatch--1', getState()) // This will likely be the action itself, unless // a middleware further in chain changed it. return returnValue } } // 省略 logger二、logger3 复制代码
logger
中间件函数作的事情也比较简单,返回两层函数,next
就是下一个中间件函数,调用返回结果。为了让读者能看懂,我把logger1
用箭头函数、logger2
则用普通函数。
写好例子后
,咱们接着来看怎么调试Redux.applyMiddleware(...middlewares))
源码。
cd redux-analysis && hs -p 5000 # 上文说过npm i -g http-server 复制代码
打开http://localhost:5000/examples/index.2.redux.applyMiddleware.compose.html
,按F12
打开控制台,
先点击加号操做+1,把结果展现出来。
从图中能够看出,next
则是下一个函数。先1-2-3,再3-2-1这样的顺序。
这种也就是咱们常说的中间件,面向切面编程(AOP)。
接下来调试,在如下语句打上断点和一些你以为重要的地方打上断点。
// examples/index.2.redux.applyMiddleware.compose.html var store = Redux.createStore(counter, Redux.applyMiddleware(logger1, logger2, logger3)) 复制代码
// redux/src/applyMiddleware.js /** * ... * @param {...Function} middlewares The middleware chain to be applied. * @returns {Function} A store enhancer applying the middleware. */ export default function applyMiddleware(...middlewares) { return createStore => (...args) => { const store = createStore(...args) let dispatch = () => { throw new Error( 'Dispatching while constructing your middleware is not allowed. ' + 'Other middleware would not be applied to this dispatch.' ) } const middlewareAPI = { getState: store.getState, dispatch: (...args) => dispatch(...args) } const chain = middlewares.map(middleware => middleware(middlewareAPI)) dispatch = compose(...chain)(store.dispatch) return { ...store, dispatch } } } 复制代码
// redux/src/createStore.js export default function createStore(reducer, preloadedState, enhancer) { // 省略参数校验 // 若是第二个参数`preloadedState`是函数,而且第三个参数`enhancer`是undefined,把它们互换一下。 if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') { enhancer = preloadedState preloadedState = undefined } if (typeof enhancer !== 'undefined') { if (typeof enhancer !== 'function') { throw new Error('Expected the enhancer to be a function.') } // enhancer 也就是`Redux.applyMiddleware`返回的函数 // createStore 的 args 则是 `reducer, preloadedState` /** * createStore => (...args) => { const store = createStore(...args) return { ...store, dispatch, } } ** / // 最终返回加强的store对象。 return enhancer(createStore)(reducer, preloadedState) } // 省略后续代码 } 复制代码
把接收的中间件函数logger1
, logger2
, logger3
放入到 了middlewares
数组中。Redux.applyMiddleware
最后返回两层函数。 把中间件函数都混入了参数getState
和dispatch
。
// examples/index.2.redux.applyMiddleware.compose.html var store = Redux.createStore(counter, Redux.applyMiddleware(logger1, logger2, logger3)) 复制代码
最后这句实际上是返回一个加强了dispatch
的store
对象。
而加强的dispatch
函数,则是用Redux.compose(...functions)
进行串联起来执行的。
export default function compose(...funcs) { if (funcs.length === 0) { return arg => arg } if (funcs.length === 1) { return funcs[0] } return funcs.reduce((a, b) => (...args) => a(b(...args))) } 复制代码
// applyMiddleware.js dispatch = compose(...chain)(store.dispatch) // compose funcs.reduce((a, b) => (...args) => a(b(...args))) 复制代码
这两句可能不是那么好理解,能够断点多调试几回。我把箭头函数转换成普通函数。
funcs.reduce(function(a, b){ return function(...args){ return a(b(...args)); }; }); 复制代码
其实redux
源码中注释很清晰了,这个compose
函数上方有一堆注释,其中有一句:组合多个函数,从右到左,好比:compose(f, g, h)
最终获得这个结果 (...args) => f(g(h(...args)))
.
看Redux.compose(...functions)
函数源码后,仍是不明白,不要急不要慌,吃完鸡蛋还有汤。仔细来看如何演化而来,先来简单看下以下需求。
传入一个数值,计算数值乘以10再加上10,再减去2。
实现起来很简单。
const calc = (num) => num * 10 + 10 - 2; calc(10); // 108 复制代码
但这样写有个问题,很差扩展,好比我想乘以10
时就打印出结果。 为了便于扩展,咱们分开写成三个函数。
const multiply = (x) => { const result = x * 10; console.log(result); return result; }; const add = (y) => y + 10; const minus = (z) => z - 2; // 计算结果 console.log(minus(add(multiply(10)))); // 100 // 108 // 这样咱们就把三个函数计算结果出来了。 复制代码
再来实现一个相对通用的函数,计算这三个函数的结果。
const compose = (f, g, h) => { return function(x){ return f(g(h(x))); } } const calc = compose(minus, add, multiply); console.log(calc(10)); // 100 // 108 复制代码
这样仍是有问题,只支持三个函数。我想支持多个函数。 咱们了解到数组的reduce
方法就能实现这样的功能。 前一个函数
// 咱们经常使用reduce来计算数值数组的总和 [1,2,3,4,5].reduce((pre, item, index, arr) => { console.log('(pre, item, index, arr)', pre, item, index, arr); // (pre, item, index, arr) 1 2 1 (5) [1, 2, 3, 4, 5] // (pre, item, index, arr) 3 3 2 (5) [1, 2, 3, 4, 5] // (pre, item, index, arr) 6 4 3 (5) [1, 2, 3, 4, 5] // (pre, item, index, arr) 10 5 4 (5) [1, 2, 3, 4, 5] return pre + item; }); // 15 复制代码
pre
是上一次返回值,在这里是数值1,3,6,10
。在下一个例子中则是匿名函数。
function(x){ return a(b(x)); } 复制代码
item
是2,3,4,5
,在下一个例子中是minus、add、multiply
。
const compose = (...funcs) => { return funcs.reduce((a, b) => { return function(x){ return a(b(x)); } }) } const calc = compose(minus, add, multiply); console.log(calc(10)); // 100 // 108 复制代码
而Redux.compose(...functions)
其实就是这样,只不过中间件是返回双层函数罢了。
因此返回的是next函数
,他们串起来执行了,造成了中间件的洋葱模型。 人们都说一图胜千言。我画了一个相对简单的redux
中间件原理图。
redux
中间件原理图
若是还不是很明白,建议按照我给出的例子,多调试。
cd redux-analysis && hs -p 5000 # 上文说过npm i -g http-server 复制代码
打开http://localhost:5000/examples/index.3.html
,按F12
打开控制台调试。
lodash源码中 compose
函数的实现,也是相似于数组的reduce
,只不过是内部实现的arrayReduce
// lodash源码 function baseWrapperValue(value, actions) { var result = value; // 若是是lazyWrapper的实例,则调用LazyWrapper.prototype.value 方法,也就是 lazyValue 方法 if (result instanceof LazyWrapper) { result = result.value(); } // 相似 [].reduce(),把上一个函数返回结果做为参数传递给下一个函数 return arrayReduce(actions, function(result, action) { return action.func.apply(action.thisArg, arrayPush([result], action.args)); }, result); } 复制代码
koa-compose源码也有compose
函数的实现。实现是循环加promise
。 因为代码比较长我就省略了,具体看连接若川:学习 koa 源码的总体架构,浅析koa洋葱模型原理和co原理小节 koa-compose 源码
(洋葱模型实现)
打开http://localhost:5000/examples/index.4.html
,按F12
打开控制台,按照给出的例子,调试接下来的Redux.combineReducers(reducers)
和Redux.bindActionCreators(actionCreators, dispatch)
具体实现。因为文章已经很长了,这两个函数就不那么详细解释了。
combineReducers
函数简单来讲就是合并多个reducer
为一个函数combination
。
export default function combineReducers(reducers) { const reducerKeys = Object.keys(reducers) const finalReducers = {} for (let i = 0; i < reducerKeys.length; i++) { const key = reducerKeys[i] // 省略一些开发环境判断的代码... if (typeof reducers[key] === 'function') { finalReducers[key] = reducers[key] } } // 通过一些处理后获得最后的finalReducerKeys const finalReducerKeys = Object.keys(finalReducers) // 省略一些开发环境判断的代码... return function combination(state = {}, action) { // ... 省略开发环境的一些判断 // 用 hasChanged变量 记录先后 state 是否已经修改 let hasChanged = false // 声明对象来存储下一次的state const nextState = {} //遍历 finalReducerKeys for (let i = 0; i < finalReducerKeys.length; i++) { const key = finalReducerKeys[i] const reducer = finalReducers[key] const previousStateForKey = state[key] // 执行 reducer const nextStateForKey = reducer(previousStateForKey, action) // 省略容错代码 ... nextState[key] = nextStateForKey // 两次 key 对比 不相等则发生改变 hasChanged = hasChanged || nextStateForKey !== previousStateForKey } // 最后的 keys 数组对比 不相等则发生改变 hasChanged = hasChanged || finalReducerKeys.length !== Object.keys(state).length return hasChanged ? nextState : state } } 复制代码
若是第一个参数是一个函数,那就直接返回一个函数。若是是一个对象,则遍历赋值,最终生成boundActionCreators
对象。
function bindActionCreator(actionCreator, dispatch) { return function() { return dispatch(actionCreator.apply(this, arguments)) } } export default function bindActionCreators(actionCreators, dispatch) { if (typeof actionCreators === 'function') { return bindActionCreator(actionCreators, dispatch) } // ... 省略一些容错判断 const boundActionCreators = {} for (const key in actionCreators) { const actionCreator = actionCreators[key] if (typeof actionCreator === 'function') { boundActionCreators[key] = bindActionCreator(actionCreator, dispatch) } } return boundActionCreators } 复制代码
redux
所提供的的API
除了store.replaceReducer(nextReducer)
没分析,其余都分析了。
从源码实现上来看,vuex
源码主要使用了构造函数,而redux
则是多用函数式编程、闭包。
vuex
与 vue
强耦合,脱离了vue
则没法使用。而redux
跟react
没有关系,因此它可使用于小程序或者jQuery
等。若是须要和react
使用,还须要结合react-redux
库。
// logger 插件,具体实现省略 function logger (store) { console.log('store', store); } // 做为数组传入 new Vuex.Store({ state, getters, actions, mutations, plugins: process.env.NODE_ENV !== 'production' ? [logger] : [] }) // vuex 源码 插件执行部分 class Store{ constructor(){ // 把vuex的实例对象 store整个对象传递给插件使用 plugins.forEach(plugin => plugin(this)) } } 复制代码
vuex
实现扩展则是使用插件形式,而redux
是中间件的形式。redux
的中间件则是AOP(面向切面编程),redux
中Redux.applyMiddleware()
其实也是一个加强函数,因此也能够用户来实现加强器,因此redux
生态比较繁荣。
相对来讲,vuex
上手相对简单,redux
相对难一些,redux
涉及到一些函数式编程、高阶函数、纯函数等概念。
文章主要经过一步步调试的方式按部就班地讲述redux
源码的具体实现。旨在教会读者调试源码,不害怕源码。
面试官常常喜欢考写一个redux
中间件,说说redux
中间件的原理。
function logger1({ getState }) { return next => action => { const returnValue = next(action) return returnValue } } 复制代码
const compose = (...funcs) => { if (funcs.length === 0) { return arg => arg } if (funcs.length === 1) { return funcs[0] } // 箭头函数 // return funcs.reduce((a, b) => (...args) => a(b(...args))) return funcs.reduce((a, b) => { return function(x){ return a(b(x)); } }) } 复制代码
const enhancerStore = Redux.create(reducer, Redux.applyMiddleware(logger1, ...)) enhancerStore.dispatch(action) 复制代码
用户触发enhancerStore.dispatch(action)
是加强后的,其实就是第一个中间件函数,中间的next
是下一个中间件函数,最后next
是没有加强的store.dispatch(action)
。
最后再来看张redux
工做流程图
是否是就更理解些了呢。
若是读者发现有不妥或可改善之处,再或者哪里没写明白的地方,欢迎评论指出。另外以为写得不错,对你有些许帮助,能够点赞、评论、转发分享,也是对个人一种支持,很是感谢呀。要是有人说到怎么读源码,正在读文章的你能推荐个人源码系列文章,那真是太好了。
@胡子大哈:动手实现 Redux(一):优雅地修改共享状态,总共6小节,很是推荐,虽然我很早前就看完了《react小书》,如今再看一遍又有收获
美团@莹莹 Redux从设计到源码,美团这篇是我基本写完文章后看到的,感受写得很好,很是推荐
redux 中文文档
redux 英文文档
若川的学习redux源码仓库
面试官问:JS的继承
面试官问:JS的this指向
面试官问:可否模拟实现JS的call和apply方法
面试官问:可否模拟实现JS的bind方法
面试官问:可否模拟实现JS的new操做符
做者:常以若川为名混迹于江湖。前端路上 | PPT爱好者 | 所知甚少,惟善学。
若川的博客,使用vuepress
重构了,阅读体验可能更好些
掘金专栏,欢迎关注~
segmentfault
前端视野专栏,欢迎关注~
知乎前端视野专栏,欢迎关注~
语雀前端视野专栏,新增语雀专栏,欢迎关注~
github blog,相关源码和资源都放在这里,求个star
^_^~
可能比较有趣的微信公众号,长按扫码关注。欢迎加我微信ruochuan12
(注明来源,基原本者不拒),拉你进【前端视野交流群】,长期交流学习~
本文使用 mdnice 排版