探索 Redux4.0 版本迭代 论基础谈展望(对比 React context)

DJ Snake on libe

Redux 在几天前(2018.04.18)发布了新版本,6 commits 被合入 master。从诞生起,到如今 4.0 版本,Redux 保持了使用层面的平滑过渡。同时前不久, React 也从 15 升级到 16 版本,开发者并不需要作出太大的变动,即可“无痛升级”。但是在版本迭代的背后很多有趣的设计值得了解。Redux 此次升级同样如此。

本文将从此次版本升级展开,从源代码改动入手,进行分析。通过后文内容,相信读者能够在 JavaScript 基础层面有更深认识。

本文支持前端初学者学习,同时更适合有 Redux 源码阅读经验者,核心源码并不会重复分析,更多将聚焦在升级改动上。

改动点总览

这次升级改动点一共有 22 处,最主要体现在 TypeScript 使用、CommonJS 和 ES 构建、关于 state 抛错三方面上。对于工程和配置的改动,我们不再多费笔墨。主要从代码细节入手,基础入手,着重分析以下几处改动:

  • 中间件 API dispatch 参数处理;
  • applyMiddleware 改动;
  • bindActionCreators 对 this 透明化处理;
  • dispatching 时,对 state 的冻结;
  • Plain Object 类型判断;

话不多说,我们直接进入正题。

applyMiddleware 参数处理

这项改动由 Asvarox 提出。熟悉 Redux 源码中 applyMiddleware.js 设计的读者一定对 middlewareAPI 并不陌生:对于每个中间件,都可以感知部分 store,即 middlewareAPI。这里简单展开一下:

 const middlewareAPI = {
   getState: store.getState,
   dispatch: (action) => dispatch(action)
 };
 chain = middlewares.map(middleware => middleware(middlewareAPI));
 dispatch = compose(...chain)(store.dispatch)

创建一个中间件 store:

let newStore = applyMiddleware(mid1, mid2, mid3, ...)(createStore)(reducer, null);

我们看,applyMiddleware 是个三级 curry 化的函数。它将陆续获得了三个参数,第一个是 middlewares 数组,[mid1, mid2, mid3, ...],第二个是 Redux 原生的 createStore,最后一个是 reducer;

applyMiddleware 利用 createStore 和 reducer 创建了一个 store,然后 store 的 getState 方法和 dispatch 方法又分别被直接和间接地赋值给 middlewareAPI 变量。middlewares 数组通过 map 方法让每个 middleware 带着 middlewareAPI 这个参数分别执行一遍。执行完后,获得 chain 数组,[f1, f2, ... , fx, ...,fn],接着 compose 将 chain 中的所有匿名函数,[f1, f2, ... , fx, ..., fn],组装成一个新的函数,即新的 dispatch,当新 dispatch 执行时,[f1, f2, ... , fx, ..., fn] 将会从右到左依次执行。以上解释改动自:pure render 专栏

好了,把中间件机制简要解释之后,我们看看这次改动。故事源于 Asvarox 设计了一个自定义的中间件,这个中间件接收的 dispatch 需要两个参数。他的“杰作”就像这样:

const middleware = ({ dispatch }) => next => (actionCreator, args) => dispatch(actionCreator(...args));

对比传统编写中间件的套路:

const middleware = store => next => action => {...}

我们能清晰地看到他的这种编写方式会有什么问题:在原有 Redux 源码基础上,actionCreator 参数后面的 args 将会丢失。因此他提出的改动点在:

     const middlewareAPI = {
       getState: store.getState,
-      dispatch: (action) => dispatch(action)
+      dispatch: (...args) => dispatch(...args)
     }

如果你好奇他为什么会这样设计自己的中间件,可以参考 #2501 号 issue。我个人认为对于需求来说,他的这种“奇葩”方式,可以通过其他手段来规避;但是对于 Redux 库来说,将 middlewareAPI.dispatch 参数展开,确实是更合适的做法。

此项改动我们点到为止,不再钻牛角尖。应该学到:基于 ES6 的不定参数与展开运算符的妙用。虽然一直在说,一直在提,但在真正开发程序时,我们仍然要时刻注意,并养成良好习惯。

