前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

手写一个迷你版 Redux,从 createStore 到中间件

首页2018-07-23 09:20:24Front-End
JavaScriptReactRedux源码实现

面试里被问「Redux 的原理是什么」,背几句「单一数据源、状态只读、纯函数修改」其实过不了关,因为面试官下一句多半是「那你说说 applyMiddleware 是怎么把一串中间件串起来的」。我第一次被问到这儿的时候卡住了,回去把 Redux 的核心文件翻了一遍才发现,去掉类型检查和报错信息,真正干活的代码不到一百行。

这篇就把这一百行自己写一遍。从 createStore 的三件套开始,一路写到 compose、applyMiddleware 和 bindActionCreators,最后手搓一个 thunk 中间件。读完你应该能不看文档把 store 的骨架默出来。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • createStore 里 getState / dispatch / subscribe 三个方法各自在扛什么
  • 为什么 createStore 一定要在返回之前先 dispatch 一次 @@INIT
  • compose 那行 reduce 到底把函数嵌成了什么形状
  • applyMiddleware 怎么用一层层洋葱包住原始的 dispatch
  • bindActionCreators 解决的是哪个具体的重复劳动
  • 手写一个 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。

fe
  • 一、先把 store 的形状定下来
    • 1.1 那个 @@INIT 是干什么的
  • 二、compose,把函数嵌套变成一行 reduce
  • 三、applyMiddleware,洋葱是怎么裹起来的
    • 3.1 enhancer 是怎么接上的
  • 四、bindActionCreators,省掉满屏的 dispatch
  • 五、把 store 送进 React
  • 六、自己造一个 thunk 中间件
  • 七、这些代码今天还有用吗
  • 总结
  • 参考

← React 之 context 跨层级传值原理与重渲染范围浅析 React 高阶组件 HOC 的两种实现与常见陷阱 →