了解问题
问题中的示例对象使用了无效的符号,并且还分配了一个全局变量 anotherArr 作为副作用,这可能是无意的。 (更新:问题中的示例对象在我发布此答案后已更改。下面的对象仍然类似于原始示例对象。)在此答案中,我将采用以下表示法:
const prevObj = {
arr: [{
name: 'Ankur',
}, {
email: 'ankur4736@gmail.com',
}, {
key: 456,
}, [{
name: 'Shubham',
anotherNestedArr: [{
name: 'Gundeep Paji'
}],
}]],
};
const newObj = {
arr: [{
name: 'Shubham',
}, {
email: 'shubham@gmail.com',
}, {
key: 466,
}, [{
name: 'Gundeep Paji',
anotherNestedArr: [{
name: 'Ankur'
}],
}]],
};
如果我正确理解了这个问题,您不仅要在对象之间进行深入比较,还要创建一个包含差异描述的新对象。您声明您当前的代码适用于对象但不适用于数组。从您的代码中,我收集到您希望差异看起来像这样:
const diff = {
arr: [{
name: {
oldValue: 'Ankur',
newValue: 'Shubham',
},
}, {
email: {
oldValue: 'ankur4736@gmail.com',
newValue: 'shubham@gmail.com',
},
}, {
key: {
oldValue: 456,
newValue: 466,
},
}, [{
name: {
oldValue: 'Shubham',
newValue: 'Gundeep Paji',
},
anotherNestedArr: [{
name: {
oldValue: 'Gundeep Paji',
newValue: 'Ankur',
},
}],
}]],
};
稀疏数组与密集数组
在我们开始之前,我应该指出,有两种不同的方法可以创建一个新数组,其中只包含旧数组中在新数组中已更改的值。为简单起见,我将使用两个数字数组作为示例:
const oldArr = [1, 2, 3, 4, 5, 6, 7];
const newArr = [1, 1, 3, 4, 7, 6, 7];
// differences: ^ ^
为了便于说明,暂时假设我们只将旧值存储在 diff 中,而不是 {oldValue, newValue} 对象。无论哪种方式,我们的 diff 数组一开始都是空的,但如果我们选择 dense 表示 diff 的方式,我们只需将 oldArr 中在 newArr 中更改的每个元素附加到末尾:
const denseDiff = [2, 5];
这种表示的优点是denseDiff.length 会告诉我们一个直截了当的事实,即两个元素发生了变化。缺点是我们无法知道更改元素的原始索引是什么。
sparse方式比较复杂:
const sparseDiff = [, 2, , , 5];
sparseDiff.length,即5,不再告诉我们改变元素的数量。相反,它只传达已更改元素的最高索引。另外,如果我们在0和sparseDiff.length之间取一个没有变化的索引,例如sparseDiff[3],它总是告诉我们undefined,其含义并不明确。
这里有很多混淆的空间,这在编程中通常不是一件好事。但是,稀疏方式的主要优点是更改元素的索引在oldArr 和sparseDiff 之间始终相等。也就是说,我们为每个索引i 维护以下不变量:
oldArr[i] == newArr[i] ? sparseDiff[i] == undefined : sparseDiff[i] == oldArr[i]
幸运的是,我们还有一种方法可以直接找到哪些索引对应于更改的元素。 _.keys(或Object.keys)将告诉我们仅显式设置的元素的索引(因为它们已更改):
_.keys(sparseDiff) // ['1', '4']
_.keys(sparseDiff).length 甚至会告诉我们改变的元素数量。我们可以通过密集表示获得所有我们可以获得的信息,只是以一种更加迂回和混乱的方式。
最后,稀疏数组可以说是这里的正确方法,因为它们允许我们获取我们可能需要的所有信息。无论如何,我指出了稀疏数组的缺点,以强调仍然存在权衡。
您的代码
在跳到解决方案之前,让我们借此机会尽可能多地学习。您的代码中有几处令我印象深刻。
双外层函数
这是我注意到的第一件事:
function difference(oldValues, newValues) {
function changes(oldValues, newValues) {
//...
}
return changes(oldValues, newValues);
}
difference 函数只是将其参数以相同的顺序转发给changes,然后回显其返回值。这意味着difference 并没有真正做任何事情。您可以删除最外层的difference 函数,调用changes 而不是difference,仍然得到相同的结果:
function changes(oldValues, newValues) {
//...
}
_.transform
我注意到的第二件事是您使用的是_.transform。如果我们看一下这个函数是如何构思的,它原来是_.reduce 的一个变体,它可以通过从迭代对象返回false 来提前停止迭代。由于迭代器必须能够返回false,但我们也必须能够在最后获得结果,这意味着迭代器必须就地修改累加器,而不是像我们在_.reduce 中那样返回它。这有两个主要问题。
首先,就地修改不适用于字符串等不可变值。因此,_transform 仅适用于对象和数组。考虑以下函数来反转字符串,它可以与_.reduce 一起使用,但不能与_.transform 一起使用:
function reverse(string) {
return _.reduce(string, (result, char) => char + result);
}
您可以通过将result 包装在一个对象中然后解开它来解决此问题,但这意味着您正在解决_.transform 的限制,而您应该使用@首先是987654367@。
其次,更重要的是,对对象进行部分转换是没有意义的。对象的键是无序的。假设在迭代期间,您在特定键上找到停止条件。纯属巧合,其他一些键已经被访问过,而其他键还没有。没有正当理由说明已经访问过的键应该是转换结果的一部分,而其他键不应该是,因为这种划分是完全任意的。
_.transform 对于不可变的值是无用的,对于无序的集合是无意义的。有序集合、数组怎么样?乍一看,在一些合理的场景中,您可能希望对数组进行部分转换。例如,要计算高于阈值的最小数字,该阈值可以通过从一开始就对数组中的数字求和来获得:
function thresholdPartialSum(numbers, threshold) {
return _.transform(numbers, function(result, number) {
result.wrapped += number;
return result.wrapped <= threshold;
}, { wrapped: 0 }).wrapped;
}
注意wrapped 出现在四个地方。我们正在解决_.transform 的限制,即必须在此处修改累加器。
诚然,_.transform 在这种情况下比_.reduce 具有性能优势。然而,我们都不需要。旧的_.find 和类似的函数已经很好地处理了部分迭代数组。此外,由于无论如何我们都必须修改累加器,我们不妨关闭它。 _.find 将与 _.transform 一样处理此类场景,但复杂性更低:
function thresholdPartialSum(numbers, threshold) {
let partialSum = 0;
find(numbers, function(number) {
partialSum += number;
return partialSum > threshold;
});
return partialSum;
}
总之,你应该永远使用_.transform。请改用_.reduce 或_.find。
重复递归
这是我注意到的第三件事:
if (!_.isEqual(value, oldValues[key])) {
// recursion here
}
虽然这不会导致错误的结果,但_.isEqual 本身就是递归的。它的返回值基于是否发现任何差异。这意味着当发现差异时,您最终会再次递归迭代这两个值,只是为了找回 _.isEqual 已经识别但没有告诉您位置的差异。
乍一看,这似乎没有那么大的问题。您可能会递归两次而不是一次。不是最有效的,但最多慢两倍,对吧?不幸的是,重复并不止于此。一旦您递归调用自己的changes 函数,_.isEqual 将再次为您递归进入的元素的每个子元素调用,并且它将再次递归。
为了量化这一点,让我们首先将嵌套对象可视化为一棵树。
const tree = {
mainBranch1: {
branch1_1: {
twig1_1_1: {
leaf1_1_1_1: 'value 1.1.1.1',
leaf1_1_1_2: 'value 1.1.1.2',
},
twig1_1_2: {
leaf1_1_2_1: 'value 1.1.2.1',
leaf1_1_2_2: 'value 1.1.2.2',
},
},
branch1_2: {
twig1_2_1: {
leaf1_2_1_1: 'value 1.2.1.1',
leaf1_2_1_2: 'value 1.2.1.2',
},
twig1_2_2: {
leaf1_2_2_1: 'value 1.2.2.1',
leaf1_2_2_2: 'value 1.2.2.2',
},
},
},
mainBranch2: {
branch2_1: {
twig2_1_1: {
leaf2_1_1_1: 'value 2.1.1.1',
leaf2_1_1_2: 'value 2.1.1.2',
},
twig2_1_2: {
leaf2_1_2_1: 'value 2.1.2.1',
leaf2_1_2_2: 'value 2.1.2.2',
},
},
branch2_2: {
twig2_2_1: {
leaf2_2_1_1: 'value 2.2.1.1',
leaf2_2_1_2: 'value 2.2.1.2',
},
twig2_2_2: {
leaf2_2_2_1: 'value 2.2.2.1',
leaf2_2_2_2: 'value 2.2.2.2',
},
},
},
};
我们有一棵具有四个分支级别的树(不包括tree 本身)。有十六片叶子。
递归将在leaf 键处停止,所以让我们计算_.isEqual 将访问叶键的次数。假设我们将tree 与另一个具有相似结构的对象进行比较,并且大约一半的叶子值发生了变化。
首先,我们在tree 和另一个对象上调用changes。我们为每个mainBranch 调用一次_.isEqual。 _.isEqual 递归调用自身,直到它到达叶键。它需要访问大约一半的叶子才能找到差异,此时它知道它必须返回false。至此,我们平均访问了每片叶子半次。
每次_.isEqual 返回false,我们在对应的主分支上递归调用changes。对changes 的每次调用然后对其中的每个branch 调用一次_.isEqual。 _.isEqual 再次递归调用自身,直到它到达叶键。现在我们平均访问了每片叶子一次。
_.isEqual 每次都再次返回false,所以我们再次递归到changes。整个模式重复,我们每片叶子访问一次半。
我们最终使用twigs 再次重复整个模式一次,最终平均每个叶子键调用两次_.isEqual。这意味着 16 * 2 = 32 在叶键上总共调用 _.isEqual。每个叶子的调用总数等于树的深度乘以叶子的数量,再除以 2。在复杂度分析中,我们称之为对数线性或准线性time。
这是最坏的情况。少于一半的叶子不同,changes 将减少递归,而超过一半的叶子不同,_.isEqual 将更快地停止自己的递归。然而,如果没有重复递归,我们将只需要访问每个叶子一次,而不管树的深度如何。毕竟,当我们判断某个键是否发生了变化时,还不如直接记录差异。对于大型、深度嵌套的结构,这可能会有很大的不同,尤其是在热循环中发生的情况下。
删除重复需要我们将算法从前序遍历更改为后序traversal。也就是说,不是首先确定一个较大的分支是否有任何差异,然后在每个较小的分支中找到这些差异,我们必须直接收集最小分支中的所有差异。如果我们在较小的分支中发现任何差异,那么我们就知道我们也应该记录包含较大的分支。
解决方案
足够的理论。以下代码解决了我上面讨论的问题,适用于对象和数组。无论您使用的是 Underscore 还是 Lodash,它都能正常工作。
function changes(oldCollection, newCollection) {
return _.reduce(oldCollection, function(result, oldValue, key) {
const newValue = newCollection[key];
if (_.isObject(oldValue) && _.isObject(newValue)) {
const diff = changes(oldValue, newValue);
if (!_.isEmpty(diff)) result[key] = diff;
} else if (oldValue !== newValue) {
result[key] = { oldValue, newValue };
}
return result;
}, _.isArray(oldCollection) ? [] : {});
}
最后的评论
与原始问题一样,changes 返回的 diff 对象包含每个更改的叶键的嵌套 {oldValue, newValue} 对象。如果旧对象或新对象包含名称为 oldValue 或 newValue 的键,这可能会导致歧义。这并不像看起来那么疯狂。考虑比较两个不同时间点的差异。
为避免这种情况,您可以选择在 diff 中仅存储旧值(或仅存储新值)。由于您将知道更改的值的确切位置,因此在另一个对象中查找相应的键将是微不足道的。您只需更改一行即可使算法以这种方式运行,但这留给读者作为练习。
与原始问题一样,上述解决方案没有考虑到新集合可能具有旧集合中不存在的新键。如果您想检测新密钥并且您可以接受删除的密钥将被遗漏,只需迭代 newCollection 而不是 oldCollection。如果要捡起两边缺失的键,先用symmetric difference确定常用键和不常用键。像上面一样对待常用键,而直接记录不常用键作为差异。这再次留给读者作为练习。
更新:检测删除和添加的元素
在上面的“最后评论”中,我评论说我上面的解决方案,就像在原始问题中一样,没有检测到对象和数组中新添加的元素。 Ankur Sharma 评论说,这实际上是他在问题中寻求解决方案的问题之一。为了解决这个问题,下面是我的解决方案的新版本。
下面的代码假定下划线。对于 Lodash 4,请使用 _.mapValues 而不是 _.mapObject。
// Helper for the call to _.mapObject/_.mapValues below.
const wrapAddedKey = newValue => ({ oldValue: undefined, newValue });
function changes(oldCollection, newCollection) {
const diff = _.reduce(oldCollection, function(result, oldValue, key) {
const newValue = newCollection[key];
if (_.isObject(oldValue) && _.isObject(newValue)) {
const diff = changes(oldValue, newValue);
if (!_.isEmpty(diff)) result[key] = diff;
} else if (oldValue !== newValue) {
result[key] = { oldValue, newValue };
}
return result;
}, _.isArray(oldCollection) ? [] : {});
const addedKeys = _.difference(_.keys(newCollection), _.keys(oldCollection));
const additions = _.pick(newCollection, addedKeys);
return _.extend(diff, _.mapObject(additions, wrapAddedKey));
}
更新 2
从问题的另一个更新中,很明显提问者希望将缺失值表示为 null 而不是 undefined。下面的代码解决了这个问题。
// Helper for the call to _.mapObject/_.mapValues below.
const wrapAddedKey = newValue => ({ oldValue: null, newValue });
function changes(oldCollection, newCollection) {
const diff = _.reduce(oldCollection, function(result, oldValue, key) {
const newValue = key in newCollection ? newCollection[key] : null;
if (_.isObject(oldValue) && _.isObject(newValue)) {
const diff = changes(oldValue, newValue);
if (!_.isEmpty(diff)) result[key] = diff;
} else if (oldValue !== newValue) {
result[key] = { oldValue, newValue };
}
return result;
}, _.isArray(oldCollection) ? [] : {});
const addedKeys = _.difference(_.keys(newCollection), _.keys(oldCollection));
const additions = _.pick(newCollection, addedKeys);
return _.extend(diff, _.mapObject(additions, wrapAddedKey));
}