剑指immer,更快更强的不可变数据js工具 limu 稳定版发布!
发布于 9 个月前 作者 fantasticsoul 2721 次浏览 来自 分享

image.png

前言

欢迎了解和关注limu,访问文档并点击右键调出控制台后可实时体验 limu api 和 immer api做对比(全局已绑定limu和immer对象)。

接下来让我们一起深入了解limu的诞生历程吧 ^_^

不可变数据的现状

不可变数据由于拥有结构共享的特性,让一些严重依赖浅比较的框架快速获得性能收益(如react),同时也让一些需要使用严格不可变数据的场景避免了深克隆带来的冗余性能开销,而当下除了immutablejs 和 immer 这两款非常流行的工具库之外,有没有一款比它们的性能和易用性都更好的不可变数据工具库呢?在回答此问题之前,我们先看下immutablejsimmer陷入的困境。

immutablejs作为一个先驱者,最早的git提交记录可以追溯到2014年4月,伴随着react的不可变状态编程理念在2015年之后开始越来越走红,现已达到30K+ star数量,它在js语言世界里拥有为不可变数据指引方向般的重要地位,带领大家认识到了不可变数据在某些特定编程领域的重要性。

不过它的问题也比较突出,主要归结为2点

  • 1 api 复杂,与原始js操作处理隔离的状态,有很重的学习成本和记忆负担
  • 2 内建了一套自己的数据结构,需要通过fromJstoJs做普通json和不可变数据直接的相互转换,带来了额外的开销。
// 额外的学习成本和记忆负担
immutableA = Immutable.fromJS([0, 0, [1, 2]]);
immutableB = immutableA.set 1, 1;
immutableC = immutableB.update 1, (x) -> x + 1;
immutableC = immutableB.updateIn [2, 1], (x) -> x + 1;

而 2018诞生的 immer 则完美的解决了以上两点问题,它巧妙的使用Proxy代理了原始数据,让用户可以像原始js一样完成所有不可变数据的操作(不支持的环境自动降级为 defineProperty),这样一来用户没有了任何学习成本和记忆负担.

const { produce } = limu;
const baseState = {
  a: 1,
  b: [ 1, 2, 3 ],
  c: {
    c1: { n: 1 },
    c2: { m: 2 },
  }
};
// 像原始js一样丝滑的操作不可变数据
const nextState = produce(baseState, (draft)=>{
  draft.a = 2;
  draft.b['2'] = 100;
});

console.log(nextState === baseState); // false
console.log(nextState.a === baseState.a); // false
console.log(nextState.b === baseState.b); // false
console.log(nextState.c === baseState.c); // true

immer真的就是终极答案了么,在大数组和深层次对象场景immer的性能问题较为突出,见此问题描述,社区开始有不少作者另起炉灶尝试突破,留意到这里面较为突出的有structuramutative,经我实测发现确实如它们所说的快过immer较多倍,但依然未能解决既要速度快又要开发体验好的问题,这两个问题我将在下面一一具体意义剖析。

limu诞生

在2021年底我开始为状态库concent构思v3版本,其中一个重点是支持深度依赖收集(v2只支持收集状态的第一层读依赖),那么就需要深度使用Proxy来完成此动作,在深度使用immer是发现调试模式下查看草稿非常糟心,需要借助JSON.parse(JSON.stringify(draft))来完成,尽管后来发现current接口可以导出草稿副本并查看数据结构,但漫天插入额外的current然后在编译时擦除真的让我比较烦恼,且current本身也有不小的开销,再加上通过issue发现immer的如下类似的性能问题后

const demo = { info: Array.from(Array(10000).keys()) };
produce(demo, (draft) => {
  draft.info[2000] = 0; // take long time
});

开始尝试设计并实现limu,期望保持像immer一样的api,但能够更快且更好用,于是在经历经过无数个小迭代后,摸索出了一些提速关键技巧(下面将会介绍到),解决了内存泄露问题,并达成了保证质量的两个关键点:

  • 跑通了 370+ 测试用例

image.png

  • 测试覆盖率到达了97%

image.png

同时也让性能和易用性均达到我的理想后,终于可以正式宣布稳定版发布,且已开始作为基础组件服务于新闻门户,接下来将重点介绍limu的3大优势。

更快

image.png

区别于immer的写时复制机制,limu采用读时浅克隆写时标记修改机制,具体操作流程我们将以下图为例来讲解,使用produce接口生成草稿数据后,limu只会对草稿数据读取路径上经过的相关数据节点做浅克隆

image.png

修改了目标节点下的值的时候,则会回溯该节点到跟节点的所有途径节点并标记这些节点为已修改

image.png

最后结束草稿生成final对象时,limu只需要从根节点把所有标记修改的节点的副本替换到对应位置即可,没有标记修改的节点则不使用副本(注:生成副本不代表已被修改)

这样的机制在对象的原始层级关系较为复杂且修改路径不广的场景下,且不需要冻结原始对象时,性能表现异常优异,可达到比 immer 快 5 倍或更多,只有在修改数据逐渐遍及整个对象所有节点时,limu的性能才会呈线性下载趋势,逐步接近immer,但也要比immer快很多。

