ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零手写Redux核心:状态管理、中间件与不可变更新

从零手写Redux核心:状态管理、中间件与不可变更新 1. Redux 的三条铁律以及它们到底挡掉了哪些问题我在很多项目里见过 Redux 的错误用法也见过不少人把它当成一个非用不可的负担塞进项目里。说实话Redux 本身的设计非常优雅核心逻辑少到可以用几十行代码讲清楚。如果你只是照着文档用永远只能看到它的表面createStore、dispatch、reducer、connect这些名字你都能背出来但一旦遇到异步、中间件顺序、selector 缓存这类问题时就开始抓瞎了。所以我自己带人入门的时候一直坚持让他们从零实现一个简易 Redux只有亲手写一遍核心原理才会真正长在脑子里。这篇文章就是想带着你把这套核心原理完整走一遍。1.1 单一数据源为什么不是全局变量这么简单Redux 的第一条原则是单一数据源整个应用只有一个 storestore 里保存一份完整的 state 树。很多人觉得这不就是一个全局变量嘛有什么稀奇的。实际上它的区别在于——全局变量你可以随时改、随处改改完还没人知道而 Redux 的 store 看起来像全局变量但它把读和写的通道全部收口了。读只能通过 getState写只能通过 dispatch 派发 action。这意味着任何一次状态变化都是经过同一道闸门进来的中间处理的逻辑可以被统一拦截、记录、扩展。你想想看如果状态散落在十个组件各自的内存里A 组件改了自己的状态B 组件怎么感知要么靠事件广播要么靠 props 层层传要么靠父级 setState 强制刷新每一种都是临时方案。单一 store 的价值不是有一个全局对象而是全局对象只有唯一的修改入口。这个设计带来的直接好处是调试方便。你打开 Redux DevTools能看到每一次 action 是什么时候派发的、派发前是什么状态、派发后变成什么状态。这种时间旅行的能力完全建立在所有修改都经过 dispatch这个前提上。如果你的代码里有二十个 setState 在偷偷改状态那 DevTools 看到的只是剪刀手剪辑后的结果很多信息就丢了。1.2 只读状态和纯 reducer一加一等于二永远不会等于别的第二条原则state 是只读的你不能直接改它必须通过 reducer 返回一个新的 state 来替换它。第三条原则reducer 必须是纯函数同样的输入永远得到同样的输出。很多人对不可变更新有误解以为这只是 Redux 社区的一种代码洁癖。其实不是。Redux 依赖引用相等来判断状态是否变化如果 reducer 直接改了原 state 里的某个字段然后原样返回同一个对象引用Redux 的 diff 逻辑就完全失效了。订阅了 store 的组件应该重新渲染却不重新渲染DevTools 里也看不到任何变化记录整个系统就像一个假装在工作的空转齿轮。我用一句话概括这三条铁律的内在逻辑把状态管理从多方随意读写收敛成一个入口、一次派发、一份新状态的单向数据流。数据流是死的但死的数据流反而让应用变得可预测。你在任何时刻打开 app只要知道当前 state 和最近一个 action就能推算出未来所有状态。这就是纯函数带给你的确定性。熟悉了这些原则下面我们就从最核心的 createStore 开始动手写。2. 28 行代码写出 createStore状态容器的核心算法先别急着拿官方源码闭上眼睛想一个问题如果你要设计一个状态容器最少需要哪几个能力读状态、改状态、监听状态变化就这三个。Redux 给每个能力都起了名字getState、dispatch、subscribe。所以 createStore 的骨架其实特别简单就是一个闭包套着一个 state外加三组操作函数。2.1 createStore 的接口设计和 reducer 的接入方式createStore 接受两个参数reducer 和 preloadedState。reducer 是一个纯函数签名是(state, action) newState。它第一次执行的时候需要有个初始 stateRedux 的做法是在 createStore 内部 dispatch 一个特殊的初始化 action让 reducer 返回我们传进去的 preloadedState或者 reducer 自己定义的默认 state。实际写代码的时候我会加一个isDispatching标志。为什么需要这个标志因为 reducer 执行期间必须保持世界静止如果 reducer 内部又调用了 store.getState()拿到的可能是处于中间态的数据这个数据是不应该被外部看到的如果在 reducer 内部再派发一个 action那整个 reducer 的执行栈就乱套了。官方 Redux 对这两种情况都会直接抛错我们也要把这个保护逻辑写出来。2.2 getState、dispatch、subscribe 的最小实现下面是 createStore 核心代码大概二十来行。我们暂时不处理 enhancer也不做 replaceReducer只保留最核心的机制。function createStore(reducer, preloadedState) { let currentState preloadedState let currentReducer reducer let listeners [] let isDispatching false function getState() { if (isDispatching) { throw new Error(不能在 reducer 执行过程中读取状态) } return currentState } function dispatch(action) { if (isDispatching) { throw new Error(reducer 内部不能再派发 action) } try { isDispatching true currentState currentReducer(currentState, action) } finally { isDispatching false } for (let i 0; i listeners.length; i) { listeners[i]() } return action } function subscribe(listener) { if (typeof listener ! function) { throw new Error(订阅者必须是函数) } listeners.push(listener) let isSubscribed true return function unsubscribe() { if (!isSubscribed) return isSubscribed false const index listeners.indexOf(listener) listeners.splice(index, 1) } } dispatch({ type: redux/INIT }) return { getState, dispatch, subscribe } }这段代码有几个细节值得展开说。第一listener 的调用放在 reducer 执行完之后且不在 try 块里。这是刻意的listeners 是外部传入的函数如果某个 listener 抛异常不能影响 store 本身的完整性error 就让它沿着调用栈冒出去由调用方处理。第二subscribe 返回的 unsubscribe 函数我用isSubscribed标志防止重复取消订阅。重复调用 unsubscribe 不会报错只会在第一次执行时真正移除监听器。这算是我踩过的一个坑——如果不加这个标志同一个 listener 被 splice 两次第一次移除后 index 会变成 -1listeners.splice(-1, 1)会把整个数组的最后一个元素误删掉。第三dispatch({ type: redux/INIT })这一步不能省。它让 reducer 在 store 创建的第一时间执行一次确保 currentState 被正常初始化。或者举个例子如果你的 reducer 写了switch (action.type)但没有任何 default 分支初始化 action 就会返回 undefined那所有后续操作都无从谈起。官方 Redux 在这里用redux/INIT加上随机数字前缀用来避免和业务 action 撞名。我们简化版直接用普通字符串即可。2.3 两个必须处理的边界初始化 action 与 dispatch 重入关于 dispatch 重入我刚才在代码里已经抛了异常但我想单独强调一下它的必要性。官方 Redux 的错误提示是 Reducers may not dispatch actions.我第一次看到这句话没太当回事直到有一次在 reducer 里写了异步触发 dispatch 的代码结果状态更新时组件的生命周期里又连续派发了几个 action整个执行顺序完全不可控页面渲染出来的数据错得莫名其妙。我用一个表格来说明 dispatch 重入的场景和后果场景不处理的结果加上 isDispatching 保护后reducer 执行中调用 getState拿到中间态数据数据不完整直接抛错提前暴露 bugreducer 执行中调用 dispatch状态更新嵌套监听从中间态出发数据错乱直接抛错中断执行listener 里调用 dispatch属于正常场景允许正常执行当前 dispatch 已结束你要记住在这里抛错不是给用户添麻烦而是把不该发生的状态变更掐死在源头。生产环境里宁可看到一个明确的报错也不要看到数据秘之变来变去。3. 中间件机制把 dispatch 变成一条装配流水线createStore 写完以后你手里这个 store 已经可以跑通最基本的单向数据流了dispatch 一个 actionreducer 算新状态listener 被通知。但真实项目里远远不够最典型的问题是异步。action 得等接口返回之后再派发dispatch 一个普通的 action 对象显然做不到怎么办3.1 为什么需要中间件中间件到底改了什么Redux 的答案是中间件。刚接触这个概念的时候我特别迷惑中间件到底是改 reducer 还是改 dispatch其实都不改——中间件是包装 dispatch 的函数。理解这件事需要先接受一个转换dispatch 不一定是那个原始函数它可以被一层层包上装饰逻辑。打个比方createStore 里的 dispatch 是一条裸线中间件就是往线上串联的插座每个插座都能对通过电流做点什么最后你插上设备时得到的电流已经是经过所有插座处理的电流。下面是一个最常用的 logger 中间件长什么样const loggerMiddleware ({ getState, dispatch }) (next) (action) { console.log(老状态, getState()) console.log(action, action) const result next(action) console.log(新状态, getState()) return result }它的结构是一个三层的柯里化函数。第一层拿到 getState 和 dispatch第二层拿到 next第三层拿到 action。这个三层结构不是 Redux 的专利很多框架的插件机制都用类似模式。中间件要做的事非常简单在 next(action) 之前拦截在 next(action) 之后收尾。3.2 applyMiddleware 与 compose 的组合逻辑现在问题来了多个中间件怎么串起来Redux 提供了一个 compose 工具函数把多个中间件合成一个从右往左执行的链路。也就是说最后一个中间件最接近原始 dispatch第一个中间件最靠近业务代码。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))) }这段代码网上到处都是你直接背也可以但理解了才有意义compose 接收一组函数返回一个新函数这个新函数从右往左执行所有函数把上一个函数的返回值作为下一个函数的入参。applyMiddleware 用到的就是 compose 的这个特性function applyMiddleware(...middlewares) { return (createStore) (...args) { const store createStore(...args) let dispatch () { throw new Error(中间件构造期间不允许 dispatch) } const middlewareAPI { getState: store.getState, // 注意这里故意用闭包变量 dispatch而不是 store.dispatch dispatch: (action) dispatch(action) } const chain middlewares.map((middleware) middleware(middlewareAPI)) dispatch compose(...chain)(store.dispatch) return { ...store, dispatch } } }3.3 一个常见误区为什么中间件里不能用 store.dispatch这是我在面试里经常问的一个点很多人答不上来。仔细看 middlewareAPI 的 dispatch它传的是(action) dispatch(action)指向的是后面重新赋值的那个 dispatch而不是 store.dispatch。为什么不能直接用 store.dispatch因为 store.dispatch 是没经过任何中间件包装的裸函数。如果中间件内部拿到的 dispatch 是裸函数那它派发的任何 action 都会直接走原始 dispatch完全绕过中间件链条中间件后面的所有逻辑都失效了。用闭包变量 dispatch 的好处是当所有中间件组合完毕、dispatch 被重新赋值之后中间件内部拿到的 dispatch 也跟着指向了完整的、包装好的新函数。你可以在中间件里放心地调用dispatch(someAction)这个调用会从整条流水线的最外层重新走一遍也就是说 action 会被所有中间件再处理一次。thunk 中间件就是靠这个能力实现异步的const thunkMiddleware ({ dispatch, getState }) (next) (action) { if (typeof action function) { return action(dispatch, getState) } return next(action) }当你在业务代码里派发一个函数thunk而不是对象时它先被 dispatch 接收到发现是函数就直接调用这个函数把完整的 dispatch 和 getState 传进去。这就实现了先发一个异步请求等数据回来后再派发真正的 action 对象的流程。中间件整个机制的关键就是给这句话注入了生命力dispatch 可以处理的不只是普通对象它可以是任何东西只要中间件接得住。4. connect 与 React 的绑定把 store 传进组件树的正确姿势store 写好了中间件也通了接下来就遇到实际问题React 组件怎么拿到 store两种思路摆在面前一种是把这个 store 作为 props 一层层从 App 传下去另一种是把它塞进 Context 里。Redux 官方走的是第二种因为组件树一旦深了逐层传 props 的体验简直是一场灾难。4.1 Context 传 store而不是 props 逐层传React 提供 Context 机制我们可以创建一个 Context 对象在应用根部把 store 注入进去随便哪一层子组件都可以直接读取。import React from react export const ReduxContext React.createContext(null)Provider 组件的实现也非常简单就是把 store 放到 Context 的 value 上然后正常渲染子组件。这一步没有任何魔法组件树之所以能共享这个 Context靠的是 React 自己的上下文机制。4.2 mapStateToProps 和 mapDispatchToProps 的职责划分有了 Context 里的 storeconnect 要做的事就清晰了它是一个高阶组件接收两个映射函数返回一个新的组件包装器。mapStateToProps 接收完整的 store state 和组件自身的 props返回一个切片对象这个对象会被展开传给被包装组件。mapDispatchToProps 接收 dispatch 和自身 props返回一组组已经绑定 dispatch 的事件函数。例如const mapStateToProps (state, ownProps) ({ count: state.counter.value }) const mapDispatchToProps (dispatch) ({ increment: () dispatch({ type: INCREMENT }) })这么设计的好处非常直接业务组件不会感知到 store 的存在它只管接收 count 和 increment完全不 care 数据到底来自哪个状态树、事件是怎么派发出去的。组件与 Redux 的耦合被两个映射函数隔离掉了。4.3 订阅 store 与组件刷新的完整闭环connect 包装出来的组件需要在 store 状态变化时重新渲染。最朴素的做法是在 componentDidMount 里调用 store.subscribe把强制重新渲染注册为一个 listener在 componentWillUnmount 里取消订阅。function connect(mapStateToProps, mapDispatchToProps) { return (WrappedComponent) { class Connect extends React.Component { componentDidMount() { this.unsubscribe this.context.subscribe(() this.forceUpdate()) } componentWillUnmount() { this.unsubscribe this.unsubscribe() } render() { const state this.context.getState() const stateProps mapStateToProps(state, this.props) let dispatchProps {} if (typeof mapDispatchToProps function) { dispatchProps mapDispatchToProps(this.context.dispatch, this.props) } return WrappedComponent {...this.props} {...stateProps} {...dispatchProps} / } } Connect.contextType ReduxContext return Connect } }这里的核心是 forceUpdate 和 subscribe 的组合connect 组件不依赖父组件传新的 props 就能自行重新渲染。任何一次 store 里的 dispatch都会触发 listeners 链表上的所有 listener所有被 connect 包裹的组件都会 forceUpdate然后重新执行 mapStateToProps把最新的数据切片传给真正的业务组件。这里有一个性能隐患listeners 的执行是无差别的不管状态变化跟这个组件有没有关系只要有人 dispatch 它就会重新计算。所以 Redux 官方在 connect 内部做了浅比较优化如果 mapStateToProps 返回的结果和上一次的引用相同就会跳过这次不必要的重新渲染。这个优化思想我们在下一节接着展开。5. 补全到可以实际使用combineReducers、缓存与内存陷阱到这里核心机制已经齐了但离真实项目还缺一块拼图一个超大型 state 树不可能只由一个 reducer 管理我们得把 reducer 拆分成多个模块再组合起来。这个组合工具就是 combineReducers。5.1 一个能用 20 行的 combineReducerscombineReducers 的思想说起来很简单接受一个{ counterReducer, todoReducer }这样的对象返回一个新 reducer。这个新 reducer 被调用时把 state 按 key 拆开分给对应 reducer每个 reducer 负责算出自己那块的新值最后再拼成一颗新 state 树。function combineReducers(reducers) { return (state {}, action) { const nextState {} let hasChanged false for (const key of Object.keys(reducers)) { const previousStateForKey state[key] const nextStateForKey reducers[key](previousStateForKey, action) if (typeof nextStateForKey undefined) { throw new Error(reducer ${key} 返回了 undefined忘记处理 action 类型了) } nextState[key] nextStateForKey hasChanged hasChanged || nextStateForKey ! previousStateForKey } return hasChanged ? nextState : state } }这段代码真正体现出不可变更新的价值每个子 reducer 返回旧引用时说明这部分 state 没有变化只有当至少一个子 reducer 返回新引用时整棵状态树才生成新引用。hasChanged 变量救下的是一次又一次无意义的全树重新渲染。5.2 selector 缓存是性能关键但缓存到底缓存了什么真实项目的 mapStateToProps 不可能像上面那个例子那么简单经常要基于 state 做二次计算比如过滤列表、汇总金额。如果不做任何缓存store 每次变化所有 connect 组件的所有计算都要重跑一遍。reselect 的核心思路是把 mapStateToProps 里的计算单独拎出来封装成一个带缓存的 selector。缓存策略极其简单——记录上一次的输入和输出下一次被调用时如果输入引用和上一次完全相同直接返回上一次的输出。function createSelector(...selectors, resultFunc) { let lastInputs let lastResult return (...args) { const currentInputs selectors.map((selector) selector(...args)) if (lastInputs currentInputs.every((value, i) value lastInputs[i])) { return lastResult } lastInputs currentInputs lastResult resultFunc(...currentInputs) return lastResult } }这个思路和 Redux 两个核心特性配合得天衣无缝。第一个特性state 更新一定是不可变的所以 state 里任何引用没变的子树一定没有发生数据变化。第二个特性connect 组件判断要不要重新渲染只看 mapStateToProps 返回对象引用变没变。只要 selector 缓存了结果引用就不会变组件就不会重新渲染。5.3 不可变更新与引用相等Redux 性能模型的真正前提我见过不少人在 reducer 里把不可变更新写成浅拷贝 局部嵌套但嵌套层级一深就开始偷懒直接改内部字段然后返回同一引用。这种状态树的引用关系会在 combineReducers 的 hasChanged 判断里悄悄失效后续的 selector 缓存和 React-Redux 的重渲染短路也一起失效。举个例子// 错误写法 case UPDATE_NAME: state.user.name action.name return state // 正确写法 case UPDATE_NAME: return { ...state, user: { ...state.user, name: action.name } }这两种写法的区别不是更新了对象和没更新对象的区别而是引用变了与引用没变的区别。Redux 的性能模型完全建立在引用之上引用相等是它判断一切变化的唯一方式。一旦你用可变方式更新整个模型就从地基上塌了。我建议你在 practice 阶段配合 Object.freeze 使用 reducer。Object.freeze 会让对象在严格模式下无法被修改一旦你尝试直接改 state 里的字段立刻抛 TypeError。这不能解决所有问题但能帮你把所有写着写着就变成可变更新的坏习惯暴露出来。6. 和官方 Redux 的差异以及继续深挖的方向写到这里我们手里的这份简易 Redux已经能支撑真实业务的大部分场景createStore 管状态、applyMiddleware 管增强、connect 管 React 绑定、combineReducers 管拆分、selector 管缓存。这已经是一个九九成型的 Redux 了。最后我想讲讲这个简易版和官方 Redux 还有哪些差距以及这些差距背后的设计考量。6.1 我们简化了什么官方又多了什么官方 Redux 比我们多了一层 enhancer 概念。applyMiddleware 本质上是一个 enhancer它接管 createStore 的创建过程在 store 创建后、返回前对 dispatch 做包装。我们上面把 applyMiddleware 硬编码成了 createStore 的一个参数而官方把这种扩展方式泛化成了 createStore 的第三个参数 enhancer。另外一个差别是类型定义。官方版本里有完整的 TypeScript 类型声明decode 起来复杂得多但核心逻辑和我们写的完全一致。如果你想更深入了解我强烈建议你去看官方仓库里的 src/createStore.ts 和 src/applyMiddleware.ts你会发现那些看起来高深的类型体操下面是多么熟悉的面孔。官方还多了 replaceReducer可以用来做代码热替换或者动态加载 reducer。这个功能在基本运行时用不到但对微前端和大型应用模块化却很重要。6.2 现在的调试工具为什么能时空穿梭背后靠的是什么Redux DevTools 之所以能实现时空穿梭核心前提是我们刚才写过的三个特性所有状态变化必须经过 dispatch、reducer 是纯函数、state 是不可变的。开发者工具在中间件层拦截每一次 action把 action 和前后两个 state 引用完整记录在内存里。需要回放某一步时它把 dispatch 调出去reducer 重新计算一遍由于 reducer 是纯函数同样的 action 必然得到同样的结果历史状态就被精确还原了。我在自己的项目里试过用真实现法去追踪一个诡异 bug某个状态字段总是晚一步更新页面顺序全乱了。我在 DevTools 里回放了 action 序列发现有个中间件在异步回调里再次 dispatch 了 action导致两个 action 的先后顺序和预期完全相反。如果没有纯函数和单一数据源这种问题几乎不可能通过工具定位。所以我现在每次教别人 Redux都特别强调一件事它所有的酷炫能力都不是加法叠加出来的而是那三条铁律天然长出来的果实。最后再分享一个小建议。你自己实现完这个简易版之后可以顺手做两件事第一把 thunk 中间件再扩展一下让它支持 Promise感受一下中间件组合带来的无限可能第二给 logger 中间件加上只打印特定 action 类型的过滤配置你会发现中间件写起来就像搭乐高想插哪里插哪里。等这两件事都做完了你对 Redux 的理解一定会比看十遍文档都深。
返回列表