深入理解Redux中间件

思考

我们在 Redux 异步 Action 中经常使用这种写法:

export function getTodos(){
  return (dispatch,getStore) => {
    TodoApi.getTodos().then(result=>{
      dispatch({
      type:TODOLIST,
      data:result.data
      })
    })
  }
}

而正常的 Action 就看起来好像应该是这样:

export function getTodos(){
  return {
    type:TODOLIST,
    data:result.data
  }
}

为什么我们上一种写法能正常工作呢?Action不应该返回一个对象吗怎么返回了一个函数?

Action 之所以支持第一种写法,是因为我们引入了 redux-thunk 中间件。那么 redux-thunk 是怎么工作的?

让我们在本文中一步步剖析 middleware 中间件的实现原理。

什么是 middleware

  • middleware 是指可以被嵌入在框架接收请求到产生响应过程之中的代码。

  • middleware 最优秀的特性就是可以被链式组合。我们可以在一个项目中使用多个独立的第三方 middleware。

正因为 middleware 可以完成包括异步 API 调用在内的各种事情,了解它的演化过程是一件相当重要的事。

我们将以记录日志和创建崩溃报告为例,体会从分析问题到通过构建 middleware 解决问题的思维过程。

问题:记录日志

现在设想这么一个问题:我们需要在应用中每一个 Action 被发起以及新的 state 被计算完成时都将他们记录下来。当程序出现问题时,我们可以通过查阅日志找出是哪个 action 导致了 state 不正确。

尝试 1: 手动记录

假设,我们在获取Todo列表时这么调用

export function getTodos(){
  return {
    type:TODOLIST,
    data:result.data
  }
}
store.dispatch(getTodos())

为了记录这个action以及新的state,我们可以通过这种方式记录:

let action = getTodos();
console.log('dispatching', action)
store.dispatch(action)

虽然这么做可以达到想要的效果,可是我们并不想每次都这么多。

尝试 2: 封装 Dispatch

我们可以将上面的操作封装成一个函数

function dispatchWithLog(store,action){
    console.log('dispatching', action)
    store.dispatch(action)
}

然后我们这么调用它:

dispatchWithLog(store, getTodos())

我们已经接近了 middleware 的思想,但每次都要导入一个外部方法总归不大方便。

尝试 3: 替换 Dispatch

如果我们直接替换 store 实例中的 dispatch 函数会怎么样呢?

Redux store 只是一个包含一些方法的普通对象,同时我们使用的是 JavaScript,因此我们可以这样来包装 dispatch:

let next = store.dispatch
store.dispatch = function dispatchWithLog(action) {
  console.log('dispatching', action)
  let result = next(action)
  console.log('next state', store.getState())
  return result
}
  • 将 next 指向原生的dispatch。
  • 将 store.diapatch 变成我们自定义的函数。
  • 在这个自定义的函数中调用next,也就是原dispatch。

这样就完美地改写了dispatch,保留了原始功能,还添加了自定义的方法。离我们想要的已经非常接近了!

但直接替换 dispatch 令人感觉还是不太舒服,不过利用它我们做到了我们想要的。

问题: 捕获异常

如果我们想对 dispatch 附加超过一个的特殊处理,又会怎么样呢?

脑海中出现的另一个常用的特殊处理就是在生产过程中报告 JavaScript 的错误。

但是全局的 window.onerror 并不可靠,因为它在一些旧的浏览器中无法提供错误堆栈,而这是排查错误所需的至关重要信息。

试想当发起一个 action 的结果是一个异常时,我们将包含调用堆栈,引起错误的 action 以及当前的 state 等错误信息通通发到报告服务中,不是很好吗?这样我们可以更容易地在开发环境中重现这个错误。

然而,将日志记录崩溃报告 分离是很重要的。理想情况下,我们希望他们是两个不同的模块,也可能在不同的包中。否则我们无法构建一个由这些工具组成的生态系统。

按照我们的想法,日志记录和崩溃报告属于不同的模块,他们看起来应该像这样:

