怎样手写 Redux 的 createStore、combineReducers、applyMiddleware 和 compose?
createStore 用闭包保存 state 和监听函数,dispatch 调 reducer 算出新 state 再通知订阅者,创建时先派发一个初始化 action 拿到默认值。combineReducers 把每个 key 交给各自的 reducer,所有分支都没变时返回原对象。applyMiddleware 是一个 store 增强器,把中间件用 compose 串起来包住原始 dispatch,每个中间件的形状是 ({ getState, dispatch }) => next => action。
简答
createStore(reducer)返回一个 store,里面用闭包存着当前的state和一组监听函数。getState返回 state,subscribe添加监听并返回取消函数,dispatch(action)调用reducer(state, action)算出新 state,然后挨个通知监听函数。创建时它会先派发一个内部的初始化 action,让 reducer 返回默认值。
combineReducers({ count, user })把一个大 reducer 拆成几个小的:每个 key 只交给对应的 reducer 处理自己那一块 state。它还会比较每一块前后是不是同一个引用,全都没变就返回原来的 state 对象,这样订阅方用引用比较就能知道有没有更新。中间件用来在 action 到达 reducer 之前做事情,比如打日志、处理函数形式的异步 action。每个中间件的形状是
({ getState, dispatch }) => next => action,next是下一个中间件的 dispatch。applyMiddleware用compose把它们一层层包在原始dispatch外面,返回一个 dispatch 被换掉的 store。要注意,
compose(f, g, h)的执行顺序是f(g(h(x))),所以写在前面的中间件在最外层,最先拿到 action。另外 Redux 官方现在把createStore标为不推荐,建议用 Redux Toolkit 的configureStore,但createStore不会被删除,手写它仍是理解 Redux 的好办法。
先写 compose 和 createStore
compose 把多个函数从右往左串起来,手写思路见《怎样手写函数柯里化和 compose?》。这里直接用 Redux 源码里的写法。createStore 的第二个参数既可以是初始 state,也可以直接传增强器,所以开头要判断一下。
function compose(...funcs) {
if (funcs.length === 0) return (arg) => arg;
return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
function createStore(reducer, preloadedState, enhancer) {
if (typeof preloadedState === 'function' && enhancer === undefined) {
enhancer = preloadedState;
preloadedState = undefined;
}
if (enhancer) return enhancer(createStore)(reducer, preloadedState);
let state = preloadedState;
let listeners = [];
let isDispatching = false;
function getState() {
return state;
}
function subscribe(listener) {
listeners.push(listener);
return function unsubscribe() {
listeners = listeners.filter((item) => item !== listener);
};
}
function dispatch(action) {
if (isDispatching) throw new Error('reducer 里不能 dispatch');
try {
isDispatching = true;
state = reducer(state, action);
} finally {
isDispatching = false;
}
// 先拷贝一份,监听函数里订阅或退订只影响下一次 dispatch。
listeners.slice().forEach((listener) => listener());
return action;
}
// 用一个不会被业务 reducer 处理的 action 拿到各个 reducer 的默认 state。
dispatch({ type: '@@mini-redux/INIT' });
return { getState, subscribe, dispatch };
}
function combineReducers(reducers) {
const keys = Object.keys(reducers);
return function combination(state = {}, action) {
let hasChanged = false;
const nextState = {};
for (const key of keys) {
const previous = state[key];
const next = reducers[key](previous, action);
nextState[key] = next;
hasChanged = hasChanged || next !== previous;
}
return hasChanged ? nextState : state;
};
}
function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState) => {
const store = createStore(reducer, preloadedState);
let dispatch = () => {
throw new Error('构建中间件时不能 dispatch');
};
const api = {
getState: store.getState,
dispatch: (action) => dispatch(action),
};
const chain = middlewares.map((middleware) => middleware(api));
dispatch = compose(...chain)(store.dispatch);
return { ...store, dispatch };
};
}
function count(state = 0, action) {
return action.type === 'add' ? state + 1 : state;
}
function user(state = { name: '游客' }, action) {
return action.type === 'login' ? { name: action.name } : state;
}
const logs = [];
const logger = () => (next) => (action) => {
logs.push(`进入 ${action.type ?? '函数'}`);
return next(action);
};
const thunk = ({ dispatch, getState }) => (next) => (action) =>
typeof action === 'function' ? action(dispatch, getState) : next(action);
const store = createStore(combineReducers({ count, user }), applyMiddleware(logger, thunk));
const before = store.getState();
store.dispatch({ type: 'unknown' });
console.log(store.getState() === before ? '同一个对象' : '新对象'); // 同一个对象
store.subscribe(() => logs.push(`通知 ${store.getState().count}`));
store.dispatch({ type: 'add' });
store.dispatch((dispatch) => dispatch({ type: 'login', name: '小明' }));
console.log(JSON.stringify(store.getState())); // {"count":1,"user":{"name":"小明"}}
console.log(logs.join(' | ')); // 进入 unknown | 进入 add | 通知 1 | 进入 函数 | 进入 login | 通知 1
createStore 里的几个细节
为什么创建时要先 dispatch 一次?因为 state 一开始是 undefined,reducer 的默认参数 state = 0 这时才会生效。Redux 官方文档说,创建 store 时会派发一个内部的假 action 来填充初始 state,业务 reducer 不用处理它,只要在收到 undefined 时返回初始值就行。示例里 count 和 user 也是这样拿到 0 和 { name: '游客' } 的。
为什么通知前要 slice() 一份?如果监听函数在执行时又调用了 subscribe 或取消订阅,直接遍历原数组会让这一轮的通知名单跟着变。先拷贝,这一轮通知的就是 dispatch 开始时的那批监听函数。Redux 源码里用的是另一种做法:维护当前和下一份两个监听列表,要修改时才复制,效果一样。
isDispatching 是为了阻止在 reducer 里 dispatch。reducer 应该是纯函数,见《Redux 的单向数据流为什么强调 reducer 的纯粹性?》,在里面再 dispatch 会让状态更新嵌套起来。Redux 源码遇到这种情况会抛出 “Reducers may not dispatch actions.”,在 reducer 执行期间调用 getState 和 subscribe 也会报错,示例只保留了 dispatch 这一条。
combineReducers 为什么要比较引用
示例里派发了一个谁都不处理的 unknown,getState() 前后是同一个对象。这是因为每个子 reducer 遇到不认识的 action 都原样返回自己那一块,combination 发现每一块都是原来的引用,就返回原来的 state,而不是新建一个内容相同的对象。Redux 源码也是这样判断的,此外它还会检查 key 的数量有没有变化。
这样做的好处是,订阅方用 === 就能判断状态有没有变化。React Redux 的 useSelector 默认也是用 === 比较前后两次选出来的值,来决定组件要不要重新渲染,没变化的分支保持原引用,选它的组件就不会白白重渲染。
官方的 combineReducers 还会做一些检查,比如用 undefined 调用每个 reducer,如果有 reducer 返回 undefined 就直接报错,提醒它必须返回初始值。示例为了突出主线,没有写这些检查。
applyMiddleware 怎样把中间件串起来
applyMiddleware 本身是一个 store 增强器,官方文档写的签名是 createStore => createStore:拿到原来的 createStore,返回一个新的。新的 createStore 先正常创建 store,再把 dispatch 换掉。
换的过程分两步。第一步,把 { getState, dispatch } 传给每个中间件,得到一组 next => action => ... 的函数。第二步,用 compose 把它们串起来,再传入原始的 store.dispatch。展开来就是 logger(thunk(store.dispatch)):thunk 拿到的 next 是原始 dispatch,logger 拿到的 next 是 thunk 包好的那层。所以 action 先进 logger,再进 thunk,最后才到 reducer。
日志里的顺序说明了这一点。派发函数时,先记下“进入 函数”,thunk 发现它是函数,就调用它,函数里又 dispatch 了一个 login,于是“进入 login”出现在后面。
为什么传给中间件的 dispatch 要写成 (action) => dispatch(action),而不是直接传 store.dispatch?因为 thunk 里调用的 dispatch 应该是包好中间件以后的那个,这样函数里派发的 action 也会重新经过整条中间件链。写成箭头函数,调用时才去读变量 dispatch,这时它已经被替换成最终版本了。在替换之前,变量 dispatch 是一个会抛错的函数,Redux 源码的提示是 “Dispatching while constructing your middleware is not allowed”,因为这时其他中间件还没装好。
常见追问
中间件和 store 增强器有什么区别?
中间件只包装
dispatch,官方说中间件能做的事,手动包装每次dispatch调用也能做到,只是放在一个地方更好管理。增强器能包装整个createStore,可以改getState、subscribe等任何东西,applyMiddleware本身就是一个增强器。官方也说,自己写增强器的情况很少,写中间件更简单。
多个增强器怎么一起用?
用
compose把它们组合成一个增强器再传给createStore。官方建议把applyMiddleware放在其他增强器前面,因为中间件可能是异步的,比如放在 DevTools 增强器之前,DevTools 才能看到 Promise 中间件之类发出的原始 action。