我维护着 Microdiff,这是一个针对深层对象差异化进行了性能和尺寸优化的库。有人在一个 Microdiff 的问题 中发表了一个帖子,要求我写一篇关于我如何使 Microdiff 变得快速的博客文章。
我相信,对于你注意到的其他库存在的效率低下以及你是如何克服它们的(包括你说的不支持的“边缘情况”)的解释(也许是一篇博客文章?)将会很有趣。它们也将有助于正确解释基准测试结果。
因此,我决定这样做。这篇博客文章描述了我如何使 Microdiff 比大多数其他对象和数组差异化库更快。
免责声明:我在这个问题上有很强的偏见。
差异化介绍
差异化(difference tracking)是追踪两个对象之间的不同之处。例如,假设你有两个对象,对象 a 和对象 b。
const a = {
bananas: true,
apples: true,
peaches: true,
};
const b = {
bananas: true,
apples: false,
lemons: true,
};
使用 Microdiff,要获取这些差异,你会这样做:
import diff from "microdiff";
console.log(JSON.stringify(microdiff(a, b)));
/*
[
{
'type':'CHANGE',
'path':['apples'],
'value':false,
'oldValue':true},
{
'type':'REMOVE',
'path':['peaches'],
'oldValue':true
},
{
'type':'CREATE',
'path':['lemons'],
'value':true
}
]
*/
如你所见,所有的更改,无论是值的更改、添加还是移除,都被记录下来。差异化对于许多事情都是必不可少的,比如虚拟 DOM,因为它们需要记录元素的变化。现在,让我们了解一下 Microdiff 之前的差异化生态系统存在的问题。
Microdiff 之前的差异化生态系统
差异化生态系统处于糟糕的状态。许多库虽然下载量很大,但并没有得到积极的维护,并且制作质量很差。现在,让我们来看看我们的第一个例子,deep-diff。
Deep-Diff 是最受欢迎的 JavaScript 库之一,用于深层对象差异化。它每周的下载量在 100 万到 200 万之间,并且被拥有超过 10k GitHub 星的工具使用。然而,它也有许多缺陷。首先,最后一次提交是在 2019 年,它不遵循现代约定,比如不支持 ESM 并且不提供捆绑的 TypeScript 类型。
此外,它的尺寸和性能也存在问题。它的大小为 5.5kb 压缩后和 1.9kb Gzipped。尺寸并不是很糟糕,但考虑到这只是一个简单的实用程序,因此应该有更小的尺寸。相比之下,Microdiff 的大小为 0.9kb 压缩后和 0.5kb Gzipped。现在,就性能而言,Deep-Diff 也表现不佳。它并不是为了尺寸小和速度快而设计的,因为它有许多不同的功能,这增加了很大的开销。此外,它不会像分组类型行为一样去做一些优化以提高性能。由于这些原因,Microdiff 可以比 快 400%。
Deep-Object-Diff 是另一个流行的差异化库。虽然它自 2018 年以来没有更新过,但它拥有一些 Deep-Diff 缺失的现代特性,比如 ESM 和内置的 TypeScript 类型。此外,如果你使用基本的差异化,它的性能可以接近 Microdiff。然而,它仍然有两个问题,尺寸和提供的信息。首先,虽然它没有 deep-diff 那么大,但它仍然很大,压缩后大小为 5.2kb,Gzipped 大小为 1kb。其次,由于输出设计的方式,它提供的细节很少。Microdiff 提供了更多的细节,比如变更类型、新值、旧值和路径,而 Deep-Object-Diff 的最详细的差异化(detailedDiff
)并不提供旧值。另外,如果你想要接近 Microdiff 的速度,你必须使用主要的差异化函数而不是 detailedDiff
,这样你就不知道变更类型了。
虽然 JSDiff 支持对象差异化,但它主要设计用于文本差异化。它很大,压缩后大小为 15.8kb,Gzipped 大小为 5.9kb,并且非常慢(比 Microdiff 慢 2100%)。我不会深入讨论它为什么如此慢,因为它简单来说并不是为对象差异化而设计的。
Microdiff 如何解决这个问题
专注于性能的架构
Microdiff 通过专注于性能和尺寸而解决了许多这些问题,而不会牺牲易用性。它不是拥有一堆复杂的函数,而是一个简单的递归函数。Microdiff 还使用了诸如组合类型行为等策略,以减小尺寸同时提高性能。例如,假设你想要查看正则表达式和 JavaScript 日期之间的差异。为了准确追踪变化,你必须将正则表达式转换为字符串,并将日期转换为数字。这个问题的一个朴素的解决方案可能是这样的:
if (value instanceof RegExp && value2 instanceof RegExp) {
return value.toString() === value.toString();
} else if (value instanceof Date && value2 instanceof Date) {
return Number(value) === Number(value2);
}
这样可以解决问题,但是如果你还需要检查 new String()
对象或 new Number()
对象呢?(new String()
和 new Number()
不创建原始值,所以你必须像处理日期和正则表达式那样将它们转换为原始值)为了解决这个问题,Microdiff 的实现更像是这样的:
const richTypes = { Date: true, RegExp: true, String: true, Number: true };
if (richTypes[Object.getPrototypeOf(value).constructor.name]) {
return isNaN(value)
? value.toString() === value2.toString()
: Number(value) === Number(value2);
}
这段代码首先获取一个不能直接比较的类型列表(richTypes
)。然后,它检查值是否属于这些类型之一。如果是,那么代码检查值是否可以通过 isNaN
转换为数字。如果可以(在日期和 new Number()
的情况下是 true),它将检查转换为数字的版本。如果不能(在正则表达式和 new String()
的情况下是 true),它将强制将值转换为字符串并比较该版本。在 Microdiff 中,实际的丰富类型转换逻辑并不完全相同,尽管存在一些差异,这些差异会减小尺寸并有助于使逻辑与代码的其余部分匹配。
这些原因是 Microdiff 之所以快速的部分。但另一个原因是它只关注更常见的情况,而不是每一个可能的边缘情况。
关注 99% 的情况而不是解决所有边缘情况
在这方面,自从发布以来,Microdiff 已经取得了巨大的进步。事实上,自从撰写了 最初的解释 以来,Microdiff 已经增加了对更多丰富类型和循环引用的支持。然而,仍然存在一些情况,Microdiff 的行为不够正确,比如当比较具有原型属性的对象时,因为它包括了原型属性。类型组合解决了列出的类型的这个问题,但对于所有其他类型并不适用。在以前的测试中,排除原型属性的方法并不快。但是,我可能会添加一种方式,让你可以传递自定义的继承类型来进行字符串/数字转换,这可能有助于某些情况。然而,目前还不可能实现这一点。
结论
总之,Microdiff 是最快的差异化库,因为它专注于性能的架构和关注 99% 的情况,而且 Microdiff 仍然能够使用现代特性并且易于使用。如果你对 Microdiff 感兴趣,请查看 GitHub 仓库。我希望你从中学到了一些东西,感谢你的阅读。