对象类型及拷贝

525 阅读15分钟

堆和栈

栈(stack):js 中在变量定义时,栈就为其分配好了内存空间

  • 存储的值大小固定
  • 空间较小
  • 可以直接操作其保存的变量,运行效率高
  • 由系统自动分配存储空间
// 执行了str += '6'的操作时
// 1. 在栈中又开辟了一块内存空间用于存储'primitive value change'
// 2. 然后将变量 str 指向这块空间,所以这并不违背不可变性的特点
str = 'primitive value'
str += ' change'
console.log(str);  // primitive value change

堆(heap):js 中引用类型的值实际存储在堆内存中,它在栈中只存储了一个固定长度的地址,这个地址指向堆内存中的值

  • 存储的值大小不定,可动态调整
  • 空间较大,运行效率低
  • 无法直接操作其内部存储,使用引用地址读取
  • 通过代码进行分配空间

数据类型

原始数据类型

  1. 基本类型包含:string,number,boolean,null,undefined
  2. 基本类型的直接存储在栈(stack)中
  3. 基本类型的值是不可更改的,任何方法都无法更改(或“突变”)一个原始值
var str = "abc";
console.log(str[1]="f");    // f
console.log(str);           // abc
  1. 基本类型的比较是值的比较,只要它们的值相等就认为他们是相等的
var a = 1;
var b = 1;
console.log(a === b);//true
// 比较的时候最好使用严格等,因为 == 是会进行类型转换的,比如
var a = 1;
var b = true;
console.log(a == b);//true

引用类型

  1. 引用类型包含:String、Bool、Number、Object、Array、Date、Error、RegExp、Symbol、Function
  2. 引用类型的值(object)是存放在堆内存中的,变量实际上是一个存放在栈内存的指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体
  3. 引用类型是可以直接改变其值的
var a = [1,2,3];
a[1] = 5;
console.log(a[1]); // 5
  1. 引用类型的比较是引用的比较。虽然变量 a 和变量 b 都是表示一个内容为 1,2,3 的数组,但是其在内存中的位置不一样,也就是说变量 a 和变量 b 指向的不是同一个对象,所以他们是不相等的
var a = [1,2,3];
var b = [1,2,3];
console.log(a === b); // false

null

  • 表示被赋值过的对象,刻意把一个对象赋值为null,故意表示其为空,不应有值
  • 所以对象的某个属性值为null是正常的
  • null转换为数值时值为0

undefined

  • 表示“缺少值”,即此处应有一个值,但还没有定义
  • 如果一个对象的某个属性值为undefined,这是不正常的,如obj.name=undefined,我们不应该这样写,应该直接delete obj.name
  • undefined转为数值时为NaN(非数字值的特殊值)

Symbol 类型

Symbol 类型是 ES6 中新加入的一种原始类型。每个从 Symbol() 返回的 symbol 值都是唯一的

1. Symbol 的特性

  • 独一无二

即使用两个相同的字符串创建两个Symbol变量,它们是不相等的,可见每个Symbol变量都是独一无二的

如果我们想创造两个相等的Symbol变量,可以使用Symbol.for(key)。这个方式是使用给定的key搜索现有的symbol,如果找到则返回该symbol。否则将使用给定的key在全局symbol注册表中创建一个新的symbol

var sym1 = Symbol.for('ConardLi');
var sym2 = Symbol.for('ConardLi');
console.log(sym1 === sym2); // true
  • 原始类型

使用Symbol()函数创建symbol变量,并非使用构造函数,使用new操作符会直接报错

new Symbol(); // Uncaught TypeError: Symbol is not a constructor

可以使用typeof运算符判断一个Symbol类型

typeof Symbol() === 'symbol'
typeof Symbol('ConardLi') === 'symbol'
  • 不可枚举

当使用Symbol作为对象属性时,可以保证对象不会出现重名属性,调用 for...in 不能将其枚举出来,另外调用 Object.getOwnPropertyNamesObject.keys()也不能获取 Symbol 属性

可以调用 Object.getOwnPropertySymbols() 用于专门获取Symbol属性