基于此,同样的改动也体现在:

   export default function applyMiddleware(...middlewares) {
  -  return (createStore) => (reducer, preloadedState, enhancer) => {
  -  const store = createStore(reducer, preloadedState, enhancer)
  +  return (createStore) => (...args) => {
  +  const store = createStore(...args)
     let dispatch = store.dispatch
     let chain = []

这项改动由 jimbolla 提出。

bindActionCreators 对 this 透明化处理

Redux 中的 bindActionCreators,达到 dispatch 将 action 包裹起来的目的。这样通过 bindActionCreators 创建的方法,可以直接调用 dispatch(action) (隐式调用)。可能很多开发者并不常用,所以这里稍微展开,在 action.js 文件中, 我们定义了两个 action creators:

function action1(){
  return {
   type:'type1'
  }
}
function action2(){
  return {
   type:'type2'
  }
}

在另一文件 SomeComponent.js 中,我们便可以直接使用:

import { bindActionCreators } from 'redux';
import * as oldActionCreator from './action.js'

class C1 extends Component {
  constructor(props) { 
    super(props);

    const {dispatch} = props;
    this.boundActionCreators = bindActionCreators(oldActionCreator, dispatch);
  }

  componentDidMount() {
    // 由 react-redux 注入的 dispatch:
    let { dispatch } = this.props;
    let action = TodoActionCreators.addTodo('Use Redux');
    dispatch(action);
  }

  render() {
    // ...
    let { dispatch } = this.props;
    let newAction = bindActionCreators(oldActionCreator, dispatch)
    return <Child {...newAction}></child>
  }
}

这样一来,我们在子组件 Child 中,直接调用 newAction.action1 就相当于调用 dispatch(action1),如此做的好处在于:没有 store 和 dispatch 的组件,也可以进行动作分发。

一般这个 API 应用不多,至少笔者不太常用。因此上面做一个简单介绍。有经验的开发中一定不难猜出 bindActionCreators 源码做了什么,连带着这次改动:

function bindActionCreator(actionCreator, dispatch) {
-  return (...args) => dispatch(actionCreator(...args))
+  return function() { return dispatch(actionCreator.apply(this, arguments)) }
 }

我们看这次改动,对 actionCreator 使用 apply 方法,明确地进行 this 绑定。那么这样做的意义在哪里呢?

我举一个例子,想象我们对原始的 actionCreator 进行 this 绑定,并使用 bindActionCreators 方法:

const uniqueThis = {};
function actionCreator() {
  return { type: 'UNKNOWN_ACTION', this: this, args: [...arguments] }
};
const action = actionCreator.apply(uniqueThis,argArray);
const boundActionCreator = bindActionCreators(actionCreator, store.dispatch);
const boundAction = boundActionCreator.apply(uniqueThis,argArray);

我们应该期望 boundAction 和 action 一致;且 boundAction.this 和 uniqueThis 一致,都等同于 action.this。这如此的期望下,这样的改动无疑是必须的。

对 state 的冻结

Dan Abramov 认为,在 reducer 中使用 getState() 和 subscribe() 方法是一种反模式。store.getState 的调用会使得 reducer 不纯。事实上,原版已经在 reducer 执行过程中,禁用了 dispatch 方法。源码如下:

  function dispatch(action) {
    // ...

    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }

    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    var listeners = currentListeners = nextListeners
    for (var i = 0; i < listeners.length; i++) {
      listeners[i]()
    }

    return action
  }

同时,这次修改在 getState 方法以及 subscribe、unsubscribe 方法中进行了同样的冻结处理:

 if (isDispatching) {
  throw new Error(
    'You may not call store.subscribe() while the reducer is executing. ' +
      'If you would like to be notified after the store has been updated, subscribe from a ' +
      'component and invoke store.getState() in the callback to access the latest state. ' +
      'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
  )
}

笔者认为,这样的做法毫无争议。显式抛出异常无意是合理的。

Plain Object 类型判断

Plain Object 是一个非常有趣的概念。这次改动围绕判断 Plain Object 的性能进行了激烈的讨论。最终将引用 lodash isPlainObject 的判断方法改为 ./utils/isPlainObject 中自己封装的做法:

- import isPlainObject from 'lodash/isPlainObject';
+ import isPlainObject from './utils/isPlainObject'

简单来说,Plain Object:

指的是通过字面量形式或者new Object()形式定义的对象。

Redux 这次使用了以下代码来进行判断:

export default function isPlainObject(obj) {
  if (typeof obj !== 'object' || obj === null) return false

  let proto = obj
  while (Object.getPrototypeOf(proto) !== null) {
    proto = Object.getPrototypeOf(proto)
  }

  return Object.getPrototypeOf(obj) === proto
}

如果读者对上述代码不理解,那么需要补一下原型、原型链的知识。简单来说,就是判断 obj 的原型链有几层,只有一层就返回 true。如果还不理解,可以参考下面示例代码:

function Foo() {}

// obj 不是一个 plain object
var obj = new Foo();

console.log(typeof obj, obj !== null);

let proto = obj
while (Object.getPrototypeOf(proto) !== null) {
  proto = Object.getPrototypeOf(proto)
}

// false
var isPlain = Object.getPrototypeOf(obj) === proto;
console.log(isPlain);

而 loadash 的实现为:

function isPlainObject(value) {
  if (!isObjectLike(value) || baseGetTag(value) != '[object Object]') {
    return false
  }
  if (Object.getPrototypeOf(value) === null) {
    return true
  }
  let proto = value
  while (Object.getPrototypeOf(proto) !== null) {
    proto = Object.getPrototypeOf(proto)
  }
  return Object.getPrototypeOf(value) === proto
}

export default isPlainObject

isObjectLike 源码:

function isObjectLike(value) {
  return typeof value == 'object' && value !== null
}

baseGetTag 源码:

const objectProto = Object.prototype
const hasOwnProperty = objectProto.hasOwnProperty
const toString = objectProto.toString
const symToStringTag = typeof Symbol != 'undefined' ? Symbol.toStringTag : undefined
function baseGetTag(value) {
  if (value == null) {
    return value === undefined ? '[object Undefined]' : '[object Null]'
  }
  if (!(symToStringTag && symToStringTag in Object(value))) {
    return toString.call(value)
  }
  const isOwn = hasOwnProperty.call(value, symToStringTag)
  const tag = value[symToStringTag]
  let unmasked = false
  try {
    value[symToStringTag] = undefined
    unmasked = true
  } catch (e) {}

  const result = toString.call(value)
  if (unmasked) {
    if (isOwn) {
      value[symToStringTag] = tag
    } else {
      delete value[symToStringTag]
    }
  }
  return result
}

根据 timdorr 给出的对比结果,dispatch 方法中:

master: 4690.358ms
nodash: 82.821ms

这一组 benchmark 引发的讨论自然少不了,也引出来了 Dan Abramov。笔者对此不发表任何意见,感兴趣的同学可自行研究。从结果上来看,摒除了部分对 lodash 的依赖,在性能表现上说服力增强。

展望和总结

提到 Redux 发展,自然离不开 React,React 新版本一经推出,极受追捧。尤其是 context 这样的新 API,某些开发者认为将逐渐取代 Redux。

笔者认为,围绕 React 开发应用,数据状态管理始终是一个极其重要的话题。但是 React context 和 Redux 并不是完全对立的

首先 React 新特性 context 在大型数据应用的前提下,并不会减少模版代码。而其 Provider 和 Consumer 的一一对应特性,即 Provider 和 Consumer 必须来自同一次 React.createContext 调用(可以用 hack 方式解决此“局限”),仿佛 React 团队对于此特性的发展方向设计主要体现在小型状态管理上。如果需要实现更加灵活和直接的操作,Redux 也许会是更好的选择。

其次,Redux 丰富的生态以及中间件等机制,决定了其在很大程度上具有不可替代性。毕竟,已经使用 Redux 的项目,迁移成本也将是极大的,至少需要开发中先升级 React 以支持新版 context 吧。

最后,Redux 作为一个“发布订阅系统”,完全可以脱离 React 而单独存在,这样的基因也决定了其后天与 React 本身 context 不同的性征。

我认为,新版 React context 是对 React 本身“短板”的长线补充和完善,未来大概率也会有所打磨调整。Redux 也会进行一系列迭代,但就如同这次版本升级一样,将趋于稳定,更多的是细节上调整。

退一步讲,React context 的确也和 Redux 有千丝万缕的联系。任何类库或者框架都具有其短板,Redux 同样也如此。我们完全可以使用新版 React context,在使用层面来规避 Redux 的一些劣势,模仿 Redux 所能做到的一切。如同 didierfranc 的 react-waterfall,国内@方正的 Rectx,都是基于新版 React context 的解决方案。

最后,我很赞同@诚身所说:
选择用什么样的工具从来都不是决定一个开发团队成败的关键,根据业务场景选择恰当的工具,并利用工具反过来约束开发者,最终达到控制整体项目复杂度的目的,才是促进一个开发团队不断提升的核心动力。

没错,真正对项目起到决定性作用的还是是开发者本身,完善基础知识,提升开发技能,让我们从 Redux 4.0 的改动看起吧。

广告时间:
如果你对前端发展,尤其对 React 技术栈感兴趣:我的新书中,也许有你想看到的内容。关注作者 Lucas HC,新书出版将会有送书活动。

Happy Coding!

PS: 作者 Github仓库知乎问答链接 欢迎各种形式交流!

我的其他几篇关于React技术栈的文章:

从setState promise化的探讨 体会React团队设计思想

React 应用设计之道 - curry 化妙用

组件复用那些事儿 - React 实现按需加载轮子

通过实例,学习编写 React 组件的“最佳实践”

React 组件设计和分解思考

从 React 绑定 this,看 JS 语言发展和框架设计

做出Uber移动网页版还不够 极致性能打造才见真章**

React+Redux打造“NEWS EARLY”单页应用 一个项目理解最前沿技术栈真谛

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

推荐阅读更多精彩内容