【前端面试】JavaScript:手写对象深拷贝

99 阅读6分钟

博客:pionpill

判断类型

深拷贝对象需要将对象的所有属性都拷贝一份,先简单说一下 JS 的类型。

简单类型

JS 分为简单类型和复杂类型,简单类型。假设有变量 x 那么其常见判断方式为:

  • undefined: x === undefined
  • null: x === null
  • boolean: typeof x === 'boolean'
  • number: typeof x === 'number'
  • string: typeof x === 'string'
  • symbol: typeof x === 'symbol'
  • bigInt: typeof x === 'bigint'

undefinednull 可以直接用强等于判断,因为他们的类型对应值只有一个,且存在于全局变量中。

无法用 typeof null === 'null' 来判断 null,因为历史原因,会返回 nullobject 类型,不考虑函数的情况下判断简单类型可以这样写:

typeof target !== 'obj' || target === null // 判断简单类型
typeof target === 'obj' && target !== null // 判断对象

复杂类型

复杂类型就是 Object 和继承自 Object 的对象。他们都存在于堆内存中,ES6 之前的几种对象判断方式为:

  • Object: typeof x === 'object' && x !== null
  • Function: typeof x === 'function'
  • Array: Array.isArray(x)

在 ES6 之后,又添加了很多内置容器对象,例如 Map, Set 如果使用 typeof 进行判断会返回 Object, 此时可以通过 instanceOf 检查原型链来判断:

  • Map: x instanceOf Map
  • WeakMap: x instanceOf WeakMap
  • Set: x instanceOf Set
  • WeakSet: x instanceOf WeakSet

对于自定义的类型,也可以通过这个方式判断。但这个操作符本质上是去对象原型链上找是否存在构造函数的原型,如果需要准且判断是否为某个构造函数的实例,可以这样(例如 Array):

target.__proto__ === Array.prototype

此外还有一种万能解决方案:

Object.prototype.toString.apply(x)

该方法会返回变量的准确类型。

浅拷贝

浅拷贝非常简单,这里归纳几种常见方法:

  • 对象(obj):
    • 解构语法: const newObj = { ...obj }
    • Object.assign(newObj, obj): 将 obj 属性浅拷贝到一个空对象 newObj
  • 数组(Array):
    • Array.prototype.slice(): 不传参数,返回一个浅拷贝数组对象
    • Array.of(): 创建一个新的数组对象