var obj = {
  name:'ConardLi',
  [Symbol('name2')]:'code秘密花园'
}
Object.getOwnPropertyNames(obj); // ["name"]
Object.keys(obj); // ["name"]
for (var i in obj) {
   console.log(i); // name
}
Object.getOwnPropertySymbols(obj) // [Symbol(name)]

2. Symbol 的应用场景

  • 应用一:防止XSS

在React的 ReactElement 对象中,有一个 ?typeof 属性,它是一个 Symbol 类型的变量:

var REACT_ELEMENT_TYPE =
  (typeof Symbol === 'function' && Symbol.for && Symbol.for('react.element')) ||
  0xeac7;

ReactElement.isValidElement 函数用来判断一个 React 组件是否是有效的,下面是它的具体实现。

ReactElement.isValidElement = function (object) {
  return typeof object === 'object' && object !== null && object.$$typeof === REACT_ELEMENT_TYPE;
};

可见 React 渲染时会把没有 ?typeof 标识,以及规则校验不通过的组件过滤掉。如果你的服务器有一个漏洞,允许用户存储任意JSON对象, 而客户端代码需要一个字符串,这可能会成为一个问题:

// JSON
let expectedTextButGotJSON = {
  type: 'div',
  props: {
    dangerouslySetInnerHTML: {
      __html: '/* put your exploit here */'
    },
  },
};
let message = { text: expectedTextButGotJSON };
<p>
  {message.text}
</p>

而JSON中不能存储Symbol类型的变量,这就是防止XSS的一种手段。

  • 私有属性

借助 Symbol 类型的不可枚举,我们可以在类中模拟私有属性,控制变量读写:

const privateField = Symbol();
class myClass {
  constructor(){
    this[privateField] = 'ConardLi';
  }
  getField(){
    return this[privateField];
  }
  setField(val){
    this[privateField] = val;
  }
}
  • 防止属性污染

在某些情况下,我们可能要为对象添加一个属性,此时就有可能造成属性覆盖,用Symbol作为对象属性可以保证永远不会出现同名属性。

Function.prototype.myCall = function (context) {
  if (typeof this !== 'function') {
    return undefined; // 用于防止 Function.prototype.myCall() 直接调用
  }
  context = context || window;
  const fn = Symbol();
  context[fn] = this;
  const args = [...arguments].slice(1);
  const result = context[fn](...args);
  delete context[fn];
  return result;
    }

例如上面的场景,我们模拟实现一个 call 方法,需要在某个对象上临时调用一个方法,又不能造成属性污染,Symbol 是一个很好的选择

装箱和拆箱

  • 装箱转换:把基本类型转换为对应的包装类型
  • 拆箱操作:把引用类型转换为基本类型

拆箱的过程会遵循 ECMAScript 规范规定的 toPrimitive 原则,一般会调用引用类型的 valueOf 和 toString 方法,你也可以直接重写 toPrimitive 方法。一般转换成不同类型的值遵循的原则不同,例如:

  • 引用类型转换为 Number 类型,先调用 valueOf,再调用 toString
  • 引用类型转换为 String 类型,先调用 toString,再调用 valueOf
  • 若 valueOf 和 toString 都不存在,或者没有返回基本类型,则抛出 TypeError 异常

类型判断

单用 typeof 并无法完全满足,这其实并不是 bug,本质原因是 JS 的万物皆对象的理论

  1. typeof 返回值是一个字符串,用来说明变量的数据类型
    • 一般只能返回如下几个结果: number, boolean, string, function, object, undefined
    • typeof null 返回 object
    • typeof 一般用来获取一个变量是否存在 typeof a !== "undefined"
    • 对 于Array, Null 等特殊对象使用 typeof 一律返回 object
  2. object instanceof constructor 用来检测 constructor.prototype(构造函数原型对象) 是否在 object 的原型链上
    • 由于检查整个原型链,同一个实例对象可能会对多个构造函数都返回 true
    • 所以主要用来比较自定义类型的对象
  3. Object.prototype.toString.call(obj) 返回一个表示该对象的字符串
    • Object.prototype.toString.call(obj).slice(8, -1) == 'Number'([object Number] )