function patchStoreWithLog(store) {
  let next = store.dispatch
  store.dispatch = function dispatchWithLog(action) {
    console.log('dispatching', action)
    let result = next(action)
    console.log('next state', store.getState())
    return result
  }
}

function patchStoreWithReport(store) {
  let next = store.dispatch
  store.dispatch = function dispatchWithReportErrors(action) {
    try {
      return next(action)
    } catch (error) {
      console.error('捕获一个异常!', error)
      report({
          error, 
          action,
          state: store.getState()
      })
      throw error
    }
  }
}

如果这些功能以不同的模块发布,我们可以在 store 中像这样使用它们:

patchStoreWithLog(store)
patchStoreWithReport(store)
  • 第一个patchStoreWithLog将dispatch进行了第一层封装,他的next是store原生dispatch
  • 第二个patchStoreWithReport将dispatch进行了第二次封装,他的nextdispatchWithLog

这样我们就实现了对dispatch的多重处理。

尽管如此,这种方式看起来还是有一些啰嗦。

尝试 4: 隐藏 hack

我们之前的操作本质上是一种hack。

我们用自己的函数替换掉了 store.dispatch。如果我们不这样做,而是在函数中返回新的 dispatch 呢?

function logger(store) {
  let next = store.dispatch
  // 我们之前的做法:
  // store.dispatch = function dispatchAndLog(action) {
  return function dispatchWithLog(action) {
    console.log('dispatching', action)
    let result = next(action)
    console.log('next state', store.getState())
    return result
  }
}

function report(store){
  let next = store.dispatch
    // 我们之前的做法:
    // store.dispatch = function dispatchAndLog(action) {
    return function dispatchWithReport(action) {
    try{
      let result = next(action)
      return result
      }catch (error) {
          console.error('捕获一个异常!', error)
            report({
               error, 
               action,
               state: store.getState()
            })
          throw error
      }
    }
}

我们通过闭包存储了store,以便在action真正调用的时候供其访问。

我们可以在 Redux 内部提供一个可以将实际的 hack 应用到 store.dispatch 中的辅助方法:

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

然后像这样应用多个 middleware:

applyMiddleware(store, [ logger,report ])

上面的代码可能看起来一时难以理解,这也是中间件的精华所在。我们来具体分析一下:

middleware

每一个middleware都是高阶函数,它使用了函数柯里化的思想,分两步执行:

  • 第一步:接收一个store对象,利用闭包将其存储。返回一个函数,也就是第二步。
  • 第二步:接收一个具体的action,使用上一步存储的store.dispatch执行该action,并返回执行结果。

applyMiddleware

接收原始的store对象和一系列的middlewares

它遍历 middlewares 列表,进行以下操作:

  • 对每一个 middleware 进行第一步调用,入参为store对象,返回一个可执行函数

  • 将 store.dispatch hack可执行函数

这样一来,每一个 middleware 所存储的 store ,都是被上一个 middleware hack后的。也就是说,
我们在 report 中闭包存储的store对象,它的 dispatch 方法实际上是 logger 的dispatchWithLog

总体流程

  • 调用store.dispatch,实际上调用的是dispatchWithReport

  • 进行错误信息收集,然后调用next(action),实际上调用的是dispatchWithLog(action)

  • 进行日志记录,然后调用next(action),此时才真正调用原生 store.dispatch

  • 原生的 dispatch 返回一个 Reducer 可识别的对象,层层向外传递,交由 Reducer处理。

尝试 #5: 移除 hack

为什么我们要替换原来的 dispatch 呢 ?
就是每一个 middleware 都可以操作前一个 middleware 包装过的 store.dispatch。

如果没有在第一个 middleware 执行时立即替换掉 store.dispatch,
那么 store.dispatch 将会一直指向原始的 dispatch 方法。也就是说,第二个 middleware 依旧会作用在原始的 dispatch 方法。

