递归差异
差不多 3 年后,我很高兴为这个问题提供一个全新的答案。
我们从两个不同的对象开始
const x =
{ a: 1, b: 2, c: 3 }
const y =
{ a: 1, b: 3, d: 4 }
console.log (diff (x, y))
// => ???
两个对象具有相同的a 属性。 b 属性不一样。只有x 有c 属性,只有y 有d 属性。那么???究竟应该是什么?
从diff 的角度来看,我们的输入对象a 和b 之间的关系可以是完全任意的。为了传达哪个对象造成了差异,diff 分配了描述符 left 和 right
console.log (diff (x, y))
// { b: { left: 2, right: 3 }, c: { left: 3 }, d: { right: 4 } }
在上面的输出中我们可以看到
- 哪些属性不同 –
b、c 和 d
- 哪个对象造成了差异 -
left 和/或 right
- “不同”值 - 例如左侧
b 的值为2,右侧b 的值为3;或者左边的c 的值为3,右边的c 的值为undefined
在我们开始实现这个函数之前,我们将首先研究一个涉及深度嵌套对象的更复杂的场景
const x =
{ a: { b: { c: 1, d: 2, e: 3 } } }
const y =
{ a: { b: { c: 1, d: 3, f: 4 } } }
console.log (diff (x, y))
// { a: { b: { d: { left: 2, right: 3 }, e: { left: 3 }, f: { right: 4 } } } }
正如我们在上面看到的,diff 返回一个与我们的输入相匹配的结构。最后我们期望两个相同对象的diff 返回一个“空”结果
const x1 =
{ a: 1, b: { c: { d: 2 } } }
const x2 =
{ a: 1, b: { c: { d: 2 } } }
console.log (diff (x1, x2))
// {}
上面我们描述了一个diff 函数,它不关心给定的输入对象。 “左”对象可以包含“右”对象不包含的键,反之亦然,但我们仍然必须检测任一侧的变化。从高层次开始,这就是我们处理问题的方式
const diff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "left")
, diff1 (y, x, "right")
)
diff1
我们使用描述为“左”关系的diff1 获取“单边”差异,并使用描述为“右”关系的输入对象反向获取另一个单边差异,然后我们 merge两个结果一起
我们的工作被分配给我们现在更容易完成的任务。 diff1 只需要检测一半的必要更改,merge 只是简单地组合结果。我们将从diff1开始
const empty =
{}
const isObject = x =>
Object (x) === x
const diff1 = (left = {}, right = {}, rel = "left") =>
Object.entries (left)
.map
( ([ k, v ]) =>
isObject (v) && isObject (right[k])
? [ k, diff1 (v, right[k], rel) ]
: right[k] !== v
? [ k, { [rel]: v } ]
: [ k, empty ]
)
.reduce
( (acc, [ k, v ]) =>
v === empty
? acc
: { ...acc, [k]: v }
, empty
)
diff1 接受两个输入对象和一个关系描述符rel。此描述符默认为"left",这是比较的默认“方向”。下面,请注意diff1 只提供了我们需要的一半结果。在对diff1 的秒 调用中反转参数提供了另一半。
const x =
{ a: 1, b: 2, c: 3 }
const y =
{ a: 1, b: 3, d: 4 }
console.log (diff1 (x, y, "left"))
// { b: { left: 2 }, c: { left: 3 } }
console.log (diff1 (y, x, "right"))
// { b: { right: 3 }, d: { right: 4 } }
另外值得注意的是关系标签"left" 和"right" 是用户可定义的。例如,如果您在比较的对象之间有已知的关系,并且希望在 diff 输出中提供更多描述性标签...
const customDiff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "original")
, diff1 (y, x, "modified")
)
customDiff
( { host: "localhost", port: 80 }
, { host: "127.0.0.1", port: 80 }
)
// { host: { original: 'localhost', modified: '127.0.0.1' } }
在上面的示例中,在程序的其他区域中处理输出可能更容易,因为标签 original 和 modified 比 left 和 right 更具描述性。
合并
剩下的就是将两个半差异合并成一个完整的结果。我们的merge 函数也可以通用并接受任何两个对象作为输入。
const x =
{ a: 1, b: 1, c: 1 }
const y =
{ b: 2, d: 2 }
console.log (merge (x, y))
// { a: 1, b: 2, c: 1, d: 2 }
如果每个对象包含一个属性,其值也是一个对象,merge 将递归并合并嵌套对象。
const x =
{ a: { b: { c: 1, d: 1 } } }
const y =
{ a: { b: { c: 2, e: 2 } }, f: 2 }
console.log (merge (x, y))
// { a: { b: { c: 2, d: 1, e: 2 } }, f: 2 }
下面我们将我们的意图编码为merge
const merge = (left = {}, right = {}) =>
Object.entries (right)
.reduce
( (acc, [ k, v ]) =>
isObject (v) && isObject (left [k])
? { ...acc, [k]: merge (left [k], v) }
: { ...acc, [k]: v }
, left
)
这就是整个套件和一堆!展开下面的代码sn-p,在自己的浏览器中运行代码演示
const empty =
{}
const isObject = x =>
Object (x) === x
const diff1 = (left = {}, right = {}, rel = "left") =>
Object.entries (left)
.map
( ([ k, v ]) =>
isObject (v) && isObject (right[k])
? [ k, diff1 (v, right[k], rel) ]
: right[k] !== v
? [ k, { [rel]: v } ]
: [ k, empty ]
)
.reduce
( (acc, [ k, v ]) =>
v === empty
? acc
: { ...acc, [k]: v }
, empty
)
const merge = (left = {}, right = {}) =>
Object.entries (right)
.reduce
( (acc, [ k, v ]) =>
isObject (v) && isObject (left [k])
? { ...acc, [k]: merge (left [k], v) }
: { ...acc, [k]: v }
, left
)
const diff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "left")
, diff1 (y, x, "right")
)
const x =
{ a: { b: { c: 1, d: 2, e: 3 } } }
const y =
{ a: { b: { c: 1, d: 3, f: 4 } } }
console.log (diff (x, y))
// { a: { b: { d: { left: 2, right: 3 }, e: { left: 3 }, f: { right: 4 } } } }
console.log (diff (diff (x,y), diff (x,y)))
// {}
备注
当我们回顾 diff 函数时,我想强调其设计的一个重要部分。大部分工作由merge 函数处理,该函数与diff 完全分离,但tough nut to crack 本身是一个tough nut to crack。因为我们将关注点分离为单个函数,所以现在可以很容易地在程序的其他区域重用它们。我们想要diff,我们得到了它,并且我们免费获得了直观的深层merge 功能。
额外:支持数组
我们的diff 函数非常方便,因为它可以抓取深度嵌套的对象,但是如果我们的对象属性之一是数组呢?如果我们可以使用相同的技术来区分数组,那就太好了。
支持此功能需要对上述代码进行重大更改。但是,大部分结构和推理保持不变。例如,diff 完全不变
// unchanged
const diff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "left")
, diff1 (y, x, "right")
)
为了支持merge 中的数组,我们引入了一个突变助手mut,它将[ key, value ] 对分配给给定对象o。数组也被视为对象,因此我们可以使用相同的mut 函数更新数组和对象
const mut = (o, [ k, v ]) =>
(o [k] = v, o)
const merge = (left = {}, right = {}) =>
Object.entries (right)
.map
( ([ k, v ]) =>
isObject (v) && isObject (left [k])
? [ k, merge (left [k], v) ]
: [ k, v ]
)
.reduce (mut, left)
浅合并按预期工作
const x =
[ 1, 2, 3, 4, 5 ]
const y =
[ , , , , , 6 ]
const z =
[ 0, 0, 0 ]
console.log (merge (x, y))
// [ 1, 2, 3, 4, 5, 6 ]
console.log (merge (y, z))
// [ 0, 0, 0, <2 empty items>, 6 ]
console.log (merge (x, z))
// [ 0, 0, 0, 4, 5, 6 ]
还有深度合并
const x =
{ a: [ { b: 1 }, { c: 1 } ] }
const y =
{ a: [ { d: 2 }, { c: 2 }, { e: 2 } ] }
console.log (merge (x, y))
// { a: [ { b: 1, d: 2 }, { c: 2 }, { e: 2 } ] }
在diff1 中支持数组更具挑战性
const diff1 = (left = {}, right = {}, rel = "left") =>
Object.entries (left)
.map
( ([ k, v ]) =>
isObject (v) && isObject (right[k])
? [ k, diff1 (v, right[k], rel) ]
: right[k] !== v
? [ k, { [rel]: v } ]
: [ k, {} ]
)
.filter
( ([ k, v ]) =>
Object.keys (v) .length !== 0
)
.reduce
( mut
, isArray (left) && isArray (right) ? [] : {}
)
但是有了这些变化,我们现在可以深入比较包含数组的对象——甚至包含对象的数组!
const x =
{ a: 1, b: [ { c: 1 }, { d: 1 }, { e: 1 } ] }
const y =
{ a: 1, b: [ { c: 2 }, { d: 1 }, 5, 6 ], z: 2 }
console.log (diff (x, y))
// { b:
// [ { c: { left: 1, right: 2 } }
// , <1 empty item>
// , { left: { e: 1 }, right: 5 }
// , { right: 6 }
// ]
// , z: { right: 2 }
// }
因为diff1 会根据其输入类型仔细改变其行为,我们可以免费获得数组差异
const x =
[ 1, 2, 3, 4 ]
const y =
[ 1, 2, 9 ]
const z =
[ 1, 2, 9 ]
console.log (diff (x, y))
// [ <2 empty items>, { left: 3, right: 9 }, { left: 4 } ]
console.log (diff (y, z))
// []
在下面的浏览器中运行完整的程序
const isObject = x =>
Object (x) === x
const isArray =
Array.isArray
const mut = (o, [ k, v ]) =>
(o [k] = v, o)
const diff1 = (left = {}, right = {}, rel = "left") =>
Object.entries (left)
.map
( ([ k, v ]) =>
isObject (v) && isObject (right[k])
? [ k, diff1 (v, right[k], rel) ]
: right[k] !== v
? [ k, { [rel]: v } ]
: [ k, {} ]
)
.filter
( ([ k, v ]) =>
Object.keys (v) .length !== 0
)
.reduce
( mut
, isArray (left) && isArray (right) ? [] : {}
)
const merge = (left = {}, right = {}) =>
Object.entries (right)
.map
( ([ k, v ]) =>
isObject (v) && isObject (left [k])
? [ k, merge (left [k], v) ]
: [ k, v ]
)
.reduce (mut, left)
const diff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "left")
, diff1 (y, x, "right")
)
const x =
{ a: 1, b: [ { c: 1 }, { d: 1 }, { e: 1 } ] }
const y =
{ a: 1, b: [ { c: 2 }, { d: 1 }, 5, 6 ], z: 2 }
console.log (diff (x, y))
// { b:
// [ { c: { left: 1, right: 2 } }
// , <1 empty item>
// , { left: { e: 1 }, right: 5 }
// , { right: 6 }
// ]
// , z: { right: 2 }
// }
浅差异
这个答案的previous version 提供了一个对象diff 函数,用于比较具有相同键的对象和具有不同键的对象,但是这两种解决方案都没有递归地对嵌套对象执行差异。
递归交集
在this related Q&A 中,我们采用两个输入对象并计算递归intersect 而不是diff。