typeof null 被识别为 Object,是历史遗留问题

  • JavaScript 中的值是由一个表示类型的标签和实际数据值表示的
  • 对象的类型标签是 0
  • 由于 null 代表的是空指针(大多数平台下值为 0x00),因此,null 的类型标签是 0
  • typeof null 也因此返回 "object"

类型转换(隐式类型转换)

1. if 语句和逻辑语句

在 if 语句和逻辑语句中,如果只有单个变量,会先将变量转换为 Boolean 值,只有(null、undefined、NaN、0、false、'')这几种情况会转换成 false,其余被转换成 true

2. 各种运算

- * / ,会先将非 Number 类型转换为 Number 类型

+ 分以下几种情况

  • 当一侧为 String 类型,被识别为字符串拼接,并会优先将另一侧转换为字符串类型
  • 当一侧为 Number 类型,另一侧为原始类型,则将原始类型转换为 Number 类型
  • 当一侧为 Number 类型,另一侧为引用类型,将引用类型和 Number 类型转换成字符串后拼接

3. ==

使用 == 时,若两侧类型相同,则比较结果和 === 相同,否则会发生隐式转换,使用 == 时发生的转换可以分为几种不同的情况(只考虑两侧类型不同)

  • NaN

NaN和其他任何类型比较永远返回false(包括和他自己)

NaN == NaN // false
  • Boolean

Boolean 和其他任何类型比较,Boolean 首先被转换为 Number 类型

true == 1  // true 
true == '2'  // false
true == ['1']  // true
true == ['2']  // false
  • String 和 Number

String 和 Number 比较,先将 String 转换为 Number 类型

123 == '123' // true
'' == 0 // true
  • null 和 undefined

null == undefined 比较结果是 true,除此之外,null、undefined 和其他任何结果的比较值都为 false

null == undefined // true
null == '' // false
null == 0 // false
null == false // false
undefined == '' // false
undefined == 0 // false
undefined == false // false
  • 原始类型和引用类型

当原始类型和引用类型做比较时,对象类型会依照 ToPrimitive 规则转换为原始类型:

'[object Object]' == {} // true
 '1,2,3' == [1, 2, 3] // true

!的优先级高于==,![]首先会被转换为false,然后根据上面第二点,false 转换成 Number 类型 0,左侧[]转换为0,两侧比较相等。

 [] == ![] // true

根据数组的 ToPrimitive 规则,数组元素为 null 或 undefined 时,该元素被当做空字符串处理,所以[null]、[undefined]都会被转换为0

[null] == false // true
[undefined] == false // true

4. 经典面试题

一道有意思的面试题,如何让 a == 1 && a == 2 && a == 3

const a = {
   value:[3,2,1],
   valueOf: function () {return this.value.pop(); },
} 

传值与传址

基本数据类型的赋值(=)是在内存中新开辟一段栈内存,然后再把值赋值到新的栈中。所以基本类型赋值的两个变量是两个独立相互不影响的变量。

var a = 10;
var b = a;

a ++ ;
console.log(a); // 11
console.log(b); // 10

引用类型的赋值是传址,是对象保存在栈中的地址的赋值。两个变量就指向同一个对象,因此两者之间操作互相有影响

var a = {}; // a保存了一个空对象的实例
var b = a;  // a和b都指向了这个空对象

a.name = 'jozo';
console.log(a.name); // 'jozo'
console.log(b.name); // 'jozo'

b.age = 22;
console.log(b.age);// 22
console.log(a.age);// 22

console.log(a == b);// true

值传递和引用传递

ECMAScript中所有的函数的参数都是按值传递的

当变量是原始类型时,将参数本身的值复制了一份,传递到函数中

let name = 'init value';
function changeValue(name){
  name = 'change value';
}
changeValue(name);
console.log(name); // init value

当变量是引用类型时,是将参数指向内存的地址复制了一份,传递到函数中。我们在函数内部对对象的属性进行操作,实际上和外部变量指向堆内存中的值相同,但是这并不代表着引用传递。

let obj = {};
function changeValue(obj){
  obj.name = 'change value';
  obj = {name:'init value'};
}
changeValue(obj);
console.log(obj.name); // change value

