【问题标题】:Find deep difference between nested array of object with lodash or underscore or vanilla js使用 lodash 或下划线或 vanilla js 查找嵌套对象数组之间的深层差异
【发布时间】:2022-01-02 00:36:46
【问题描述】:

我要比较的深层嵌套对象

const prevObj = {
    "ui_data": [
        {
            "children": [
                {
                    "editable": true,
                    "id": "22615650-510a-11ec-8393-cfd004935be3",
                    "mandatory": false,
                    "modifiable": true,
                    "options": [],
                    "t_id": "text",
                    "title": "Text 1",
                    "type": "FormFieldType.textField"
                },
                {
                    "editable": true,
                    "id": "2a6f9000-510a-11ec-8393-cfd004935be3",
                    "mandatory": false,
                    "modifiable": true,
                    "options": [],
                    "t_id": "cb",
                    "title": "Check Box 2",
                    "type": "FormFieldType.checkBox"
                }
            ],
            "id": "fe8223e0-5109-11ec-8393-cfd004935be3",
            "title": "Page-1",
            "type": "FormContainerType.page"
        }
    ]
}

const newObj = {
    "ui_data": [
        {
            "children": [
                {
                    "editable": true,
                    "id": "22615650-510a-11ec-8393-cfd004935be3",
                    "mandatory": false,
                    "modifiable": true,
                    "options": [],
                    "t_id": "text",
                    "title": "Text 1",
                    "type": "FormFieldType.textField"
                },
                {
                    "editable": true,
                    "id": "2a6f9000-510a-11ec-8393-cfd004935be3",
                    "mandatory": false,
                    "modifiable": true,
                    "options": [],
                    "t_id": "cb",
                    "title": "Check Box 2",
                    "type": "FormFieldType.checkBox"
                }
            ],
            "id": "fe8223e0-5109-11ec-8393-cfd004935be3",
            "title": "Page-1",
            "type": "FormContainerType.page"
        }
    ]
}

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 = _.compact(changes(oldValue, newValue));
            if (!_.isEmpty(diff)) {
                result[key] = diff;
            };
        } else if (oldValue !== newValue) {
            result[key] = { oldValue: oldValue, newValue: newValue };
        }
        return result;
    }, _.isArray(oldCollection) ? [] : {});
    const addedKeys = _.difference(_.keys(newCollection), _.keys(oldCollection));
    const additions = _.pick(newCollection, _.compact(addedKeys));
    return _.extend(diff, __.mapObject(additions, wrapAddedKey));
}

我正在创建一个算法来比较深度嵌套的对象,我正在使用 lodash 来比较对象的变化。我有两个深度嵌套的对象,其中也包含数组。我想比较一下它们内部是否有任何更新、创建或删除。上面的代码适用于一级深度数组,但如果有一个对象数组位于对象数组内,则此代码不起作用。我已经分享了上面的对象示例。我期望的完整输出,例如如果创建了新内容,那么它应该返回以下内容。

{
oldValues : null,
newValues : {
    // Values of newly created items
}
}

如果某些内容被删除,那么它应该返回

{
    oldValues  : {
         // Older Values of deleted item
    },
    newValues : null
}

如果有东西更新了,那么它应该返回

{
     oldValues  : {
        // Older Values of item
    },
    newValues : {
        // Newer Values of item
    }
}

【问题讨论】:

  • 比较对象之间的差异可能很棘手,你看过npmjs.com/package/just-diff
  • 使用_.isEqual()函数lodash.com/docs/4.17.15#isEqual
  • JSON Diff,有很多脚本
  • 您最新的prevObjnewObj 是相同的。这就是你得到一个空对象的原因。如果你让它们不同,你会发现我的答案中的原始代码已经可以工作到任意深度,因为它是递归的。我的答案中唯一似乎缺少的是它使用undefined 而不是null 来指示缺失值。我会解决的。
  • 顺便说一句,很高兴承认您从我的答案中复制并编辑了代码。更好的是还包括您的原始代码,因为我的答案是响应该代码。您的编辑使代码变得更糟;这两个对_.compact 的调用不应该存在(它们会导致信息丢失),并且您在倒数第二行的_.mapObject 中复制了_

标签: javascript arrays lodash underscore.js


【解决方案1】:

了解问题

问题中的示例对象使用了无效的符号,并且还分配了一个全局变量 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,不再告诉我们改变元素的数量。相反,它只传达已更改元素的最高索引。另外,如果我们在0sparseDiff.length之间取一个没有变化的索引,例如sparseDiff[3],它总是告诉我们undefined,其含义并不明确。

这里有很多混淆的空间,这在编程中通常不是一件好事。但是,稀疏方式的主要优点是更改元素的索引在oldArrsparseDiff 之间始终相等。也就是说,我们为每个索引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} 对象。如果旧对象或新对象包含名称为 oldValuenewValue 的键,这可能会导致歧义。这并不像看起来那么疯狂。考虑比较两个不同时间点的差异。

为避免这种情况,您可以选择在 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));
}

【讨论】:

  • 如果数组中添加了任何新对象,此解决方案将不起作用。我正在为我的数据创建审计跟踪。如果数据更新,我会比较旧和更新的对象,以查看是否创建了任何东西。请帮我解决这个问题。我在敲我的头。
  • 好的,今天晚些时候我会更新答案。
  • 我已经更新了问题中的对象,我想检测对象中是否有任何新创建、更新或删除的内容并返回旧值和新值
  • @AnkurSharma 我在答案的底部添加了一个新部分,用于解决新添加的元素。
  • 你真是个救命稻草。上帝保佑你!
猜你喜欢
  • 2015-05-13
  • 2023-03-28
  • 2021-01-06
  • 1970-01-01
  • 1970-01-01
  • 2015-10-05
  • 1970-01-01
  • 2015-08-03
  • 1970-01-01
相关资源
最近更新 更多