Redux 中间件实现原理

3,069 阅读8分钟

什么是柯里化

在了解 redux 中间件之前,有必要先了解一下什么是柯里化,redux 是基于此的,理解它你才能理解中间件是怎么工作的。

这里是高级程序设计一书对于柯里化的解释,先看看就好,有个大概印象。

它用于创建已经设置好了一个或多个参数的函数。函数的柯里化的基本使用方法和函数绑定是一样的:使用一个闭包返回一个函数。两者的区别在于,当函数被调用时,返回的函数还需要设置一些传入的参数

一个简单的加法函数封装

function add(n1, n2) {
    return n1 + n2
}
console.log(add(1, 2)); // 3

使用柯里化的思想改造 add 这个函数

function add (n1) {
    return function(n2) {
        return n1 + n2
    }
}
add(1)(2); // 3

看起来和闭包是一样的,不同在于使用柯里化改造的函数 return 的函数有入参。

使用箭头函数简化一下写法

const add = (n1) => (n2) => {
    return n1 + n2
}
add(1)(2); // 3

柯里化可以做些什么

1) 保存变量,延迟计算

柯里化可以和闭包一样保存函数内局部变量以共用,达到延时计算的目的。

  function currying(func) {
      const args = [];
      return function result(...rest) {
          if (rest.length === 0)
              return func(...args);
          args.push(...rest);
          return result;
      }
  }
  
  const add = (...args) => args.reduce((a, b) => a + b);
  
  const sum = currying(add);
  sum(1,2)(3);
  sum(4);
  sum(); // 10

2) 参数复用,确保每次只传入一个参数

参数复用

实现一个原生的 bind 方法,call 和 apply 会立即执行,但 bind 不会,是因为 bind 返回的是一个函数。

第一个参数传入环境上下文,绑定调用者使用时的 this 指向,执行时传入参数即可,不用再传入 context,它会持久保存。

Function.prototype.bind = function (context, ...args) {
    return (...rest) => {
        this.call(context, ...args, ...rest);
    }
}

确保每次只传入一个参数

// 多参数,可能和期望的结果不同
[11, 11, 11, 11].map(parseInt);
// 输出 [11, NaN, 3, 4],因为 parseInt 可以接收 2 个参数,控制进制转换
[11, 11, 11, 11].map((value) => { return parseInt(value) });
// 输出 输出 [11, NaN, 3, 4]

什么是中间件?

Redux 文档中的描述:

如果你使用过 Express 或者 Koa 等服务端框架, 那么应该对 middleware 的概念不会陌生。 在这类框架中,middleware 是指可以被嵌入在框架接收请求到产生响应过程之中的代码。例如,Express 或者 Koa 的 middleware 可以完成添加 CORS headers、记录日志、内容压缩等工作。middleware 最优秀的特性就是可以被链式组合。你可以在一个项目中使用多个独立的第三方 middleware。

Redux 中间件能用来做什么?

Redux middleware 可以进行日志记录、创建崩溃报告、调用异步接口或者路由等, 且可以被链式调用.

redux 是怎么拓展中间件的?

在讨论 redux 如何实现中间件的功能前, 我们先来回顾一下 redux 改变数据的过程.

redux数据流向 - 来自阮一峰的网志

其中, 视图层用户的操作, 我们无法控制. reducer 要求必须是纯函数, 所以也不合适在这里做操作. 那么适合添加的位置, 是位于 action 被发起之后,到达 reducer 之前的扩展点.

记录访问日志

如果我们想追踪用户的访问行为及 state 数据变动, 我们应该怎么做呢.

Step1: 手动记录

在 todo 应用中, 记录进行添加的动作。 可以在添加动作的前后打印信息,这是最简单的写法,也是复用率最低的写法,你需要在每个关键节点都添加。

console.log('action -> ' + 'addTodo');
store.dispatch(addTodo(text));
console.log('nextState -> ' + store.getState());

Step2: 封装 dispatch

为了解决复用率低的问题,我们封装一个函数, 实现 dispatch 功能, 每次分派 action 时, 使用封装的函数替代原生的 dispatch.

function dispatchAndLog(store, action) {
  console.log('action ->', action);
  store.dispatch(action);
  console.log('nextState ->', store.getState());
}

