前言
在Immutable I中,已经讲到为什么要实现并利用immutable state,在当前前端各个视图框架或库中,data->view是核心思想,在render过程利用immutable state来尽可能减少re-rendering,将可以大幅度提升性能。同时也提到了由Facebook推出的嫡系工具库immutable.js,是react社区的一柄利器。正如Chapter I 中说到的,immutable.js利用的是Persistent Vectors机制,是典型的利用空间换时间的做法。那么还有没有比immutable.js更好的实现方式呢?答案是肯定的,请出本文的主角immer,immer才是更适合你的immutable state实现方案,它已经俨然成为react社区的新宠儿。
immer
简介
immer提供了一种更加便捷的方式来实现immutable state,是基于一种“复写”机制来实现的。意思是说,在对current state进行修改时,会生成一个temporary draft state作为代理proxy state,最后修改完成时,会基于被修改的draft state产生next state而保持current state不变。意味着在数据修改过程中,只需要对proxy state进行简单赋值操作就能享有所有immutable state带来的好处。下图引自immer官方文档,从图上我们也能清晰的看出来immer是如何实现immutable state。
immer内部将要被编辑修改的state称作一份draft(草稿),同时也规定了只有object、array、Map、Set及实现Symbol("immer-draftable")才能作为符合要求的draft。
简单produce
先来一个简单的例子来比较直观的了解下immer的Immutable实现方式,也就是展示一下immer produce方法的魅力。
顾名思义:produce即是生产。但这里更恰当的理解是“复制”,只是这个“复制”的过程中会生成代理。根据输入“复制”得到一个全新的输出,同时保留输入不发生任何变化。
import produce from "immer"
const baseState = [
{
todo: "Learn typescript",
done: true
}
];
const nextState = produce(baseState, draft => {
draftState.push({todo: "Tweet about it"}) // 简单的对象操作
draftState[0].done = false // 简单的属性复制
});借助上面这个例子,下面我们就来分析一下immer的工作原理。
工作原理
结合上面提到的immer“复写”机制的示意图,我们来看下immer的工作原理。下面是原理流程图,
从上图中我们可以比较清晰的了解到immer是如何实现这样一种简单优雅的Immutable机制,核心思想是current -> proxy -> next。
事实上从图中可以得出最核心的步骤是,
- 生成proxy,proxy = createProxy(current),根据current生成proxy
- proxy如何处理数据getter、setter来维护数据副本copy,result = recipe(proxy),传递proxy完成数据变更获取最新数据
immer内部默认情况下是基于ES6的Proxy来实现生成proxy,在不支持ES6的环境下也可以通过内部插件来开启实现。
我们来着重看下produce函数内部做了什么:
- 生成scope:一次produce有且只会生成一个root scope,用于createProxy时关联proxy target,同时会沿着proxy target传递下去进行递归proxy creation,这样递归链上生成所有的proxy都会关联到root scope进行缓存,当有嵌套produce时还会关联父级scope,从而形成scope chain用于最终的finalize。
- 生成proxy:作为在数据修改过程中实际被操作的代理,过程中每个proxy都会同时保留原始base、副本copy,最终会利用副本来生成最新的数据。proxy作为一个ES6的Proxy实例,其target就是对base state包装的proxy state。
- 在proxy handler getter拦截里会根据修改的key取到对应的最近修改的值,如果未被修改且发现对应value未生成proxy,会进行递归的proxy creation,此时会传递当前immer实例及当前proxy state。
- 执行recipe:调用recipe生成最终的数据,此间会把生成好的proxy传递进去,此时,大家就能明白
(draft) => next句柄中的draft是什么了,事实上就是这个新生成的proxy,所有对draft的修改操作都会被proxy拦截代理。 - 最后finalize:根据proxy state的copy来生成最终全新的next state。
从上面简单produce例子代码中,可以发现在immer produce中,接收baseState和recipe handler,同时在recipe handler中对生成的proxy进行修改,而且你会发现对其进行修改时不需要额外复杂的api,只需要最平常的对象操作和属性赋值,这种数据修改方式给予我们很大的便利,也降低了在已有应用中加入Immutable机制的改造成本。生成的这个proxy很好“保护”了baseState,baseState就好像从来没被碰过一样,返回的nextState就是基于proxy修改之后的全新state。
在produce方法的源码中我们可以发现上面说的这个过程,这个时候你可能非常关心这个proxy又做了什么,但如果你只是纯粹使用immer的produce来实现Immutable的话,你只要明白proxy即是一个ES6 Proxy实例,它的target是对baseState的包装,proxy的getter拦截读取baseState里的对应值,再用setter拦截设置自己的副本,最终返回该副本。
但是你如果不满足于理解immer如何使用,而想看看它的内部魔法,那我们一起往下看。
const proxy = createProxy(this, base, undefined)
try {
result = recipe(proxy) // 将proxy传入recipe handler中,也就是上面例子中的draft
}Curried produce
上面说到简单produce的用法对于保持Immutable是非常实用的,但不是最吸引人的,因为通过这种方式得到一个Immutable state难免看着有点“低效”,是否有一种更高效且更贴合react习惯的recipe handler,immer不愧是react社区的新贵,当然不会忘记满足这样的诉求,提供了另一种更强大的produce方式(curried produce)。看下面的例子
// mapper will be of signature (state, index) => state
const mapper = produce((draft, index) => {
draft.index = index
})
// example usage
console.dir([{}, {}, {}].map(mapper))
//[{index: 0}, {index: 1}, {index: 2}])当produce接收一个函数时,会得到像(draft,other) => next这样的curried produce,想必大家对前面这样的函数签名非常熟悉,一定能联想到react setState里的functional state以及redux reducer,这也是immer作为更适合的Immutable实现方式的另一个原因,更贴合react技术栈。同样我们看看produce内部做了什么,可以得到这样的一个curried produce。
if (typeof base === "function" && typeof recipe !== "function") {
const defaultBase = recipe
recipe = base
const self = this
return function curriedProduce(
this: any, // ts写法
base = defaultBase, // 这里会传入base state, 例如react component state或者是reducer state之类的
...args: any[] // 可以传入像reducer action之类的
) {
// 返回一个全新的state, 这里和简单produce的处理一样
return self.produce(base, (draft: Drafted) => recipe.call(this, draft, ...args))
}
}从上面源码可以看出来,就是对produce做了一次curry,将收到的第一个参数作为recipe handler,第二个参数作为默认的baseState,这也完美契合了redux reducer默认state的做法。得到的curriedProduce第一个参数会接收一个baseState,例如react component state或者redux reducer state,第二个参数可以接收像reducer action之类的。
Redux reducer
- 不用immer的reducer长这样,里面存在各种对当前state的merge处理,生怕修改影响到其他属性,往往会使得代码看着有点点“冗余”和“混乱”
const byId = (state, action) => {
switch (action.type) {
case RECEIVE_PRODUCTS:
return {
...state,
...action.products.reduce((obj, product) => {
obj[product.id] = product
return obj
}, {})
}
default:
return state
}
}- 使用immer之后,完全不需要考虑当前的state,尽情修改就是,不仅代码简洁明了,往往更贴近代码逻辑语义。
import produce from "immer"
const byId = produce((draft, action) => {
switch (action.type) {
case RECEIVE_PRODUCTS:
action.products.forEach(product => {
draft[product.id] = product
})
}
})React setState
- 不用immer的setState,与reducer遇到同样的问题
onClick = () => {
this.setState(prevState => ({
user: {
...prevState.user,
age: prevState.user.age + 1
}
}))
}- 使用immer之后,curried produce刚好可以作为functional state。prevState即是当前的baseState。
onClick = () => {
this.setState(
produce(draft => {
draft.user.age += 1
})
)
}异步处理
对于state的修改,不只停留在同步简单修改,也会有需要根据异步获取到的数据来进行修改的场景,immer同样不会忽略这一点,这也可以作为immer成为更合适的Immutable实现方式的一个原因。produce同样具备实现异步修改的能力。
if (typeof Promise !== "undefined" && result instanceof Promise) {
return result.then(
result => {
usePatchesInScope(scope, patchListener)
return processResult(result, scope) // 进入异步
},
error => {
revokeScope(scope)
throw error
}
)
}从上面源码可以看出,只需要recipe handler简单的返回一个Promise就可以实现异步修改。如下面的例子,在recipe handler里可以将异步转同步,本质上还是可以像简单produce那样修改state,等到对于所有想要修改的fields都做好修改,就自然而然拿到了最终的全新state。
import produce from "immer"
const user = {
name: "michel",
todos: []
}
const loadedUser = await produce(user, async function(draft) {
draft.todos = await doSomeAsyncWork(); // 此处实现异步修改
})👆上面这样的异步Immutable方式,我觉得这是一种非常舒服的编码方式,有太多的可操作空间,得到的修改后的数据是可以马上用于像setState这样的触发re-rendering,所有的代码逻辑语义都是非常顺畅的,想修改什么field,修改它,不用考虑其他不希望修改的fields,然后更新视图,真正的data驱动view,不再有不必要的re-rendering。
总结
immer最为一种更便捷的Immutable机制,当我们使用它时,几乎都感觉不到它的存在而又无处不体现它的原理的精妙。用一种最贴合data->view思想的直观数据变更方式而不影响我们的编码习惯,真的是编码效率和应用性能的双丰收,大家可以多尝试一些immer在react技术栈中的玩法,打造更加健壮的应用。