Awesome
diffy-update
本库实现了一个更新对象的函数,同时随更新过程输出新旧对象的差异结构
为何要开发这个库
在当前的前端形势下,不可变(Immutable)的概念开始出现在开发者的视野中,以不可变作为第一考虑的设计和实现会让程序普遍拥有更好的可维护性
而在不可变的前提下,我们不能对一个对象的属性进行直接的操作(赋值、修改、删除等),因此更新一个对象变得复杂:
let newObject = clone(source);
newObject.foo = 1;
如果我们需要修改更深层次的属性,则会变得更为复杂:
let newObject = clone(source);
newObject.foo = clone(newObject.foo);
newObject.foo.bar = 1;
// 有其它属性都需要依次操作
这是相当麻烦的,每次更新都会需要大量的代码,因此偷懒的话我们会用深克隆来搞定这事:
let newObject = deepClone(source);
newObject.foo.bar = 1;
// 其它修改
但是深克隆存在一些严重的问题:
- 性能不好,我们只更新一层属性的情况下,原对象的n层属性都要经过克隆操作,有大量无谓的遍历和对象创建开销
- 遇到环引用无法处理
基于此,社区上出现了一些用声明式的指令更新对象的辅助库,比如React Immutability Helpers,这些库封装了上面的逻辑,且选择了效率最优(仅复制未更新的属性,不需要深克隆)的方案
但是随之而来的一个问题是,当我们更新完一个对象,如何知道更新了什么?如果我们所有的针对更新的操作都在更新后立即进行,那么在编码时我们可以人为地基于更新指令进行:
let newObject = update(source, {foo: {$set: 1}});
view.globalDatasource = newObject;
view.updateUserInterfaceOfFoo();
但现实中几乎不可能存在如此理想的场景,更多的时候我们仅仅拿到一个未知来源的newObject
。如果根据newObject
强制进行界面的完全刷新,自然会导致性能的损失。我们更希望找到对象更新前后的差异,可以针对性地进行后续的操作,因此就会引入diff
这一概念,比如使用flibit/diff:
let differences = diff(oldObject, newObject);
for (let node of differences) {
this.updateForPath(node.path, node.rhs);
}
但是值得注意的是,差异分析本身是一个基于两个对象的深度遍历的操作,它是耗时的,在一个系统中引入这样一个环节必然会损失掉一定的性能
基于以上的原因,我们更希望有这样的一个库,它可以提供基本的对象更新的功能,且在更新的同时实时计算出对象前后的差异。因为更新的过程中知道更新的指令,所以可以在没有额外的遍历损耗的情况下直接得到差异,diffy-update
库正是以此为目标而诞生的
使用
前置环境
diffy-update
完全由ES2015+编写,如果环境无法满足要求,则在使用前需要添加对应的polyfill
或shim
,并使用babel进行编译,全局至少要包含Object.entries
函数的实现
针对babel
除es2015 preset外,至少需要function bind插件得以正常工作
基本场景
仅withDiff
函数会提供差异对象:
import {withDiff, isDiffNode} from 'diffy-update';
let source = {
name: {
firstName: 'Navy',
lastName: 'Wong'
}
};
let [target, diff] = withDiff(source, {name: {firstName: {$set: 'Petty'}}});
console.log(target);
// {
// name: {
// firstName: 'Pretty',
// lastName: 'Wong'
// }
// }
console.log(isDiffNode(diff.name.firstName));
// true
console.log(diff);
// {
// name: {
// firstName: {
// changeType: 'change',
// oldValue: 'Navy',
// newValue: 'Pretty'
// }
// }
// }
当前版本仅实现了针对基本类型和对象的差异计算,针对数组的差异计算将在后续版本中提供
差异对象的结构与输入的source
对象相同,其中如果有一个属性有被修改,则该属性会变为一个“差异节点”,使用isDiffNode
进行判断即可,如果一个属性为差异节点,则会仅包含以下属性:
changeType
表示修改的类型,值为"add"
、"remove"
或者"change"
oldValue
表示修改前的值,如果changeType
为"add"
则值恒定为undefined
newValue
表示修改后的值,如果changeType
为"remove"
则值恒定为undefined
快捷方式
update
模块的默认导出是withDiff
函数的快捷方式,仅返回更新后的对象,不提供差异对象,可用于函数内部更新对象等常用场景
除此之外,本库还提供了一系列快捷函数,如set
、push
、unshift
、merge
、defaults
等,这些函数可用于快速更新对象的某个属性,可以通过API文档进行查阅
链式调用
chain
模块提供了链式更新一个对象的方法,使用方法如下:
import chain from 'update/chain';
let source = {
name: {
firstName: 'Navy',
lastName: 'Wong'
},
age: 20,
children: ['Alice', 'Bob']
};
let target = chain(source)
.set(['name', 'firstName'], 'Petty')
.set('age', 21)
.push('children', 'Cary');
console.log(target);
// {
// name: {
// firstName: 'Pretty',
// lastName: 'Wong'
// },
// age: 21,
// children: ['Alice', 'Bob', 'Petty']
// }
在使用chain
后得到的对象也有withDiff
方法,可以同时获得更新后的对象和差异对象。
chain
后的对象每次调用对应的更新方法(如set
、push
等),都会得到一个新的对象,原有的对象不会受影响,比如:
import chain from 'update/chain';
let source = {
name: {
firstName: 'Navy',
lastName: 'Wong'
},
age: 20,
children: ['Alice', 'Bob']
};
let updateable = chain(source);
let nameUpdated = updateable.set(['name', 'firstName'], 'Petty');
let ageUpdated = nameUpdated.set('age', 21);
console.log(nameUpdated);
// 注意age并没有受影响
//
// {
// name: {
// firstName: 'Pretty',
// lastName: 'Wong'
// },
// age: 20,
// children: ['Alice', 'Bob', 'Petty']
// }
chain
是延迟执行的,所以假设已经对foo
进行了操作,再对着foo.bar
(或更深层级的属性)进行操作,会出现不可预期的行为,如以下代码:
import chain from 'update/chain';
let source = {
name: {
firstName: 'Navy',
lastName: 'Wong'
},
age: 20,
children: ['Alice', 'Bob']
};
let target = chain(source)
.set('ownedCar', {brand: 'Benz'})
.merge('ownedCar', {type: 'C Class'});
// 注意ownedCar.type并没有生效
//
// {
// name: {
// firstName: 'Pretty',
// lastName: 'Wong'
// },
// age: 20,
// children: ['Alice', 'Bob', 'Petty'],
// ownedCar: {
// brand: 'Benz'
// }
// }
这并不会给你预期的结果,所以在使用链式调用的时候要注意每个指令的路径。
差异合并
在一个完整的应用模型中,如果每一次对数据的操作都映射为后续的操作(如UI更新),则可能出现一些不可预期的问题:
- 可能因为频繁的UI更新导致性能的问题
- 如果存在一些循环的变化,则可能进入死循环
所以在成熟的应用中,我们通常会在进行若干次数据变化后,根据整体的变化来进行后续的逻辑,这就要求每次变化产生的差异可以相互合并,生成一个最终的差异以供后续使用
diffy-update
库提供了merge
模块来支持差异的合并,我们可以使用mergeDiff
函数将多次withDiff
生成的差异进行合并:
import {withDiff} from 'diffy-update/update';
import {mergeDiff} from 'diffy-upadte/merge';
let source = {
age: 21,
name: {
firstName: 'Gray',
lastName: 'Zhang'
}
};
let [ageUpdated, diffOnAge] = withDiff(source, {age: {$set: 22}});
let [nameUpdated, diffOnName] = withDiff(ageUpdated, {name: {firstName: {$set: 'Pretty'}}});
console.log(nameUpdated);
// {
// age: 22,
// name: {
// firstName: 'Pretty',
// lastName: 'Zhang'
// }
// }
console.log(diffOnAge);
// {
// age: {
// changeType: 'change',
// oldValue: 21,
// newValue: 22
// }
// }
console.log(diffOnName);
// {
// name: {
// firstName: {
// changeType: 'change',
// oldValue: 'Gray',
// newValue: 'Pretty'
// }
// }
// }
// 注意要提供最先和最后的对象,即`source`和`nameUpdated`,中间过程产生的`ageUpdated`没用
let totalDiff = mergeDiff(diffOnAge, diffOnName, source, nameUpdated);
console.log(totalDiff);
// {
// age: {
// changeType: 'change',
// oldValue: 21,
// newValue: 22
// },
// name: {
// firstName: {
// changeType: 'change',
// oldValue: 'Gray',
// newValue: 'Pretty'
// }
// }
// }
差异的合并是智能的,它包括:
- 同一个属性发生多次变化,则会合并成一个,根据变化的类型生成新的变化,如
"add"
后再进行"change"
则会合并为一个"add"
- 如果变化导致最终属性值前后相同,则该差异会被丢弃,如先
"add"
后"remove"
,或者多次"change"
导致最终值并没有变化 - 一个属性变化后,其子属性再变化,或者反之,也同样会进行合并,如
foo.bar
变化后再修改foo
,则会变成foo
的整体变化
需要注意的是,差异合并本身是一个消耗资源的计算工作(虽然很快),因此在实现上并不追求输出最小的差异集,而是在性能和正确性之间取得一个折衷,在可接受的速度之下输出相对优化后的差异结果
应用场景
在使用diffy-update
后,我们可以制作一个非常简易的UI-数据绑定模型,其基本逻辑为:
- 在UI中可以声明某一区块与数据对象中某个属性的绑定关系
- 打开一个异步操作,使用
setImmediate
等函数完成 - 允许用户通过
withDiff
方法更新数据,同时记录最初的数据对象、每一次的差异以及更新后的新数据对象 - 在异步回调后,将收集的差异,配合原数据对象、新数据对象,使用
mergeDiff
生成最终的差异对象 - 通过遍历差异对象,仅更新UI中绑定了产生变化的属性部分
当然一个完整的模型需要更多的细节考虑,但大致思路如上所示,diffy-update
在这一模型中作为底层的工具库,可以提供非常大的帮助
API文档
npm i
npm run doc
open doc/api/index.html
更新历史
2.0.0
- 差异节点中的
$change
属性改名为changeType
,现在应该使用isDiffNode
函数判断一个对象是否为差异节点 - 文档更新为简体中文
2.1.0
update
模块下的isDiffNode
函数已标记为废弃,请使用diffNode
模块下的该函数- 增加了差异合并的相关函数
2.2.0
- 针对发布到npm的版本增加了编译后的
dist
目录,可以直接使用
2.3.0
- 修改了编译方式,现在所有文件会编译至根目录,以便NodeJS环境下使用
2.4.0
- 添加了
$splice
指令以及对应的splice
快捷函数 - 构建增加了压缩后的文件及SourceMap
2.5.0
- 增加了
chain
模块提供链式调用