前言
欢迎了解和关注limu,访问文档并点击右键调出控制台后可实时体验 limu api 和 immer api做对比(全局已绑定limu和immer对象)。
接下来让我们一起深入了解limu的诞生历程吧 ^\_^
不可变数据的现状
不可变数据由于拥有结构共享的特性,让一些严重依赖浅比较的框架快速获得性能收益(如react),同时也让一些需要使用严格不可变数据的场景避免了深克隆带来的冗余性能开销,而当下除了immutablejs 和 immer 这两款非常流行的工具库之外,有没有一款比它们的性能和易用性都更好的不可变数据工具库呢?在回答此问题之前,我们先看下immutablejs
和immer
陷入的困境。
immutablejs
作为一个先驱者,最早的git提交记录可以追溯到2014年4月,伴随着react
的不可变状态编程理念在2015年之后开始越来越走红,现已达到30K+ star数量,它在js语言世界里拥有为不可变数据指引方向般的重要地位,带领大家认识到了不可变数据在某些特定编程领域的重要性。
不过它的问题也比较突出,主要归结为2点
- 1 api 复杂,与原始js操作处理隔离的状态,有很重的学习成本和记忆负担
- 2 内建了一套自己的数据结构,需要通过
fromJs
和toJs
做普通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
的性能问题较为突出,见此问题描述,社区开始有不少作者另起炉灶尝试突破,留意到这里面较为突出的有structura和mutative,经我实测发现确实如它们所说的快过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+ 测试用例
- 测试覆盖率到达了
97%
同时也让性能和易用性均达到我的理想后,终于可以正式宣布稳定版发布,且已开始作为基础组件服务于新闻门户,接下来将重点介绍limu的3大优势。
更快
区别于immer
的写时复制机制,limu
采用读时浅克隆写时标记修改机制,具体操作流程我们将以下图为例来讲解,使用produce
接口生成草稿数据后,limu
只会对草稿数据读取路径上经过的相关数据节点做浅克隆
修改了目标节点下的值的时候,则会回溯该节点到跟节点的所有途径节点并标记这些节点为已修改
最后结束草稿生成final
对象时,limu
只需要从根节点把所有标记修改的节点的副本替换到对应位置即可,没有标记修改的节点则不使用副本(注:生成副本不代表已被修改)
这样的机制在对象的原始层级关系较为复杂且修改路径不广的场景下,且不需要冻结原始对象时,性能表现异常优异,可达到比 immer 快 5 倍或更多,只有在修改数据逐渐遍及整个对象所有节点时,limu
的性能才会呈线性下载趋势,逐步接近immer
,但也要比immer
快很多。
测试验证
为验证上述结论,用户可按照以下流程获得针对limu
与immer
性能测试对比数据
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
得到如下结果 (柱条越短代表越快)
注:以上是v9版本,immer 23年4月发布了v10版本,经测试发现结果变化不大,性能提升不明显
一个是我们自己准备的深层次 json 读写案例,结果如下 (柱条越短代表越快)
可通过注入
ST
值调整不同的测试策略,例如ST=1 node caseReadWrite.js
,不注入时默认为1
- ST=1,关闭冻结,不操作数组
- ST=2,关闭冻结,操作数组
- ST=3,开启冻结,不操作数组
- ST=4,开启冻结,操作数组
更强
limu
利用Symbol
和原型链隐藏代理元数据,让元数据始终跟随草稿节点,在草稿结束后才擦除,让用户不仅可以像操作原生js一样操作不可变数据,还能像查看原生json一样查看草稿数据(仅需展开一层代理即可),且始终让用用户对草稿的修改数据实时同步到可查看节点上,极大的提高了调试体验。
这里我们将分别列举limu
、immer
、mutative
、structura
在调试状态下对草稿展开的图示:
limu 可任意查看草稿所有节点,且数据始终同步为修改后的数据
structura 可查看草稿的原始结构,但草稿数据是过期的(注:但log的数据是正确的)
mutative 保持了和immer类似的结构,无法快速查看
- immer 利用Proxy层层代理,无法快速查看
轻量
imu设计为面向现代浏览器的不可变数据js库,只运行于支持proxy特性的js环境,原生支持根对象为Map
,Set
,Array
,Object
,相比immer 6.3kb大小容量接近减少1/3。
同时提供了更多实用的api
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
支持对createDraft
、produce
、immt
配置 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);
将产生以下监听结果,非常有利于上层框架做读写依赖的收集
即将发布的helux v3基于limu
驱动后完成了非常多有意思的功能,尽请期待。
结语
2年磨砺,让一个最初有点玩具性质的作品最终落地(融入concent、helux)是我意料之外的结果,结合最近爆火的室温超导的韩国团队做类比,他们的LK-99一烧就是20多年,不管结果是否如意,至少拥有一颗挚爱科学的心才能够坚持下来,想起在无数个深夜一遍遍npm run test
并优化代码,何尝又不是因为保持一颗挚爱的心而沉溺进去炼代码丹呢?
不管 limu 是否会被淹没在历史的星辰大海里,稳定版的发布算是给自己一个交代了,愿各位码客也保持源源不断的求知欲炼出心中的丹药。
友链
欢迎关注这些有趣的项目