赋值/浅拷贝/深拷贝

    var obj1 = {
        'name' : 'zhangsan',
        'age' :  '18',
        'language' : [1,[2,3],[4,5]],
    };

    var obj2 = obj1;

    var obj3 = shallowCopy(obj1);
    function shallowCopy(src) {
        var dst = {};
        for (var prop in src) {
            if (src.hasOwnProperty(prop)) {
                dst[prop] = src[prop];
            }
        }
        return dst;
    }

    obj2.name = "lisi";
    obj3.age = "20";

    obj2.language[1] = ["二","三"];
    obj3.language[2] = ["四","五"];

    console.log(obj1);  
    //obj1 = {
    //    'name' : 'lisi',
    //    'age' :  '18',
    //    'language' : [1,["二","三"],["四","五"]],
    //};

    console.log(obj2);
    //obj2 = {
    //    'name' : 'lisi',
    //    'age' :  '18',
    //    'language' : [1,["二","三"],["四","五"]],
    //};

    console.log(obj3);
    //obj3 = {
    //    'name' : 'zhangsan',
    //    'age' :  '20',
    //    'language' : [1,["二","三"],["四","五"]],
    //};
  • 对象
    • obj1:原始数据
    • obj2:将 obj1 赋值操作得到
    • obj3:将 obj1 浅拷贝得到
  • 操作
操作 结果
obj2.name = "lisi"; obj1.name 的值改变,obj3.name 不会改变
obj3.age = "20"; obj1.name 和 obj3.name 的值都不会改变
obj2.language[1] = ["二","三"]; obj1.language 和 obj3.language 被改变
obj3.language[2] = ["四","五"]; obj1.language 和 obj2.language 被改变
  • 结果

赋值:将 B 对象的引用直接赋值给 A 对象,对象 A B 引用的仍是同一个对象

深拷贝:将 B 对象拷贝到 A 对象中,包括 B 里面的子对象(不仅将原对象的各个属性逐个复制出去,而且将原对象各个属性所包含的对象也依次采用深复制的方法递归复制到新对象上)

浅拷贝:将 B 对象拷贝到 A 对象中,但不包括 B 里面的子对象(浅复制只会将对象的各个属性进行依次复制,并不会进行递归复制)

-- 和原数据是否指向同一对象 第一层数据为基本数据类型 原数据中包含子对象
赋值 改变使原数据一同改变 改变使原数据一同改变
浅拷贝 改变不会使原数据一同改变 改变使原数据一同改变
深拷贝 改变不会使原数据一同改变 改变不会会使原数据一同改变

浅拷贝的实现方式

Object.assign(target, ...sources)

该方法用于将所有自身的并且可枚举的属性的值从一个或多个源对象复制到目标对象并返回目标对象

  1. bject.assign 方法只会拷贝源对象自身的并且可枚举的属性到目标对象。该方法使用源对象的[[Get]]和目标对象的[[Set]],所以它会调用相关 getter 和 setter
  2. String类型和 Symbol 类型的属性都会被拷贝
  3. 在出现错误的情况下,例如,如果属性不可写,会引发TypeError,如果在引发错误之前添加了任何属性,则可以更改target对象
  4. Object.assign 不会在那些source对象值为 null 或 undefined 的时候抛出错误,null 和 undefined 会被忽略

手动实现

function shallowCopy(source) {
    var dst = {};
    for (var prop in source) {
        if (source.hasOwnProperty(prop)) {
            dst[prop] = source[prop];
        }
    }
    return dst;
}

深拷贝的实现

第三方库

Object.create()、$.extend、lodash等第三方库

var _ = require('lodash');
var obj1 = {
    a: 1,
    b: { f: { g: 1 } },
    c: [1, 2, 3]
};
var obj2 = _.cloneDeep(obj1);
console.log(obj1.b.f === obj2.b.f);
// false

JSON.parse(JSON.stringify(obj))

原理:用JSON.stringify将对象转成JSON字符串,再用JSON.parse()把字符串解析成对象,一去一来,新的对象产生了,而且对象会开辟新的栈,实现深拷贝。

缺点:

  • 具有循环引用的对象时,会报错
  • 当值为函数、正则、undefined、或 symbol 时,无法拷贝
  • 会抛弃对象的 constructor ,即不管原来的构造函数是什么深拷贝后都会变成 object

