之前学习函数式编程语言的过程中,有 3 比较重要的特性:
JavaScript 虽然具有函数式语言的特性,但是很可惜,它还是没有具备不可变数据这一大优势。
在开发复杂系统的情况下,不可变性具有两个非常重要的特性:不可修改 (减少错误的发生) 以及结构共享(节省空间)。不可修改也意味着数据容易回溯,易于观察。
当前端开发谈到不可变性数据时候,第一个一定会想到 Immer 库,Immer 利用
ES6 的 proxy,几乎以最小的成本实现了 js 的不可变数据结构。React 也通过不可变数据结构结合提升性能。不过 Immer
还是有一定侵入性。那么有没有较好且没有侵入的解决方案呢?本文将介绍另一个工具 immutability-helper,该库也在 React 性能优化 有所描述。
浅拷贝实现不可变数据
最简单的不可变数据结构就是深拷贝了。
但这对于大部分的场景来说是无法接受的,它大量消耗了时间与空间,会让复杂的系统变得不可用。
事实上,开发中完全可以利用浅拷贝来实现不可变数据结构的,这也是 immutability-helper 所使用的方案。我们先来构造以下数据:
我们怎么才能在不改变原有数据的情况下改变 user.company.name 呢?代码如下
我们并没有改变原有的 user 数据,同时获取了共用其他数据结构的 newUser。同时,如果当前功能需要数据回溯,即使将当前对象直接存入一个数组中,内存占用也不会出现非常大的情况。当然,Immer Patches 对于回溯的处理更优,后续个人也会继续解读不可变结构的其他工具库。
immutability-helper 用法
使用浅拷贝来实现不可变数据结构是不错,但是编写起来过于复杂。当开发者面对复杂的数据结构,未免捉襟见肘。还很容易写出 bug。
于是 kolodny 出手编写了 immutability-helper 来帮助我们构建不可变的数据结构。
我们可以看到 update 函数传入之前的数据以及一个对象结构,得到了新的数据。$set 是替换目前的数据的意思。除此之外,还有其他的命令。
针对数组的操作
- { $push: any[] } 针对当前数组数据 push 一些数组
- { $unshift: any[] } 针对当前数组数据 unshift 一些数组
- { $splice: {start: number, deleteCount: number, …items: T[]}[] }
使的参数调用目标上的每个项目,注意顺序
还有一个可以基于当前数据进行操作的 $apply.
该库还有针对对象的 $set, $unset, $merge 以及针对 Map,Set 的 $add, $remove。甚至我们还可以自定义指令。这些就不一一介绍了,大家遇到了就自行查阅一下文档。
添加辅助函数
对比之前的写法无疑对我们已经有很大的帮助了。但是针对当前操作还是非常难受。还是需要编写复杂的数据结构。
编写如下函数:
当前代码在 val-path-helper 中,该库还有其他的功能,目前还在编写中。
如此一来我们就可以直接编辑数据了。
实测 React
这里我们开始实测 immutability-helper 对于 react 渲染的帮助。代码利用 Profiler API 来查看渲染代价。
我们来看一下结果会怎么样。
测试按钮 1:
- 点击 修改学校1,触发 handleSchools 函数
- 渲染原因 update,本次更新 committed 花费的渲染时间 0.8999999999068677
- 渲染失败,由于 user.schools 没有改变,renderSchools 不会重新渲染
- 再次点击 修改学校1,触发 handleSchools 函数
- 渲染原因 update,本次更新 committed 花费的渲染时间 0.10000000009313226
测试按钮 2:
- 点击 修改学校2,触发 handleSchools 函数
- 渲染原因 update,本次更新 committed 花费的渲染时间 1.6000000000931323
- 渲染成功
- 再次点击 修改学校2,触发 handleSchools 函数
- 没有进行任何修改,同时也没有触发 renderCallback
测试按钮 3:
- 点击 修改学校3,触发 handleSchools 函数
- 渲染原因 update,本次更新 committed 花费的渲染时间 1.300000000745058
- 渲染成功
- 再次点击 修改学校3,触发 handleSchools 函数
- 渲染原因 update,本次更新 committed 花费的渲染时间 0.5
根据上述条件,我们可以看到 immutability-helper 的第二个好处,如果当前数据没有改变,将不会改变对象,从而不会触发渲染。
这里尝试把 schools 数据长度增加到 10002,再做一下测试。发现花费的渲染时间没有太多改变,均在 40 ms 左右,此时我们用 console.time 测试一下深拷贝和 immutability-helper 的时间差距。
得出的结果如下所示
- 浅拷贝: 1.807861328125 ms
- 浅拷贝: 0.165771484375 ms(第二次调用)
- 深拷贝: 8.59716796875 ms
测试下来有 4 倍的性能差距,再尝试在数据中添加 4 个 schools 大小的数据.
- 浅拷贝: 3.60302734375 ms
- 浅拷贝: 0.10107421875 ms(第二次调用)
- 深拷贝: 28.789794921875 ms
可以看到,随着数据的增大,耗费的时间差距也变得非常恐怖。
源代码分析
immutability-helper 仅有几百行代码。实现也非常简单。我们一起来看看作者是如何开发这个工具库的。
先是工具函数(保留核心,环境判断,错误警告等逻辑去除):
然后是核心代码(同样保留核心) :
最后是通用指令的解析
根据上述代码,我们终于了解到了为什么作者需要传递一个对象来进行处理,同时我们也可以看出来如果当前数据路径的 key 值和指令相同就会出现错误。
其他
大家在看到如上代码会想到什么呢?就是个人之前在 手写一个业务数据比对库 中推荐的 westore diff 函数。
后续个人会结合 diff 以及 immutability-helper 开发一些有趣的工具。
参考资料
immutability-helper
val-path-helper
immutability-helper实践与优化