测试验证

为验证上述结论,用户可按照以下流程获得针对limuimmer性能测试对比数据

git clone https://github.com/tnfe/limu
cd limu
npm i
cd benchmark
npm i
node opBigData.js // 触发测试执行,控制台回显结果
# or
node caseReadWrite.js

我们准备两个用例,一个改编自 immer 官方的性能测试案例(注:跳转后见头部标注的链接)

执行 node opBigData.js 得到如下结果 (柱条越短代表越快)

image.png

注:以上是v9版本,immer 23年4月发布了v10版本,经测试发现结果变化不大,性能提升不明显

一个是我们自己准备的深层次 json 读写案例,结果如下 (柱条越短代表越快)

image.png

可通过注入ST值调整不同的测试策略,例如 ST=1 node caseReadWrite.js,不注入时默认为 1

  • ST=1,关闭冻结,不操作数组
  • ST=2,关闭冻结,操作数组
  • ST=3,开启冻结,不操作数组
  • ST=4,开启冻结,操作数组

更强

image.png

limu利用Symbol和原型链隐藏代理元数据,让元数据始终跟随草稿节点,在草稿结束后才擦除,让用户不仅可以像操作原生js一样操作不可变数据,还能像查看原生json一样查看草稿数据(仅需展开一层代理即可),且始终让用用户对草稿的修改数据实时同步到可查看节点上,极大的提高了调试体验。

这里我们将分别列举limuimmermutativestructura在调试状态下对草稿展开的图示:

  • limu 可任意查看草稿所有节点,且数据始终同步为修改后的数据

    image.png

  • structura 可查看草稿的原始结构,但草稿数据是过期的(注:但log的数据是正确的)

    image.png

  • mutative 保持了和immer类似的结构,无法快速查看

    image.png

  • immer 利用Proxy层层代理,无法快速查看

    image.png

轻量

image.png

imu设计为面向现代浏览器的不可变数据js库,只运行于支持proxy特性的js环境,原生支持根对象为MapSetArrayObject,相比immer 6.3kb大小容量接近减少1/3。

同时提供了更多实用的api

image.png

immut

生成一个不可修改的对象im,但原始对象的修改将同步会影响到im

import { immut } from 'limu';

const base = { a: 1, b: 2, c: [1, 2, 3], d: { d1: 1, d2: 2 } };
const im = immut(base);

im.a = 100; // 修改无效
base.a = 100; // 修改会影响 im

合并后依然可以读到最新值

const base = { a: 1, b: 2, c: [1, 2, 3], d: { d1: 1, d2: 2 } };
const im = immut(base);
const draft = createDraft(base);
draft.d.d1 = 100;

console.log(im.d.d1); // 1,保持不变
const next = finishDraft(draft);
Object.assign(base, next);
console.log(im.d.d1); // 100,im和base始终保持数据同步

immut 采用了读时浅代理的机制,相比deepFreeze会拥有更好性能,适用于不暴露原始对象出去,只暴露生成的不可变对象出去的场景,并利用onOperate收集读依赖

onOperate

支持对createDraftproduceimmt 配置 onOperate回调监听所有读写变化(注:immut只能监听到读变化)

例如以下代码:

const { createDraft, finishDraft } = limu;
const base = new Map([
  ['nick', { list: [1,2,3], info: { age: 1, grade: 4, money: 1000 } }],
  ['fancy', { list: [1,2,3,4,5], info: { age: 2, grade: 6, money: 100000000 } }],
  ['anonymous', { list: [1,2], info: { age: 0, grade: 0, money: 0 } }],
]);
const draft = createDraft(base, { onOperate: console.log });
draft.delete('anonymous');
draft.get('fancy').info.money = 200000000;
const final = finishDraft(draft);

将产生以下监听结果,非常有利于上层框架做读写依赖的收集

image.png

即将发布的helux v3基于limu驱动后完成了非常多有意思的功能,尽请期待。

结语

2年磨砺,让一个最初有点玩具性质的作品最终落地(融入concent、helux)是我意料之外的结果,结合最近爆火的室温超导的韩国团队做类比,他们的LK-99一烧就是20多年,不管结果是否如意,至少拥有一颗挚爱科学的心才能够坚持下来,想起在无数个深夜一遍遍npm run test并优化代码,何尝又不是因为保持一颗挚爱的心而沉溺进去炼代码丹呢?

不管 limu 是否会被淹没在历史的星辰大海里,稳定版的发布算是给自己一个交代了,愿各位码客也保持源源不断的求知欲炼出心中的丹药。

友链

欢迎关注我的其他项目 👇

  • 工具链无关sdk化模块联邦 hel-micro
  • 即将发布的具有深浅依赖收集双策略和有向图架构的全新状态库 helux v3
6 回复

最好不要去类比室温超导的韩国团队,这样的浮夸团队做科研是扯淡的。

@jxycbjhc 没有浮夸,可以参照文章测试指引做验证

@fantasticsoul 没说你写的东西浮夸,说的是你类比的室温超导韩国团队有问题, 你拿一个做事方式有问题的团队来做类比,是不是搞错对象了。。。

@jxycbjhc 明白了,下次我优化下

回到顶部