深拷贝循环引用

关于循环引用的问题解决思路有两种,一直是循环检测,一种是暴力破解(拷贝对象前先查找)。JSON.stringify内部做了循环引用的检测。

var a = {};
a.a = a;
cloneJSON(a) // Uncaught TypeError: Converting circular structure to JSON

解决引用丢失的现象。假如一个对象a,a下面的两个键值都引用同一个对象b,经过深拷贝后,a的两个键值会丢失引用关系,从而变成两个不同的对象

var b = 1;
var a = {a1: b, a2: b};

a.a1 === a.a2 // true

var c = clone(a);
c.a1 === c.a2 // false

递归实现

最简单的深拷贝

递归进行逐一赋值

  • 对象的属性进行递归遍历
  • 如果对象的属性不是基本类型时,就继续递归,直到遍历到对象属性为基本类型
  • 然后将属性和属性值赋给新对象
function deepCopy(source) {
    var target = {};
    for(var i in source) {
        if (source.hasOwnProperty(i)) {
            if (typeof source[i] === 'object') {
                target[i] = deepCopy(source[i]); // 注意这里
            } else {
                target[i] = source[i];
            }
        }
    }
    return target;
}

使用 Map 处理循环引用

使用 Map的 key 可以是对象的特性,把要拷贝的目标对象,当做 key 存起来,value是深拷贝后的对象。这种算法思想来源于 HTML5 规范定义的结构化克隆算法

结构化克隆算法通过递归输入对象来构建克隆,同时保持先前访问过的引用的映射,以避免无限遍历循环

  • Error 以及 Function 对象是不能被结构化克隆算法复制的;如果你尝试这样子去做,这会导致抛出 DATA_CLONE_ERR 的异常
  • 企图去克隆 DOM 节点同样会抛出 DATA_CLONE_ERROR 异常。
  • 对象的某些特定参数也不会被保留
    • RegExp 对象的 lastIndex 字段不会被保留
    • 属性描述符,setters 以及 getters(以及其他类似元数据的功能)同样不会被复制。例如,如果一个对象用属性描述符标记为 read-only,它将会被复制为 read-write,因为这是默认的情况下。
    • 原形链上的属性也不会被追踪以及复制

