前端知库

怎样手写 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,也可以直接传增强器,所以开头要判断一下。

JavaScript
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。