R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

immer.js - 实现不可变数据的新思路

注意:本文发布于 1649 天前,文章中的一些内容可能已经过时。

immer.js 介绍

因为项目需要,我跟不可变数据打交道也有很长一段时间了。对于不可变数据来说,大家最熟悉的应该就是 immutable-js 了,然而它有这么几个劣势:

  • API 复杂,额外引入了 Map 和 List 类型
  • 需要指定一个数组作为路径来 Get 或 Set,失去了 ES6 的语法简洁性
  • 失去了 Flow 或 TypeScript 对数据的 Schema 校验

直到我前几天偶然在网上看到了一个叫 immer.js 的库,看了几眼文档,瞬间就被它的简洁给吸引住了。先来个简单的例子(摘自官方文档):

import produce from 'immer';

const baseState = [
    { desc: 'Learn typescript', done: true },
    { desc: 'Try immer', done: false },
];

// 所有操作都被包在 produce 函数中,draftState 是 baseState 的可变版本
// 所有对 draftState 的修改,最终都会反映到 nextState 中
const nextState = produce(baseState, draftState => {
    draftState.push({ desc: 'Tweet about it' });
    draftState[1].done = true;
});

// 新元素的添加不会修改原数据
expect(baseState.length).toBe(2);
expect(nextState.length).toBe(3);

// 修改属性也不会影响原数据
expect(baseState[1].done).toBe(false);
expect(nextState[1].done).toBe(true);

// 新旧数据中,没有修改的部分是共享的
expect(nextState[0]).toBe(baseState[0]);
// 只有修改过的部分是新的
expect(nextState[1]).not.toBe(baseState[1]);

这用起来跟纯 TypeScript 没有任何区别啊!之前提到的几个劣势完全就消失了!我 TM 吹爆!

但是为了避免踩坑,也是想好好了解一下这个库的实现思路,我一边读官方文档,一边扒源码,还从网上搜索相关的资料。这篇中文文章 把基础的点基本都讲到了,不过因为它的定位是“教程”,所以有一些地方没有被覆盖到,例如内部实现、性能、注意事项等。我打算在这几个方面写一写。

内部实现

对于“实现不可变数据结构”这一点来说,immer.jsimmutable-js 的原理是一样的,即:对于修改的部分,只拷贝从根到它的路径上的所有节点,从而生成一棵新树。但在实现方式上,immutable-js 是教科书式的,immer.js 则有点 Tricky。

Proxy 与优化

为了防止数据被不遵守最佳实践的开发者无意间修改,库一般都不会暴露数据给外界,而是需要通过特定的 API 来操作(例如 immutable-js 中的 getgetInsetsetIn),但 immer.js 则是通过 Proxy 来实现:

  • 所有的操作都被包在 produce 函数中,在 Set 操作的时候顺便记录数据变更,以方便手动增量更新或查错
  • 如果 produce 的回调函数中返回了一个 Object,则直接使用它作为新的对象
  • 如果 produce 的回调函数没有返回值(或者返回了 undefined),则会使用 draftState 作为新的对象

我一开始觉得,immer.js 每次都要先 createProxy 一下,就跟在 immutable-js 中每次都要先 toJS 一样,岂不是非常慢?然而在看 src/proxy.js 的时候发现,其实每次 createProxy 只是为第一层属性创建了 Proxy,之后嵌套的几层的 Proxy 是在 get 的时候 Lazy create 的(关键代码在 这里)。这在嵌套层数多、但每一层的属性少的情况下,是很有优势的,而且也不会影响其它情况的性能。

Scope 与 Patch 的概念

Scope 是为了记录每一层的 Patches 而提取出来的一个结构,实现在 src/scope.js 中。ImmerScope 是一个类,也是一个单实例。使用上面挂载的 current 变量来记录当前操作的 Scope,所有的 Patches 都会被记录在当前的 Scope 中。

至于 Patch,immer.js 对它没有进行太多的封装,在 src/patches.js 中只是写了一些工具函数,这也是我觉得它有点遗憾的地方,因为这样没法对 Patch 的 Schema 有一个直观的认识。我是从 这段代码 了解到 Patch 的结构的:

interface Patch {
    op: 'replace' | 'add' | 'remove';
    path: string[];
    value: any;
}

对 Async-await 的处理

immer.js 支持在 produce 中使用 async 的回调函数,例如:

const loadedUser = await produce(user, async draftState => {
    const data = await window.fetch("http://host/" + draftState.name);
    draftState.todos = await data.json();
});

其实这并不困难,只需要判断一下返回值是否是 Promise 即可,关键代码在 这里

官方文档还说,可以使用 createDraftfinishDraft 两个底层函数来实现相同的效果:

const draftState = createDraft(user);
draftState.todos = await data.json();
const loadedUser = finishDraft(draftState);

去看了一下源码,发现如果把这两个函数的语句先后拼起来,跟 produce 的核心部分其实是等价的。这里我就不太理解了,为什么 produce 中不直接使用这两个函数,而是把语句又重写了一遍呢?这应该对性能没有太大的影响才对。

相比之下,我更喜欢 Git 的做法:从最底层函数(例如 rev-parse)开始写起,逐步构建上层函数(例如 commit)。

暴力的 ES5 版本