当用户触发动作时, 使用 dispatchAndLog 替换 dispatch

dispatchAndLog(store, action);

Step3: 让 redux 支持记录 log 日志

dispatchAndLog 是我们封装的一个方法, 可以实现打 log 日志的功能, 但需要记住函数名,还是比较麻烦,能更简洁点就好了. 打日志是一直存在的需求, 如果 redux 本身就支持, 那就方便多了, 开封即用.

// 先用 next 缓存 dispatch
let next = store.dispatch;
store.diapatch = function (store, action) {
  console.log('action ->', action);
  let result = store.dispatch(action);
  console.log('nextState ->', store.getState());
  // 返回 action
  return result;
}

到这里, 我们已经不用操心 log 日志了, 因为每次 dispatch, 都会自动记录 action 和 nextState.

Step4: 新的需求 - 错误报告日志

上面我们记录的日志, 可以算做用户的访问日志, 包含用户动作、 数据变化等, 可以帮助我们形成用户的访问漏斗, 做数据分析. 实际开发中, 我们常需要另一种日志, 错误报告的日志. 在不使用 redux 的开发中, 我们想还原用户的使用场景是比较难的, 我们只能捕获 Error 对象, 知道哪里出错了, 却不知道用户执行怎样的操作后出错了. redux 数据流动是单向的, 我们可以保存用户出错的节点的数据, 将连续的 action 与当前 state 变化上传到服务端, 再搭配用户 UA, 还原用户场景就变的容易多了.

function patchStoreToAddLogging(store, action) {
  let next = store.dispatch;
  store.dispatch = function(action) {
    console.log('action', action);
    let result = next(action);
    console.log('nextState', store.getState());
    return result;
  }
}

function patchStoreToAddCrashReporting(store, action) {
  let next = store.dispatch;
  store.dispatch = function(action) {
    try {
      return next(action);
    } catch (err) {
      console.error('捕获一个异常!', err)
      // ...
      // 在这里上报错误信息
      // {
      //   action,
      //   state: store.getState
      // }
      throw err;
    }
  }
}

我们可以这样使用.

patchStoreToAddLogging(store, action);
patchStoreToAddCrashReporting(store, action);

刚刚完成打印日志的简化, 面对汹涌而来的需求,我们又要写一堆代码了,如果我们要实现 5 个中间件的功能, 触发一次 action, 要写 5 次才行, 可见刚刚的简化方法并不完美.

Step5: 让 redux 提供一个使用 middleware 的方法

现在都是每次执行一个中间件时, 我们手动替换 dispatch, 如果 redux 提供一个可以定制化的接口, 顺序执行传入的中间件, 不就更方便了么.

我们之前的做法, 每次执行时替换 dispatch 方法

function logger(store) {
  let next = store.dispatch;
  return function dispatchAndLog(action) {
    console.log('action ->', action);
    let result = next(action);
    console.log('nextState ->', store.getState());
    return result;
  }
}

为什么每次都要替换 store.dispatch 方法呢? 这样第二个中间件执行时, 缓存的 next 实际上是第一个中间件替换后的 dispatch, 实现 next(next(next(...args))) 链式调用. dispatch 一个 action, 可以被所有声明的中间件执行.这种形式的函数有个学术上的名词,叫洋葱模型。

function applyMiddlewareByMonkeypatching(store, middlewares) {
  middlewares = middlewares.slice();
  middlewares.reverse();

  // 在每一个 middleware 中变换 dispatch 方法。
  middlewares.forEach(middleware =>
    store.dispatch = middleware(store);
  )
} 

使用多个 middleware

applyMiddlewareByMonkeypatching(store, [ logger, crashReporter ])

Step6: 使用柯里化简化代码

每次通过 next 保存 dispatch 都是通过 store 获取, 让我们用柯里化的思想来简化一下, 通过 next 直接保存上一个中间件返回的函数, 不从 store 中获取.

至于 store、next、action 什么时候传入, 后面再分解.

通过 next 传入前一个 middleware 包装过的 dispatch

function logger(store) {
  return function wrapDispatchToAddLogging(next) {
    return function dispatchAndLog(action) {
      console.log('action ->', action);
      let result = next(action);
      console.log('nextState ->', store.getState());
      return result;
    }
  }
}