这些方法都有一些小缺陷:无法拷贝不和枚举属性。如果需要将所有属性找出来,可以参考以下几个 API:

  • Object.getOwnPropertyNames: 获取非 Symbol 的所有属性
  • Object.getOwnPropertySymbols:获取所有 Symbol 属性
  • Object.getOwnPropertyDescriptors():获取所有属性的属性描述符
  • Reflect.ownKeys():获取所有属性(包括 Symbol

一个能够完整拷贝包括不可枚举与 Symbol 属性的方法如下:

const shallowClone = <T extends Object>(obj: T ) => {
  const allKeys = Reflect.ownKeys(obj);
  const newObj = {};
  allKeys.forEach(key => {
    (newObj as any)[key] = Reflect.get(obj, key);
  })
  return newObj;
}

深拷贝

如果深拷贝对象的所有属性都是可序列化的(可以转换为 json 文件中对应的格式),那么可以用这个简易方案:

const jsonDeepClone = <T extends Object>(obj: T): T => {
  return JSON.parse(JSON.stringify(obj))
}

缺点是包括Function, BigInt, Symbol在内的不可序列化类型无法被深拷贝。

递归对象属性

下面开始一步步手写深拷贝,首先考虑两个问题:

  • 如何区分简单类型与引用类型
  • 如何获取对象的所有直接属性
  • 如何深拷贝对象的属性

第一点,封装一个函数:

const isObject = (target: any) => ["object", "function"].includes(typeof target) && target !== null;

第二点,上文说过了,用 Reflect.ownKeys() 获取包括 Symbol, 不可枚举属性在内的所有属性。网上常见的方式是使用 for...in 遍历对象属性,但这有几个缺点:

  • for...in 会获取原型链上的可枚举属性,需要使用 Object.hasOwnProperty() 在判断一遍。
  • for...in 无法获取Symbol与不可枚举属性。

本文只是处于严谨考虑将 Symbol 与不可枚举属性也深拷贝进来,具体是否要这样做请以业务场景为准。如果要更针对性地拷贝属性,可以使用 Object.getOwnPropertyDescriptors() 获取属性描述再做判断

第三点,用递归。但有一个特殊情形,函数如何深拷贝。最简单的方式是用 eval:

const cloneFunc = <T extends Function>(func: T): T => {
  return eval(func.toString())
}

eval 有安全性问题,而且不是所有的类型都可以简单地 toString 再使用 eval 执行。

一般情况下函数不需要深拷贝,但为了 this 指向正确,可以使用 Function.prototype.bind 调整指向,否则 this 还可能指向被拷贝的对象环境(箭头函数万岁)。

整理前两个问题,写一个基础版:

// unknown 在判断后编译器会自动推导类型
const deepClone = (obj: unknown) => {
    // 基本类型和函数
    if (typeof obj !== "object" || obj === null) {
        return obj;
    }
    // 两类每必要返回的对象
    if (obj instanceof Date || obj instanceof RegExp) {
        return obj;
    }
    // 数组处理
    if (obj instanceof Array) {
        const result: any[] = [];
        obj.forEach((item, index) => {
            result[index] = deepClone(item); // 下面解释为什么这样做
        });
        return result;
    }
    // Map 处理
    if (obj instanceof Map) {
        const result = new Map<any, any>();
        Array.from(obj.keys()).forEach((key) =>
            result.set(key, deepClone(obj.get(key)))
        );
        return result;
    }
    // Set 处理
    if (obj instanceof Set) {
        const result = new Set<any>();
        Array.from(obj.keys()).forEach(
            (item) => result.add(deepClone(item)),
            obj
        );
        return result;
    }
    // 常规 Object 处理
    const result = {};
    // 原型加上去
    Object.setPrototypeOf(result, Object.getPrototypeOf(obj));
    Reflect.ownKeys(obj).forEach((key) => {
        Reflect.set(
            result,
            key,
            deepClone(Reflect.get(obj, key))
        );
    });
    return result;
};

有几个细节注意下,在处理数组时有这样一段:

arrayObj.forEach((item, index) => {
    result[index] = deepClone(item, obj);
});

为什么不直接 result.append(deepClone(item)) 呢?JS 的数组有一个极其特殊的情形:空元素,不是指元素值为假值,而是没有元素值。如果直接 append,那么空元素就不会被包括在内。使用 forEach 会跳过空元素处理逻辑(没找到好的空元素判断方法),只需要通过下标赋值,那么没有赋值的元素就是空元素。

这里全部使用 instanceof 操作符判断,但我只考虑了常见情形,如果项目根据需要做了封装,那么需要特殊处理。

循环引用问题

为了避免循环引用问题,可以使用一个 map 缓存属性:

const deepClone = (obj: unknown, map = new WeakMap()) => {
    if (typeof obj !== "object" || obj === null) {
        return obj;
    }
    if (obj instanceof Date || obj instanceof RegExp) {
        return obj;
    }
    if (map.has(obj as Object)) {
        return map.get(obj as Object);
    }
    if (obj instanceof Array) {
        const result: any[] = [];
        map.set(obj, result);
        obj.forEach((item, index) => {
            result[index] = deepClone(item, map);
        });
        return result;
    }
    if (obj instanceof Map) {
        const result = new Map<any, any>();
        map.set(obj, result);
        Array.from(obj.keys()).forEach((key) =>
            result.set(key, deepClone(obj.get(key), map))
        );
        return result;
    }
    if (obj instanceof Set) {
        const result = new Set<any>();
        map.set(obj, result);
        Array.from(obj.keys()).forEach(
            (item) => result.add(deepClone(item, map)),
            obj
        );
        return result;
    }
    const result = {};
    map.set(obj, result);
    Object.setPrototypeOf(result, Object.getPrototypeOf(obj));
    Reflect.ownKeys(obj).forEach((key) => {
        Reflect.set(
            result,
            key,
            deepClone(Reflect.get(obj, key), map)
        );
    });
    return result;
};

函数 this 指向问题

上面代码中,我们的函数是直接浅拷贝过来,也即获得了原来函数的一个引用。那么这个函数的 this 还是指向原来的对象的属性,这就可能出问题,因此需要修改 this 指向:

const isObject = (target: any) => ["object", "function"].includes(typeof target) && target !== null;

const deepClone = (
    obj: unknown,
    map = new WeakMap(),
    self: Object = globalThis
) => {
    if (!isObject(obj)) {
        return obj;
    }
    if (obj instanceof Date || obj instanceof RegExp) {
        return obj;
    }
    if (map.has(obj as Object)) {
        return map.get(obj as Object);
    }
    if (obj instanceof Array) {
        const result: any[] = [];
        map.set(obj, result);
        obj.forEach((item, index) => {
            result[index] = deepClone(item, map, obj);
        });
        return result;
    }
    if (obj instanceof Map) {
        const result = new Map<any, any>();
        map.set(obj, result);
        Array.from(obj.keys()).forEach((key) =>
            result.set(key, deepClone(obj.get(key), map, obj))
        );
        return result;
    }
    if (obj instanceof Set) {
        const result = new Set<any>();
        map.set(obj, result);
        Array.from(obj.keys()).forEach(
            (item) => result.add(deepClone(item, map, obj)),
            obj
        );
        return result;
    }
    if (obj instanceof Function) {
        return obj.bind(self);
    }
    const defaultObj = obj as Object;
    const result = {};
    map.set(defaultObj, result);
    Object.setPrototypeOf(result, Object.getPrototypeOf(defaultObj));
    Reflect.ownKeys(defaultObj).forEach((key) => {
        Reflect.set(
            result,
            key,
            deepClone(Reflect.get(defaultObj, key), map, defaultObj)
        );
    });
    return result;
};

最后总结以下深拷贝的要点

  • 基本类型:直接返回
  • 引用类型,分类讨论:
    • DataRegExpFunction:可以直接返回
    • 其他引用类型:按照各自的深拷贝方法递归
  • 解决循环引用:使用 map 做一个属性缓存,如果存在直接返回
  • 函数指向性问题:获取将函数所在对象,并使用 bind 修正指向
  • 细节-对象:使用 Reflect.ownKeys 能获取Symbol,不可枚举属性在内的所有属性
  • 细节-对象:原型链保持前后一致
  • 细节-数组:考虑空元素情形

此外,WeakMapWeakSet 是没法直接深拷贝的,因为无法获取他们的 key 集。