function deepCopy(obj, map = new Map()) {
    if (typeof obj === 'object') {
        let res = Array.isArray(obj) ? [] : {}
        if (map.has(obj) {
            return map.get(obj)
        }
        map.set(obj, res)
        for (let i in obj) {
            res[i] = deepCopy(obj[i], map)
        }
        return map.get(obj)
    }
    else {
        return obj
    }
}

使用 WeakMap 代替 Map

WeekMap 只接受对象作为键名(null除外),其次 WeakMap 的键名所指向的对象不计入垃圾回收机制。WeakMap 弱引用的只是键名,而不是键值,键值依然是正常引用。

设计的目的在于,当想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用,所以必须手动删除这个引用,否则垃圾回收机制就不会释放。

如果要拷贝的对象非常庞大时,使用 Map 会对内存造成非常大的额外消耗,而我们需要手动清楚 Map 的属性才难呢过释放这块内存,而 WeekMap 会帮我们巧妙的解决这个问题。

使用循环代替递归

原理

把对象转换成树,用循环遍历一棵树,需要借助一个栈,当栈为空时就遍历完了,栈里面存储下一个需要拷贝的节点

var a = {
    a1: 1,
    a2: {
        b1: 1,
        b2: {
            c1: 1
        }
    }
}
// ----------
    a
  /   \
 a1   a2        
 |    / \         
 1   b1 b2     
     |   |        
     1  c1
         |
         1       

循环遍历简单实现

往栈里放入种子数据,然后遍历当前节点下的子元素,如果是对象就放到栈里,否则直接拷贝

function cloneLoop(x) {
    const root = {};
    // 栈
    const loopList = [
        {
            parent: root,
            key: undefined,
            data: x,
        }
    ];
    while(loopList.length) {
        // 深度优先
        const node = loopList.pop();
        const parent = node.parent;
        const key = node.key;
        const data = node.data;
        // 初始化赋值目标,key为undefined则拷贝到父元素,否则拷贝到子元素
        let res = parent;
        if (typeof key !== 'undefined') {
            res = parent[key] = {};
        }
        for(let k in data) {
            if (data.hasOwnProperty(k)) {
                if (typeof data[k] === 'object') {
                    // 下一次循环
                    loopList.push({
                        parent: res,
                        key: k,
                        data: data[k],
                    });
                } else {
                    res[k] = data[k];
                }
            }
        }
    }
    return root;
}

使用栈处理循环引用

引入一个数组 uniqueList 用来存储已经拷贝的数组,每次循环遍历时,先判断对象是否在 uniqueList 中了,如果在的话就不执行拷贝逻辑了

// 保持引用关系
function cloneForce(x) {
    // 用来去重
    const uniqueList = [];
    let root = {};
    const loopList = [
        {
            parent: root,
            key: undefined,
            data: x,
        }
    ];
    while(loopList.length) {
        // 深度优先
        const node = loopList.pop();
        const parent = node.parent;
        const key = node.key;
        const data = node.data;
        // 初始化赋值目标,key为undefined则拷贝到父元素,否则拷贝到子元素
        let res = parent;
        if (typeof key !== 'undefined') {
            res = parent[key] = {};
        }
        // 数据已经存在
        let uniqueData = find(uniqueList, data);
        if (uniqueData) {
            parent[key] = uniqueData.target;
            break; // 中断本次循环
        }
        // 保存源数据,在拷贝数据中对应的引用
        uniqueList.push({
            source: data,
            target: res,
        });
        for(let k in data) {
            if (data.hasOwnProperty(k)) {
                if (typeof data[k] === 'object') {
                    // 下一次循环
                    loopList.push({
                        parent: res,
                        key: k,
                        data: data[k],
                    });
                } else {
                    res[k] = data[k];
                }
            }
        }
    }
    return root;
}

function find(arr, item) {
    for(let i = 0; i < arr.length; i++) {
        if (arr[i].source === item) {
            return arr[i];
        }
    }
    return null;
}

Reflect 方法

for...in 无法获得 Symbol 类型的键,而 Reflect.ownKeys() 返回一个由目标对象自身的属性键组成的数组。等同于 Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target))。所以可以获取 Symbol 类型的键

function deepClone(obj) {
    if (!isObject(obj)) {
        throw new Error('obj 不是一个对象!')
    }
    let isArray = Array.isArray(obj)
    let cloneObj = isArray ? [...obj] : { ...obj }
    Reflect.ownKeys(cloneObj).forEach(key => {
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
    })
    return cloneObj
}

深入探讨

循环遍历优化

使用 while 实现一个通用的 forEach 遍历,for、while、for in 中 while 的效率是最好的

function forEach(array, iteratee) {
    let index = -1;
    const length = array.length;
    while (++index < length) {
        iteratee(array[index], index);
    }
    return array;
}

遍历类型(Map、Set、Array、Object)

每个引用类型都有 toString 方法,默认情况下 toString 方法被每个 Object 对象继承,如果此方法在自定义对象中未被覆盖,toString 返回 [object type]。但是大部分引用类型比如 Array、Date、RegExp 等都重写了 toString 方法,所以直接调用 Object 原型上未被覆盖的 toString 方法,使用 call 来改变 this 指向来达到我们想要的效果

// 是否为引用类型
function isObject(target) {
    const type = typeof target;
    return target !== null && (type === 'object' || type === 'function');
}
// 数据类型
function getType(target) {
    return Object.prototype.toString.call(target).slice(8, -1).toLowerCase();
}
// 克隆set
if (type === setTag) {
    target.forEach(value => {
        cloneTarget.add(clone(value, map));
    });
    return cloneTarget;
}
// 克隆map
if (type === mapTag) {
    target.forEach((value, key) => {
        cloneTarget.set(key, clone(value, map));
    });
    return cloneTarget;
}
// 克隆对象和数组
const keys = type === arrayTag ? undefined : Object.keys(target);
forEach(keys || target, (value, key) => {
    if (keys) {
        key = value;
    }
    cloneTarget[key] = clone(target[key], map);
});