还有另一种方式来实现这种链式调用的效果。就是将middleware柯里化为三步,
让 middleware 以方法参数的形式接收一个 next() 方法,而不是通过 store 的实例去获取,这样我们就可以避免对store.dispatch的hack

function logger(store) {
  return function wrapDispatch(next) {
    return function dispatchWithLog(action) {
      console.log('dispatching', action)
      let result = next(action)
      console.log('next state', 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)
    Raven.captureException(err, {
      extra: {
        action,
        state: store.getState()
      }
    })
    throw err
  }
}

这正是 Redux middleware 的样子。

如果要自己实现一个 middleware 应用到 redux 中,完全可以按照这种形式去写。

源码分析

我们的applyMiddleware 和 Redux 中 applyMiddleware() 的实现已经很接近了。有了上面的铺垫,让我们来分析一下真正的源码。

代码虽然只有不到20行,但看懂确实是不容易。

export default function applyMiddleware(...middlewares) { 
  return createStore => (...args) => { 
    const store = createStore(...args)  
    let dispatch = () => { 
      throw new Error(
      )
    }
    const middlewareAPI = {  // 定义API
      getState: store.getState, //注入 getStore方法
      dispatch: (...args) => dispatch(...args) //初始化dispatch
    }
    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)
    return {
      ...store,
      dispatch
    }
  }
}

middlewares

export default function applyMiddleware(...middlewares)

applyMiddleware接收第一个参数,他正是所有的middleware列表。

createStore 以及 reducers

return createStore => (...args) => {
  ... ...
}

applyMiddleware接收第二和第三个参数,他们分别是createStore reducers

为了保证只能应用 middleware 一次,它作用在 createStore() 上而不是 store 本身。

创建store

 const store = createStore(...args)  

利用传入的createStore和reducer和创建一个store

定义API

let dispatch = () => { 
      throw new Error(
      )
    }
    const middlewareAPI = {  // 定义API
      getState: store.getState, //注入 getStore方法
      dispatch: (...args) => dispatch(...args) //初始化dispatch
    }

这里有一个地方需要注意:

  • 并没有直接使用dispatch:dispatch,而是使用了dispatch:(action) => dispatch(action)

  • 如果使用了dispatch:dispatch,那么在所有的 Middleware 中实际都引用的同一个dispatch(闭包),
    那么一个中间件修改了dispatch,其他所有的dispatch都将被改变。

  • 所以这里使用dispatch:(action) => dispatch(action),每一个 middlewareAPI 的 dispatch 引用都是不同的

初始化

 const chain = middlewares.map(middleware => middleware(middlewareAPI))

让每个 middleware 带着 middlewareAPI 这个参数分别执行一遍,进行初始化。

得到的函数链为每个中间件的第一个返回函数,该函数可接收一个dispatch动作,再返回一个可以接收action的函数。

函数链中的每一个函数看起来像是这样:

middlewareAPI = {  
  getState: store.getState,
  dispatch: (...args) => dispatch(...args)
} 
// 这里 middlewareAPI 作为闭包存在于匿名函数Anonymous的作用域链
function Anonymous (next) {
  return function(action){
    ... ...
    return next(action);
  }
}

它的柯里化后两步操作分别为:

  • 接收一个next方法
  • 接受一个action,并执行next(action)

compose

dispatch = compose(...chain,store.dispatch)

这句是最精妙也是最有难度的地方。

个人认为这个compose函数是整个redux中非常亮眼的部分,
短短几行代码,就完成了一个核心功能的扩展,是责任链设计模式的经典体现。
我们来看一下compose的源码:

