剖析 microdiff 实现原理

1,810 阅读3分钟

一、前言

  microdiff 是一款对比两个对象(object or array)属性的库,相比较其它同类型的库,有以下特点:

  • 包体积小于 1 kb
  • 执行效率高
  • 零依赖
  • 支持 TypeScript

  基本的使用方式如下:

import diff from "microdiff";

const obj1 = {
 originalPropertytrue,
};
const obj2 = {
 originalPropertytrue,
 newProperty"new",
};

console.log(diff(obj1, obj2));
// [{type: "CREATE", path: ["newProperty"], value: "new"}]

二、遍历对象属性

  既然要对比两个对象的属性,那么必然需要先遍历对象的属性,JavaScript 提供了应用于各种场景的遍历方法:

  • for...in
  • Object.keys()
  • Object.getOwnPropertyNames()
  • Object.getOwnPropertySymbols()
  • Reflect.ownKeys()

  要想完全掌握上述方法,可以从对象属性的两个维度着手,第一个维度就是属性类型:

const obj = {
    foo'foo',
    bar20,
}

Object.prototype.bzz = 'bzz';

console.log(obj.bzz); // bzz

obj[Symbol('far')] = 'far'// [Symbol(far)]: 'far'

  由于 JavScript 的原型链机制以及 ES6 新增的 Symbol 基本数据类型,使得属性按照类型可以分为:

  • 自身属性
  • 继承属性
  • Symbol 属性

  第二个维度就是属性的描述符:属性除了自身的 value 之外,还有三个特殊的特性,统称为属性描述符。


Object.getOwnPropertyDescriptors(obj)

{
  foo: {
    value'foo',
    writabletrue,
    enumerabletrue,
    configurabletrue
  },
  bar: { value20writabletrueenumerabletrueconfigurabletrue },
  [Symbol(far)]: {
    value'far',
    writabletrue,
    enumerabletrue,
    configurabletrue
  }
}

  通过 Object.getOwnPropertyDescriptors 方法可以获取到对象所有属性的所有描述符,根据 enumerable 属性描述符可以把属性划分为:

  • 不可枚举属性
  • 可枚举属性

  从上图中可以看出只有 for...in 是会遍历出原型链属性的,所以早期的 JavaScript 代码中,为了避免这种情况的出现,需要这样处理:

for (let i in obj) {
    if (obj.hasOwnProperty(i)) {
        // 过滤原型链上的属性
        console.log(i);
    }
}

三、实现原理

  两个对象属性的差异化情况分为三种:

interface Difference {
 type"CREATE" | "REMOVE" | "CHANGE";
 path: (string | number)[];
 value?: any;
}

  microdiff 采用 for...in 来遍历对象属性,所以它不适用于不可枚举属性和 Symbol 属性的场景。

  属性的新增和删除是比较容易判断的:

  • 当属性仅存于 obj,则是属性的删除
  • 当属性仅存于 newObj,则是属性的新增
// 部分代码省略
// 属性删除
for (const key in obj) {
    const objKey = obj[key];
    const path = isObjArray ? +key : key;
    if (!(key in newObj)) {
        diffs.push({
            type"REMOVE",
            path: [path],
        });
        continue;
    }
}

...

// 属性新增
const isNewObjArray = Array.isArray(newObj);
for (const key in newObj) {
    if (!(key in obj)) {
        diffs.push({
            type"CREATE",
            path: [isNewObjArray ? +key : key],
            value: newObj[key],
        });
    }
}

  属性更新的判断会稍微复杂点,对于子属性值为对象的情况下,会采用递归的方式再次 diff 处理,同时也考虑了一些特殊的数据类型和循环引用的问题:

const newObjKey = newObj[key];
const areObjects =
    typeof objKey === "object" && typeof newObjKey === "object";
if (
    objKey &&
    newObjKey &&
    areObjects &&
    // 特殊类型的过滤
    !richTypes[Object.getPrototypeOf(objKey).constructor.name] &&
    // 循环引用的处理
    (options.cyclesFix ? !_stack.includes(objKey) : true)
) {
    // 递归处理
    const nestedDiffs = diff(
        objKey,
        newObjKey,
        options,
        options.cyclesFix ? _stack.concat([objKey]) : []
    );
    diffs.push.apply(
        diffs,
        nestedDiffs.map((difference) => {
            difference.path.unshift(path);
            return difference;
        })
    );
} else if (
    objKey !== newObjKey &&
    !(
        areObjects &&
        (isNaN(objKey)
            // 考虑到 Number 和 String 包装类型
            ? objKey + "" === newObjKey + ""
            : +objKey === +newObjKey)
    )
) {
    diffs.push({
        path: [path],
        type: "CHANGE",
        value: newObjKey,
    });
}

  如果忽略成本问题,「可以采用 WeakSet 才检查循环引用,这样性能会更优」

四、总结

  本文重点内容如下:

  • 理解不同属性遍历方法的差异性,根据不同应用场景,选择合适的方法
  • 递归遍历对象属性时,注意检查循环引用

  最后,「如果本文对您有帮助,欢迎点赞、收藏、分享」