面试里被问「Redux 的原理是什么」,背几句「单一数据源、状态只读、纯函数修改」其实过不了关,因为面试官下一句多半是「那你说说 applyMiddleware 是怎么把一串中间件串起来的」。我第一次被问到这儿的时候卡住了,回去把 Redux 的核心文件翻了一遍才发现,去掉类型检查和报错信息,真正干活的代码不到一百行。
这篇就把这一百行自己写一遍。从 createStore 的三件套开始,一路写到 compose、applyMiddleware 和 bindActionCreators,最后手搓一个 thunk 中间件。读完你应该能不看文档把 store 的骨架默出来。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
createStore里getState/dispatch/subscribe三个方法各自在扛什么- 为什么
createStore一定要在返回之前先 dispatch 一次@@INIT compose那行reduce到底把函数嵌成了什么形状applyMiddleware怎么用一层层洋葱包住原始的dispatchbindActionCreators解决的是哪个具体的重复劳动- 手写一个 thunk 中间件,顺带看懂中间件的三层柯里化签名
- 这套手写代码在 Redux Toolkit 成为官方推荐之后还剩什么价值
# 一、先把 store 的形状定下来
Redux 对外只暴露一个东西,就是 store。store 上有三个方法,getState 读当前状态,dispatch 派发一个 action 触发状态更新,subscribe 注册一个回调,状态变了就通知你。
先说结论,Redux 的核心不是什么复杂算法,就是一个「函数 + 数组」。函数是 reducer,负责根据旧 state 和 action 算出新 state;数组是监听器列表,负责在算完之后挨个通知。
下面这段是最小可用的 createStore。它做的事情只有一件,把 state 关进闭包,只允许通过 dispatch 去动它:
export const createStore = (reducer, preloadedState, enhancer) => {
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState
preloadedState = undefined
}
if (enhancer) {
return enhancer(createStore)(reducer, preloadedState)
}
let currentState = preloadedState
let currentListeners = []
const getState = () => currentState
const subscribe = (listener) => {
currentListeners.push(listener)
// 返回取消订阅的函数,组件卸载时必须调它
return () => {
const index = currentListeners.indexOf(listener)
if (index >= 0) currentListeners.splice(index, 1)
}
}
const dispatch = (action) => {
currentState = reducer(currentState, action)
currentListeners.forEach(listener => listener())
return action
}
dispatch({ type: '@@INIT' })
return { getState, subscribe, dispatch }
}
这里有两处我在原来那版代码上改动过,得说明一下。
第一处是 currentState 的初始值。老写法里直接写死成 let currentState = {},跑起来是能跑,但它把「还没初始化」和「空对象」混成了一件事。真实的 Redux 是把第三个位置留给 preloadedState,初始值为 undefined,这样第一次 dispatch({type: '@@INIT'}) 的时候,reducer 拿到的 state 是 undefined,才会走 state = 初始值 那个默认参数分支。写成 {} 的话默认参数不生效,reducer 里的初始 state 就被吞掉了。这个坑我是在写 combineReducers 的时候才踩出来的。
第二处是 subscribe 的返回值。老写法只往数组里 push,没给你退出的路。组件里订阅完不取消,卸载之后监听器还挂在数组上,每次 dispatch 都会往一个已经不存在的组件里 setState。这就是最经典的一类内存泄漏。
# 1.1 那个 @@INIT 是干什么的
dispatch({ type: '@@INIT' }) 这行看起来像凑数的,其实它是整个初始化流程的开关。
reducer 的标准写法是 (state = 初始值, action) => {...},里面用 switch 匹配不到任何 case 时返回 state 原样。createStore 返回之前先派发一个没人认识的 action,所有 reducer 都会走到 default 分支,把自己的默认参数返回出来。这一轮跑完,currentState 就被填成了完整的初始状态树。
所以这个 action 的 type 必须是业务代码绝对不会用到的字符串。Redux 源码里用的是 '@@redux/INIT' + 随机字符串,加随机后缀就是为了防止有人真的写了个叫 @@redux/INIT 的 action type。
一行 dispatch 换来整棵状态树的初始化,这个设计我觉得挺漂亮。
# 二、compose,把函数嵌套变成一行 reduce
在写 applyMiddleware 之前,得先搞定 compose。它要解决的问题很直白,把 [fn1, fn2, fn3] 变成 fn1(fn2(fn3(...args)))。
// fn1(fn2(fn3())) 把函数嵌套依次调用
export function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((ret, item) => (...args) => ret(item(...args)))
}
原来那版这里有个 typo,两个判断分支里写的是 funs 而不是 funcs,直接跑会抛 funs is not defined。这次改对了。
关键在最后那行 reduce。它没给初始值,所以第一轮的 ret 就是 funcs[0],item 是 funcs[1],返回 (...args) => fn1(fn2(...args))。第二轮这个新函数又变成 ret,item 是 funcs[2],套成 (...args) => fn1(fn2(fn3(...args)))。
顺着上面聊,注意执行顺序和数组顺序是反的。数组里排最后的 fn3 最先被调用,它的返回值交给 fn2,再交给 fn1。这个顺序在下一节的中间件里会直接决定「谁先看到 action」,别搞反了。
# 三、applyMiddleware,洋葱是怎么裹起来的
中间件要干的事是「在 action 到达 reducer 之前插一脚」。日志中间件想打印,thunk 中间件想把函数类型的 action 拦下来执行掉,saga 中间件想把 action 转发给 generator。
它们共用同一个签名,三层柯里化:
const middleware = ({ getState, dispatch }) => next => action => {
// 这里可以做任何事,然后决定要不要调 next(action)
return next(action)
}
第一层拿到 store 的精简 API,第二层拿到「下一个中间件的 dispatch」,第三层才是真正的 action。为什么非要拆成三层?因为这三样东西是在三个不同时机才能拿到的。store 在 applyMiddleware 执行时就有了,next 要等所有中间件排好队才知道是谁,action 得等运行时用户真的 dispatch 了才有。
有了 compose,串联本身只有一行:
export function applyMiddleware(...middlewares) {
return createStore => (...args) => {
const store = createStore(...args)
let dispatch = store.dispatch
const midApi = {
getState: store.getState,
// 这里必须用函数包一层,不能直接写 dispatch
dispatch: (...args) => dispatch(...args)
}
const middlewaresChain = middlewares.map(middleware => middleware(midApi))
dispatch = compose(...middlewaresChain)(store.dispatch)
return {
...store,
dispatch
}
}
}
老版本这段有三处写坏了,我一并修了:export applyMiddleWare(...) 少了 function 关键字;return createStore=>...args=> 里的剩余参数没加括号,正确写法是 (...args) =>;函数体最后少一个右花括号,整段是编译不过的。
midApi.dispatch 那行是这段代码里最容易被抄错的地方。它写成 (...args) => dispatch(...args) 而不是直接 dispatch,是因为此刻的 dispatch 还是原始的 store.dispatch,下一行才会被重新赋值成包装后的版本。用箭头函数包一层,取值就推迟到了真正调用的时候,中间件里调 dispatch 才能走完整条链路而不是绕过所有中间件。
这个坑我踩过。当时在中间件里想「重新派发一个 action 让别的中间件也处理一遍」,结果发现日志中间件一条都没打,排查了半天才定位到是取值时机的问题。
# 3.1 enhancer 是怎么接上的
回到第一节的 createStore,开头那几行 if (enhancer) return enhancer(createStore)(reducer, preloadedState) 现在就说得通了。
applyMiddleware(...) 的返回值正好是 createStore => (...args) => store 这个形状,把它当 enhancer 传进 createStore,createStore 就把自己交出去,让 enhancer 拿去重新组装。组装完返回的还是一个 store,只不过 dispatch 被换成了带中间件的版本。
所以 Redux 的中间件机制严格来说不是 createStore 的功能,是 enhancer 的一个应用。applyMiddleware 只是官方提供的、最常用的那个 enhancer。