function compose() {
  for (var _len = arguments.length, funcs = Array(_len), _key = 0; _key < _len; _key++) {
    funcs[_key] = arguments[_key];
  }

  if (funcs.length === 0) {
    return function (arg) {
      return arg;
    };
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  return funcs.reduce(function (a, b) {
    return function () {
      return a(b.apply(undefined, arguments));
    };
  });
}

compose在这里将所有的 中间件的第一个返回函数 聚合。
也就像我们刚才分析的,将store.dispatch传给第一个中间件,第一个中间件对其进行封装后传给第二个中间件,
以此类推... ...。

最底层的dispatchstore.dispatch,一层一层的封装,最终得到一个层层封装后的“dispatch”

或许已经不能称之为dispatch,他是原生的dispatch,以及一系列增强函数的集合

最后

return {
  ...store,
  dispatch
}

store中的所有可枚举属性复制进去(浅复制),并用层层封装好的“dispatch”覆盖store中的dispatch属性。

redux-thunk

我们回到最开始的问题

export function getTodos(){
  return (dispatch,getStore) => {
    TodoApi.getTodos().then(result=>{
      dispatch({
      type:TODOLIST,
      data:result.data
      })
    })
  }
}

上面这种写法是怎么工作的呢?

让我们结合redux-thunk源码来分析一下便一目了然。

function createThunkMiddleware(extraArgument) {
  return function (_ref) {
    var dispatch = _ref.dispatch,
      getState = _ref.getState;
    return function (next) {
      return function (action) {
        if (typeof action === 'function') {
          return action(dispatch, getState, extraArgument);
        }
        return next(action);
      };
    };
  };
}

代码同样精炼,改造后的dispatch接收到action后有两种情况:

  • 如果我们返回的action是一个普通对象,形如
{
  type:GETTODOS,
  data:[]
}

那么,redux-thunk将不予处理,继续将 action 向下传递。项目中如果没有其他中间件,这里会直接调用原生的dispatch,交由 Reducer 处理。

  • 如果我们返回的action是一个函数,就像我们一直在使用的:
  return (dispatch,getStore) => {
    TodoApi.getTodos().then(result=>{
      dispatch({
      type:TODOLIST,
      data:result.data
      })
    })
  }

为了方便理解,我们可以稍作变换:

const func = (dispatch, getStore) => {
  TodoApi.getTodos().then(result => {
    dispatch({
      type: TODOLIST,
      data: result.data
    })
  })
}
return func;

这里的返回值无疑是function。现在就轮到redux-thunk上场了:

if (typeof action === 'function') {
    return action(dispatch, getState, extraArgument);
}

这里的action就是我们的func,func在此处被执行,就相当于:

  func(dispatch, getState, extraArgument);

这里的dispatchgetState都是在闭包中存储的变量。

  • getState 可以获取到store中所有的state
  • dispatch可能是store.dispatch原生方法,当然也有可能是下一个 middleware 封装后的方法。

看到这里,应该不难明白这是如何工作的了吧:

export function getTodos(){
  return (dispatch,getStore) => {
    TodoApi.getTodos().then(result=>{
      dispatch({
      type:TODOLIST,
      data:result.data
      })
    })
  }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,783评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,360评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 142,942评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,507评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,324评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,299评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,685评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,358评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,652评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,704评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,465评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,318评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,711评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,991评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,265评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,661评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,864评论 2 335

推荐阅读更多精彩内容

  • 前言 最近几天对 redux 的中间件进行了一番梳理,又看了 redux-saga 的文档,和 redux-thu...
    Srtian阅读 32,542评论 9 40
  • “中间件”这个词听起来很恐怖,但它实际一点都不难。想更好的了解中间件的方法就是看一下那些已经实现了的中间件是怎么工...
    Jmingzi_阅读 1,660评论 1 7
  • 写在开头 本片内容主要为本人在阅读redux官方文档中基础和进阶部分的学习笔记。由于本人能力有限,所以文章中可能会...
    前端开发爱好者阅读 1,173评论 0 4
  • 一、中间件的概念 redux是有流程的,那么,我们该把这个异步操作放在哪个环节比较合适呢? Reducer?纯函数...
    java高并发阅读 326评论 0 0
  • 有时候我会经常问自己,喜欢写字,不停地写字,到底在写些什么,写字它能带给我什么。 想起刚开始要我写字的时候,我是拒...
    6月姑娘阅读 332评论 0 0