使用 ES6 箭头函数简化一下, 看着就很简洁了.

const logger = store => next => action => {
  console.log('dispatching', action);
  let result = next(action);
  console.log('next state', store.getState());
  return result;
}

const crashReporter = store => next => action => {
  try {
    return next(action);
  } catch (err) {
    console.error('Caught an exception!', err);
    // ...
    throw err;
  }
}

middleware 接收了一个 next() 的 dispatch 函数,并返回一个 dispatch 函数,返回的函数会被作为下一个 middleware 的 next(),以此类推。由于 store 中类似 getState() 的方法很有用,我们将 store 作为顶层的参数,使得它可以在所有 middleware 中被使用.

Step 7: 改造一下 applyMiddlewareByMonkeypatching

我们创建一个方法 applyMiddleware 替换 applyMiddlewareByMonkeypatching

function applyMiddleware(store, middlewares) {
  middlewares = middlewares.slice()
  middlewares.reverse()

  let dispatch = store.dispatch
  middlewares.forEach(middleware =>
    // store 提供给 middleware 获取 api 和 state, 对应 middleware 第一个函数入参
    // dispatch 对应第二个函数入参
    dispatch = middleware(store)(dispatch)
  )
  return Object.assign({}, store, { dispatch })
}

在 redux 中应用

import { createStore, combineReducers, applyMiddleware } from 'redux'

let todoApp = combineReducers(reducers)
let store = createStore(
  todoApp,
  // applyMiddleware() 告诉 createStore() 如何处理中间件
  applyMiddleware(logger, crashReporter)
)

现在任何被发送到 store 的 action 都会经过 logger.

Step 8: 在生产环境使用 redux middleware

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
...


const store = createStore(reducer, {}, applyMiddleware(thunk));
// redux version: 3.7.2
export default function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState, enhancer) => {
    // 定制化 dispatch
    const store = createStore(reducer, preloadedState, enhancer)
    let dispatch = store.dispatch
    let chain = []
    // 提供给中间件使用的 Api
    const middlewareAPI = {
      getState: store.getState,
      dispatch: (action) => dispatch(action)
    }
    // 将 Api 传递给 middleware, 并缓存中间件
    chain = middlewares.map(middleware => middleware(middlewareAPI))
    // 将中间件串联起来执行. fn1, fn2 => fn1(fn2(..args))
    dispatch = compose(...chain)(store.dispatch)
    // 返回增强后的 store 对象
    return {
      ...store,
      dispatch
    }
  }
}

实现一个简单的 redux 中间件

通过查看 redux applyMiddleware Api 源码, 发现每个 middleware 接受 Store 的 dispatch 和 getState 函数作为命名参数,并返回一个函数。该函数会被传入 被称为 next 的下一个 middleware 的 dispatch 方法,并返回一个接收 action 的新函数,这个函数可以直接调用 next(action),或者在其他需要的时刻调用,甚至根本不去调用它。调用链中最后一个 middleware 会接受真实的 store 的 dispatch 方法作为 next 参数,并借此结束调用链。所以,middleware 的函数签名是 ({ getState, dispatch }) => next => action。

const printStateMiddleware = ({ getState }) => next => action => {
  console.log('beforeState ->', getState());
  let returnValue = next(action);
  console.log('afterState ->', getState());
  return returnValue;
}

使用这个中间件,输出:

beforeState -> {todos: Array(1), visibilityFilter: "show_all"}
index.js:13 afterState -> {todos: Array(2), visibilityFilter: "show_all"}

redux 中间件 redux-thunk

看一下 redux-thunk 源码, 接收参数的形式和上面我们实现的简单 demo 是一致的,如果判断应传入的 action creator 是个函数,则将 dispatch 和 getState 传给这个函数,如果不是函数,继续执行 next 函数。

function createThunkMiddleware(extraArgument) {
 return ({ dispatch, getState }) => next => action => {
  if (typeof action === 'function') {
   return action(dispatch, getState, extraArgument);
  }
  return next(action);
 };
}
 
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
 
export default thunk;

拓展阅读

洋葱模型