不可继续遍历类型

Bool、Number、String、Error、Date 通过 target.constructor 构造

RegExp、Symbol、Function 分别处理

function cloneOtherType(targe, type) {
    const Ctor = targe.constructor;
    switch (type) {
        case boolTag:
        case numberTag:
        case stringTag:
        case errorTag:
        case dateTag:
            return new Ctor(targe);
        case regexpTag:
            return cloneReg(targe);
        case symbolTag:
            return cloneSymbol(targe);
        case funcTag:
            return cloneFunction(targe);
        default:
            return null;
    }
}
function cloneReg(targe) {
    const reFlags = /\w*$/;
    const result = new targe.constructor(targe.source, reFlags.exec(targe));
    result.lastIndex = targe.lastIndex;
    return result;
}
function cloneSymbol(targe) {
    return Object(Symbol.prototype.valueOf.call(targe));
}

克隆函数

实际上克隆函数是没有实际应用场景的,两个对象使用一个在内存中处于同一个地址的函数也是没有任何问题的

lodash 发现是函数的话就会直接返回,没有做特殊的处理

 const isFunc = typeof value == 'function'
 if (isFunc || !cloneableTags[tag]) {
    return object ? value : {}
 }

通过 prototype 来区分下箭头函数和普通函数,箭头函数是没有 prototype 的

  • 使用 eval 和函数字符串来重新生成一个箭头函数
  • 使用正则取出函数体和函数参数,然后使用new Function([arg1[, arg2[, ...argN]],] functionBody) 构造函数重新构造一个新的函数
function cloneFunction(func) {
    const bodyReg = /(?<={)(.|\n)+(?=})/m;
    const paramReg = /(?<=\().+(?=\)\s+{)/;
    const funcString = func.toString();
    // 普通函数
    if (func.prototype) {
        const param = paramReg.exec(funcString);
        const body = bodyReg.exec(funcString);
        if (body) {
            console.log('匹配到函数体:', body[0]);
            if (param) {
                const paramArr = param[0].split(',');
                console.log('匹配到参数:', paramArr);
                return new Function(...paramArr, body[0]);
            } else {
                return new Function(body[0]);
            }
        } else {
            return null;
        }
    }
    // 箭头函数
    else {
        return eval(funcString);
    }
}

拷贝原型上的属性

JS 对象是基于原型链设计的,所以当一个对象的属性查找不到时会沿着它的原型链向上查找,即这个对象的__proto__属性,也就是构造对象的构造函数的原型对象

for...in 会追踪原型链上的属性,而其它三种方法(Object.keys、Reflect.ownKeys 和 JSON 方法)都不会追踪原型链上的属性

let childTest = Object.create(test)
let result = deepClone(childTest)

拷贝不可枚举的属性

需要拷贝类似属性描述符,setters 以及 getters 这样不可枚举的属性,一般来说,这就需要一个额外的不可枚举的属性集合来存储它们

Object.getOwnPropertyDescriptors(obj) 返回指定对象的所有自身属性的描述符,如果没有任何自身属性,则返回空对象

浅拷贝一个对象的方法

Object.create(
  Object.getPrototypeOf(obj), 
  Object.getOwnPropertyDescriptors(obj) 
);
function deepClone(obj, hash = new WeakMap()) {
    if (!isObject(obj)) {
        return obj
    }
    // 查表,防止循环拷贝
    if (hash.has(obj)) return hash.get(obj)

    let isArray = Array.isArray(obj)
    // 初始化拷贝对象
    let cloneObj = isArray ? [] : {}
    // 哈希表设值
    hash.set(obj, cloneObj)
    // 返回指定对象的所有自身属性的描述符,如果没有任何自身属性,则返回空对象
    let allDesc = Object.getOwnPropertyDescriptors(obj)
    // 获取源对象所有的 Symbol 类型键
    let symKeys = Object.getOwnPropertySymbols(obj)
    // 拷贝 Symbol 类型键对应的属性
    if (symKeys.length > 0) {
        symKeys.forEach(symKey => {
            cloneObj[symKey] = isObject(obj[symKey]) ? deepClone(obj[symKey], hash) : obj[symKey]
        })
    }

    // 拷贝不可枚举属性, 因为 allDesc 的 value 是浅拷贝,所以要放在前面
    cloneObj = Object.create(
        // 获取指定对象的原型(内部[[Prototype]]属性的值)如果没有继承属性,则返回 null 
        Object.getPrototypeOf(cloneObj),
        allDesc
    )
    // 拷贝可枚举属性(包括原型链上的)
    for (let key in obj) {
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key], hash) : obj[key];
    }

    return cloneObj
}

