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团队设计思想