之前学习函数式编程语言的过程中,有 3 比较重要的特性:
- 函数是一等公民
- 数据不可变
- 惰性求值
JavaScript 虽然具有函数式语言的特性,但是很可惜,它还是没有具备不可变数据这一大优势。
在开发复杂系统的情况下,不可变性具有两个非常重要的特性:不可修改 (减少错误的发生) 以及结构共享(节省空间)。不可修改也意味着数据容易回溯,易于观察。
当前端开发谈到不可变性数据时候,第一个一定会想到 Immer 库,Immer 利用
ES6 的 proxy,几乎以最小的成本实现了 js 的不可变数据结构。React 也通过不可变数据结构结合提升性能。不过 Immer
还是有一定侵入性。那么有没有较好且没有侵入的解决方案呢?本文将介绍另一个工具 immutability-helper,该库也在 React 性能优化 有所描述。
浅拷贝实现不可变数据
最简单的不可变数据结构就是深拷贝了。
const newUser = JSON.parse(JSON.stringify(user));
newUser[key] = value;
但这对于大部分的场景来说是无法接受的,它大量消耗了时间与空间,会让复杂的系统变得不可用。
事实上,开发中完全可以利用浅拷贝来实现不可变数据结构的,这也是 immutability-helper 所使用的方案。我们先来构造以下数据:
const user = {
name: "wsafight",
company: {
name: "测试公司",
otherInfo: {
owner: "测试公司老板",
},
},
schools: [
{ name: "测试小学" },
{ name: "测试初中" },
{ name: "测试高中" },
],
};
我们怎么才能在不改变原有数据的情况下改变 user.company.name 呢?代码如下
// 修改公司名称
const newUser = {
...user,
company: {
...user.company,
name: "升级测试公司",
},
};
user === newUser;
// false
user.company === newUser.company;
// false
user.company.otherInfo === newUser.company.otherInfo;
// true
newUser.schools === user.schools;
// true
我们并没有改变原有的 user 数据,同时获取了共用其他数据结构的 newUser。同时,如果当前功能需要数据回溯,即使将当前对象直接存入一个数组中,内存占用也不会出现非常大的情况。当然,Immer Patches 对于回溯的处理更优,后续个人也会继续解读不可变结构的其他工具库。
immutability-helper 用法
使用浅拷贝来实现不可变数据结构是不错,但是编写起来过于复杂。当开发者面对复杂的数据结构,未免捉襟见肘。还很容易写出 bug。
于是 kolodny 出手编写了 immutability-helper 来帮助我们构建不可变的数据结构。
import update from "immutability-helper";
// 修改公司名称
const newUser = update(user, {
company: {
name: {
$set: "升级测试公司",
},
},
});
我们可以看到 update 函数传入之前的数据以及一个对象结构,得到了新的数据。$set 是替换目前的数据的意思。除此之外,还有其他的命令。
针对数组的操作
- { $push: any[] } 针对当前数组数据 push 一些数组
- { $unshift: any[] } 针对当前数组数据 unshift 一些数组
- { $splice: {start: number, deleteCount: number, ...items: T[]}[] }
使的参数调用目标上的每个项目,注意顺序
// 添加了用户的学校
const newUser = update(user, {
schools: {
$push: [
{ name: "测试大学" },
],
},
});
const newUser = update(user, {
schools: {
$unshift: [
{ name: "测试幼儿园" },
],
},
});
// 排序操作
const sourceItem = user[sourceIndex];
const newUser = update(user, {
schools: {
$splice: [
[sourceIndex, 1],
[targetIndex, 0, sourceItem!],
],
},
});
const newUser = update(user, {
schools: {
// 也可以同时放入命令进行操作
$unshift: [
{ name: "测试幼儿园" },
],
$push: [
{ name: "测试幼儿园" },
],
$splice: [],
},
});
还有一个可以基于当前数据进行操作的 $apply.
// 每次更新都基于当前的数据来计算
const newUser = update(user, {
name: {
$apply: (name) => `${name} change`,
},
});
该库还有针对对象的 $set, $unset, $merge 以及针对 Map,Set 的 $add, $remove。甚至我们还可以自定义指令。这些就不一一介绍了,大家遇到了就自行查阅一下文档。
添加辅助函数
对比之前的写法无疑对我们已经有很大的帮助了。但是针对当前操作还是非常难受。还是需要编写复杂的数据结构。
编写如下函数:
export const convertImmutabilityByPath = (
// 对象路径
path: string,
// 当前操作
actions: Record,
) => {
// 路径 path 没有或者不是字符串,直接返回空对象
if (!path || typeof path !== "string") {
return {};
}
// actions 没有或者不是对象,直接返回空对象
if (
!actions || Object.prototype.toString.call(actions) !== "[object Object]"
) {
return {};
}
// 简单替换 [ 和 ] 为 . 和 空字符串,没有做太多逻辑处理
// 请不要建立奇怪的对象路径,否则可能出现未知错误
const keys = path.replace(/\[/g, ".")
.replace(/\]/g, "")
.split(".")
.filter(Boolean);
const result: Record = {};
let current = result;
const len = keys.length;
// 根据路径一步步构建对象
keys.forEach((key: string, index: number) => {
current[key] = index === len - 1 ? actions : {};
current = current[key];
});
return result;
};
当前代码在 val-path-helper 中,该库还有其他的功能,目前还在编写中。
如此一来我们就可以直接编辑数据了。
convertImmutabilityByPath(
"schools[0].name",
{ $set: "试试小学" },
);
// 也可以使用 'schools.0.name' 'schools.[0].name'
// 甚至 'schools[0.name' 也行
// 我们也可以使用这种方式操作数据中对象
convertImmutabilityByPath(
`schools[${index}].${key}`,
{ $set: value },
);
实测 React
这里我们开始实测 immutability-helper 对于 react 渲染的帮助。代码利用 Profiler API 来查看渲染代价。
function App() {
const [user, setUser] = useState({
name: "wsafight",
company: {
name: "测试公司",
},
schools: [
{ name: "测试小学", start: "1998-01-02", end: "2004-01-02" },
{ name: "测试高中", start: "2005-01-02", end: "2007-01-02" },
],
});
/**
* Profiler 组件,可以查看渲染
*/
const renderCallback = (...info) => {
console.log("渲染原因", info[1]);
console.log("本次更新 committed 花费的渲染时间", info[2]);
};
const handleSchoolsChange = () => {
user.schools[0].name = "测试小学1";
setUser({ ...user });
};
const handleSchools2 = () => {
// immutability-helper
const newUser = update(
user,
convertImmutabilityByPath("schools[0].name", {
$set: "测试小学2",
}),
);
setUser(newUser);
};
const handleSchools3 = () => {
user.schools[0].name = "测试小学3";
// 深拷贝
const newUser = JSON.parse(JSON.stringify(user));
setUser(newUser);
};
// 使用 useMemo 优化性能,也可以使用 memo 或者 shouldComponentUpdate
// 如果 user.schools 不变,则不会重新渲染
const renderSchools = useMemo(() => {
return (
{user.schools.map((item) => {
return (
{item.name}
{item.start}
{item.end}
);
})}
);
}, [user.schools]);
return (
{user.name}
{renderSchools}
);
}
我们来看一下结果会怎么样。
测试按钮 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 的时间差距。
const handleSchools2 = () => {
console.time("浅拷贝");
const newUser = update(
user,
convertImmutabilityByPath("schools[0].name", {
$set: "测试小学2",
}),
);
console.timeEnd("浅拷贝");
setUser(newUser);
};
const handleSchools3 = () => {
user.schools[0].name = "测试小学3";
console.time("深拷贝");
const newUser = JSON.parse(JSON.stringify(user));
console.timeEnd("深拷贝");
setUser(newUser);
};
得出的结果如下所示
- 浅拷贝: 1.807861328125 ms
- 浅拷贝: 0.165771484375 ms(第二次调用)
- 深拷贝: 8.59716796875 ms
测试下来有 4 倍的性能差距,再尝试在数据中添加 4 个 schools 大小的数据.
- 浅拷贝: 3.60302734375 ms
- 浅拷贝: 0.10107421875 ms(第二次调用)
- 深拷贝: 28.789794921875 ms
可以看到,随着数据的增大,耗费的时间差距也变得非常恐怖。
源代码分析
immutability-helper 仅有几百行代码。实现也非常简单。我们一起来看看作者是如何开发这个工具库的。
先是工具函数(保留核心,环境判断,错误警告等逻辑去除):
// 提取函数,大量使用时有一定性能优势
const hasOwnProperty = Object.prototype.hasOwnProperty;
const splice = Array.prototype.splice;
const toString = Object.prototype.toString;
// 检查类型
function type(obj: T) {
return (toString.call(obj) as string).slice(8, -1);
}
// 浅拷贝,使用 Object.assign,如果没有就手写一个
const assign = Object.assign || /* istanbul ignore next */
((target: T & any, source: S & Record) => {
getAllKeys(source).forEach((key) => {
if (hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
});
return target as T & S;
});
// 获取对象 key
const getAllKeys = typeof Object.getOwnPropertySymbols === "function"
? (obj: Record) =>
Object.keys(obj).concat(Object.getOwnPropertySymbols(obj) as any)
: /* istanbul ignore next */
(obj: Record) => Object.keys(obj);
// 所有类型的拷贝函数
// 如果不是数组,Map,Set,对象,直接返回 拷贝值
function copy(
object: T extends ReadonlyArray ? ReadonlyArray
: T extends Map ? Map
: T extends Set ? Set
: T extends object ? T
: any,
) {
return Array.isArray(object)
? assign(object.constructor(object.length), object)
: (type(object) === "Map")
? new Map(object as Map)
: (type(object) === "Set")
? new Set(object as Set)
: (object && typeof object === "object")
? assign(Object.create(Object.getPrototypeOf(object)), object) as T
: /* istanbul ignore next */
object as T;
}
然后是核心代码(同样保留核心) :
export class Context {
// 导入所有指令
private commands: Record = assign({}, defaultCommands);
// 添加扩展指令(指令不要和对象中数据 key 相同)
public extend(directive: string, fn: (param: any, old: T) => T) {
this.commands[directive] = fn;
}
// 功能核心
public update = never>(
object: T,
$spec: Spec,
): T {
// 增强健壮性,如果操作命令是函数,修改为 $apply
const spec = (typeof $spec === "function") ? { $apply: $spec } : $spec;
// 返回对象(数组)
let nextObject = object;
// 遍历对象,获取数据项和指令
getAllKeys(spec).forEach((key: string) => {
// 传入的是一个对象,如果当前 key 是指令的话,就进行操作
if (hasOwnProperty.call(this.commands, key)) {
// 性能优化,遍历过程中,如果 object 还是当前之前数据
const objectWasNextObject = object === nextObject;
// 用指令修改对象
nextObject = this.commands[key](
(spec as any)[key],
nextObject,
spec,
object,
);
// 修改后,两者使用传入函数计算,还是相等的情况下,直接使用之前数据
// 这样的话,数据没有修改,对象也不会改变
if (objectWasNextObject && this.isEquals(nextObject, object)) {
nextObject = object;
}
} else {
// 不在指令集中,做其他操作
// 类似于 update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});
// 解析对象规则后继续递归调用 update, 不断递归,不断返回
const nextValueForKey = type(object) === "Map"
? this.update((object as any as Map).get(key), spec[key])
: this.update(object[key], spec[key]);
const nextObjectValue = type(nextObject) === "Map"
? (nextObject as any as Map).get(key)
: nextObject[key];
// 内部数据有改变的情况下,进行 copy 操作
if (
!this.isEquals(nextValueForKey, nextObjectValue) ||
typeof nextValueForKey === "undefined" &&
!hasOwnProperty.call(object, key)
) {
if (nextObject === object) {
nextObject = copy(object as any);
}
if (type(nextObject) === "Map") {
(nextObject as any as Map).set(key, nextValueForKey);
} else {
nextObject[key] = nextValueForKey;
}
}
}
});
// 返回对象
return nextObject;
}
}
最后是通用指令的解析
const defaultCommands = {
$push(value: any, nextObject: any, spec: any) {
// 数组添加,返回 concat 新数组
return value.length ? nextObject.concat(value) : nextObject;
},
$unshift(value: any, nextObject: any, spec: any) {
return value.length ? value.concat(nextObject) : nextObject;
},
$splice(value: any, nextObject: any, spec: any, originalObject: any) {
// 循环 splice 调用
value.forEach((args: any) => {
if (nextObject === originalObject && args.length) {
nextObject = copy(originalObject);
}
splice.apply(nextObject, args);
});
return nextObject;
},
$set(value: any, _nextObject: any, spec: any) {
// 直接替换当前数值
return value;
},
$toggle(targets: any, nextObject: any) {
const nextObjectCopy = targets.length ? copy(nextObject) : nextObject;
// 当前对象或者数组切换
targets.forEach((target: any) => {
nextObjectCopy[target] = !nextObject[target];
});
return nextObjectCopy;
},
$unset(value: any, nextObject: any, _spec: any, originalObject: any) {
// 拷贝后循环删除
value.forEach((key: any) => {
if (Object.hasOwnProperty.call(nextObject, key)) {
if (nextObject === originalObject) {
nextObject = copy(originalObject);
}
delete nextObject[key];
}
});
return nextObject;
},
$add(values: any, nextObject: any, _spec: any, originalObject: any) {
if (type(nextObject) === "Map") {
values.forEach(([key, value]) => {
if (nextObject === originalObject && nextObject.get(key) !== value) {
nextObject = copy(originalObject);
}
nextObject.set(key, value);
});
} else {
values.forEach((value: any) => {
if (nextObject === originalObject && !nextObject.has(value)) {
nextObject = copy(originalObject);
}
nextObject.add(value);
});
}
return nextObject;
},
$remove(value: any, nextObject: any, _spec: any, originalObject: any) {
value.forEach((key: any) => {
if (nextObject === originalObject && nextObject.has(key)) {
nextObject = copy(originalObject);
}
nextObject.delete(key);
});
return nextObject;
},
$merge(value: any, nextObject: any, _spec: any, originalObject: any) {
getAllKeys(value).forEach((key: any) => {
if (value[key] !== nextObject[key]) {
if (nextObject === originalObject) {
nextObject = copy(originalObject);
}
nextObject[key] = value[key];
}
});
return nextObject;
},
$apply(value: any, original: any) {
// 传入函数,直接调用函数修改
return value(original);
},
};
根据上述代码,我们终于了解到了为什么作者需要传递一个对象来进行处理,同时我们也可以看出来如果当前数据路径的 key 值和指令相同就会出现错误。
其他
convertImmutabilityByPath(
`schools[${index}].name`,
{ $set: "试试小学" },
);
大家在看到如上代码会想到什么呢?就是个人之前在 手写一个业务数据比对库 中推荐的 westore diff 函数。
const result = diff({
a: 1,
b: 2,
c: "str",
d: { e: [2, { a: 4 }, 5] },
f: true,
h: [1],
g: { a: [1, 2], j: 111 },
}, {
a: [],
b: "aa",
c: 3,
d: { e: [3, { a: 3 }] },
f: false,
h: [1, 2],
g: { a: [1, 1, 1], i: "delete" },
k: "del",
});
// 结果
{
"a": 1,
"b": 2,
"c": "str",
"d.e[0]": 2,
"d.e[1].a": 4,
"d.e[2]": 5,
"f": true,
"h": [1],
"g.a": [1, 2],
"g.j": 111,
"g.i": null,
"k": null
}
后续个人会结合 diff 以及 immutability-helper 开发一些有趣的工具。
鼓励一下
如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。