Proxy 是 ES6 引入的新特性,可以监听属性的 getsetdeletePropertyenumerate十几种 操作,但这是对语言底层的扩展,所以 ES5 及之前的版本是没法 Polyfill 的。immer.js 提供了 ES5 的版本,我很好奇它是如何实现的,于是就去看源码了。

src/es5.js 文件其实就是实现了一个 Fake Proxy,并没有遵循 Proxy 的规范(因为 ES5 的语言限制),只是结果跟 Proxy 一样而已:Get 和 Set 跟 Vue 的思路很像,都是使用了 Getter 和 Setter,但在最后判断数据是否有修改的时候,枚举了 Object 的 Key(对于 Array 则是枚举全部元素)来判断有没有添加或删除。关键代码在 这里

不过作者在注释中提到触发这段代码的条件是 only proxies are visited, and only objects without known changes are scanned,前半句几乎每次视图更新都会触发,后半句表明只有 Setter 无法 hold 住的场景才会执行这个函数,但这个场景包含了:

  • produce 的回调函数直接返回了一个 Object(可能压根没有用到 Setter)
  • 为 Object 添加了一个新的 Key(ES5 的语言限制)
  • 对 Array 做 Push、Pop、Splice 等操作(跟 Vue 不同,immer.js 没有 Hook 这些函数)

所以其实还是挺暴力的。不过考虑到这只是个 ES5 的兼容版本,能克服语言限制已经很不错了,就不强求那么多了。以及对于那些在 9102 年仍然使用着不支持 ES6 的浏览器的用户,他们访问的网站估计也不会用到这个库吧。

性能测试

先放一张官方文档中的性能测试图:

Immer performance

第一眼看到这张图的时候,我心想:这玩意儿跟 immutable-js + toJS 相比也没快那么多啊,直到我看了坐标轴才意识到:这特么是对数坐标系啊……这让我明白了一点:为了达到宣传效果,说自己代码性能高的时候千万不要用对数坐标系。Update:我之前看差了,这不是对数坐标系,就是正常的坐标系。

其中前三列是没有太大意义的:前两列是 Native 实现,虽然快但并没有任何不可变的功能;第三列虽然 immutable-js 本身非常快,但是只要遵循了 Redux 官方的推荐做法,代价高昂的 toJS 几乎每次视图更新都会被执行。所以,immer.js(不考虑 ES5 版本和 Freeze)不管是每次返回新数据还是修改 draftState,都是常见库中最快的,性能几乎是 immutable-js + toJS 的 2.5 倍。考虑到 Proxy 可能在后续的浏览器版本中被进一步优化,immer.js 的性能还有更大的提升空间。

注意事项

官方文档中提到了一些注意事项,但没有说明原因。我在这儿翻译一下,顺便补充一下原因:

不要对 draftState 本身重新赋值

只要稍微熟悉 JS 就不会这么做:draftState 本身是个 Proxy 对象,把 Proxy 对象都搞没了还玩个毛啊……

State 中不要出现相同的引用或循环引用

正常情况下,如果两个 Key 引用了相同的 Object,那么如果修改了其中一个,则会影响到另一个:

const obj = { test: 1 };
const target = { a: obj, b: obj };
target.a.test = 2;
console.log(target.b.test); // 2

但是这些 Object 本质上都是 Proxy,在记录 Patch 的时候只会记录一个 path,这就跟正常的 Object 行为不一致了。

不要一次性读写大量数据

虽然 immer.js 的性能比较高,但一次性读写大量数据对于 Proxy 来说还是比较吃力的。由于 immer.js 对于 Proxy 有 Lazy create 特性,所以可以放心的只在需要的 Reducer 中使用,而不必像 immutable-js 一样将整个 State 都当作不可变对象。

此外,如果只需要读取 State 被修改之前的原始值,完全可以读原变量,而不是从 draftState 中读取。因为 draftState 用到了 Proxy,会拖累性能。

尽量将更多操作包在一个 produce 中

跟上一条相反,如果 produce 被分的太细,就会有大量的时间耗费在创建 Proxy 上。因此在可能的情况下,尽量扩大 produce 的影响范围,例如 for (let x of y) produce(base, d => d.push(x)) 会比 produce(base, d => { for (let x of y) d.push(x) }) 慢很多。

不要直接返回 undefined

由于返回 undefined 会被认为“没有返回值”,immer.js 会使用 draftState 作为新数据,而不是“使用 undefined 作为新数据”。要想实现这个,需要返回 Namespace 中的 nothing 常量。

值得一提的事情

注意到 immer.js 的作者 Michel Weststrate 同时也是 MobX 的作者,这是个很好玩的事情。

MobX 是一个在实现方式上与 Vuex 类似的状态管理库,它通过监听器来监听数据修改,从而更新视图。作为一个函数式编程的爱好者,我是不喜欢这种方式的,而且这也没法实现时光旅行(例如“撤销”与“重做”)功能。

immer.js 看起来虽然在 produce 函数中也使用了可变量,但它本质上是一个代理,是对用户操作的一个记录,通过原数据和操作记录产生一个新的不可变数据。这首先在使用上跟 Redux 几乎没有区别(当然,不再需要写一堆 Spread 运算符了),其次由于我知道“只要遵循最佳实践就一定不会修改原数据”,用起来也放心了很多。

虽然这两个库没有太多的算法和架构问题,更多的是利用了语言的特性,但能想到利用可变数据实现两种完全不同的思路,并且能很好的实现出来,不得不让人佩服这个作者的能力。

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3049 天