源码

const mapTag = '[object Map]';
const setTag = '[object Set]';
const arrayTag = '[object Array]';
const objectTag = '[object Object]';
const argsTag = '[object Arguments]';

const boolTag = '[object Boolean]';
const dateTag = '[object Date]';
const numberTag = '[object Number]';
const stringTag = '[object String]';
const symbolTag = '[object Symbol]';
const errorTag = '[object Error]';
const regexpTag = '[object RegExp]';
const funcTag = '[object Function]';

const deepTag = [mapTag, setTag, arrayTag, objectTag, argsTag];


function forEach(array, iteratee) {
    let index = -1;
    const length = array.length;
    while (++index < length) {
        iteratee(array[index], index);
    }
    return array;
}

function isObject(target) {
    const type = typeof target;
    return target !== null && (type === 'object' || type === 'function');
}

function getType(target) {
    return Object.prototype.toString.call(target);
}

function getInit(target) {
    const Ctor = target.constructor;
    return new Ctor();
}

function cloneSymbol(targe) {
    return Object(Symbol.prototype.valueOf.call(targe));
}

function cloneReg(targe) {
    const reFlags = /\w*$/;
    const result = new targe.constructor(targe.source, reFlags.exec(targe));
    result.lastIndex = targe.lastIndex;
    return result;
}

function cloneFunction(func) {
    const bodyReg = /(?<={)(.|\n)+(?=})/m;
    const paramReg = /(?<=\().+(?=\)\s+{)/;
    const funcString = func.toString();
    if (func.prototype) {
        const param = paramReg.exec(funcString);
        const body = bodyReg.exec(funcString);
        if (body) {
            if (param) {
                const paramArr = param[0].split(',');
                return new Function(...paramArr, body[0]);
            } else {
                return new Function(body[0]);
            }
        } else {
            return null;
        }
    } else {
        return eval(funcString);
    }
}

function cloneOtherType(targe, type) {
    const Ctor = targe.constructor;
    switch (type) {
        case boolTag:
        case numberTag:
        case stringTag:
        case errorTag:
        case dateTag:
            return new Ctor(targe);
        case regexpTag:
            return cloneReg(targe);
        case symbolTag:
            return cloneSymbol(targe);
        case funcTag:
            return cloneFunction(targe);
        default:
            return null;
    }
}

function clone(target, map = new WeakMap()) {

    // 克隆原始类型
    if (!isObject(target)) {
        return target;
    }

    // 初始化
    const type = getType(target);
    let cloneTarget;
    if (deepTag.includes(type)) {
        cloneTarget = getInit(target, type);
    } else {
        return cloneOtherType(target, type);
    }

    // 防止循环引用
    if (map.get(target)) {
        return map.get(target);
    }
    map.set(target, cloneTarget);

    // 克隆set
    if (type === setTag) {
        target.forEach(value => {
            cloneTarget.add(clone(value, map));
        });
        return cloneTarget;
    }

    // 克隆map
    if (type === mapTag) {
        target.forEach((value, key) => {
            cloneTarget.set(key, clone(value, map));
        });
        return cloneTarget;
    }

    // 克隆对象和数组
    const keys = type === arrayTag ? undefined : Object.keys(target);
    forEach(keys || target, (value, key) => {
        if (keys) {
            key = value;
        }
        cloneTarget[key] = clone(target[key], map);
    });

    return cloneTarget;
}

module.exports = {
    clone
};

参考

如何写出一个惊艳面试官的深拷贝?

递归拷贝源码

jsmini/clone

【JS 进阶】你真的掌握变量和类型了吗