1.相关数据结构

堆(stack)和栈(heap)都是内存中划分出来用来存储的区域。

  • 栈数据结构
    栈遵循后进先出(LIFO),执行上下文的基础结构就是栈。

  • 堆数据结构
    堆数据结构是一种树状结构。它的存取数据的方式与书架和书非常相似。我们只需要知道书的名字就可以直接取出书了,并不需要把上面的书取出来。JSON格式的数据中,我们存储的 key-value 可以是无序的,因为顺序的不同并不影响我们的使用,我们只需要关心书的名字。

  • 队列数据结构
    队列遵循先进先出(FIFO),事件循环的基础结构就是队列。

2.数据类型

2.1 基本数据类型:

  • js 有 6 种基本数据类型:undefined,null,boolean,number,string,symbol
  • 基本数据类型在内存中分别占有固定大小的空间,所以都是保存在栈内存中的(闭包的自由变量是例外,其保存在堆内存中,因此 context stack 销毁后依然存在)
  • 基本数据类型是按值访问的
  • 比较:值的比较

2.2 引用数据类型:

  • 一般指的是 object
  • 对象在内存中的大小不固定,所以保存在堆内存中,又由于对象的地址大小固定,所以地址保存在栈内存中
  • 引用数据类型是按引用访问的。访问对象时,先从栈中读取内存地址,然后再根据这个地址找到堆中的对象
  • 比较:引用的比较

3.赋值、浅拷贝和深拷贝的区别

3.1 赋值

赋值是将某一数值或对象赋给某个变量的过程,包括两种:

  • 基本数据类型:
    就是简单的赋值。因为在内存中开辟了一块新的栈空间,所以赋值之后两个变量独立、互不影响
  • 引用数据类型:
    。实际操作的是对象内存的地址,最后使两个变量具有相同的引用,都指向同一个对象,因此这两个变量相互之间有影响

3.2 浅拷贝

定义:

浅拷贝即 shallow copy,它会创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值;如果属性是引用类型,拷贝的就是其内存地址,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。
简而言之,浅拷贝的“浅”在于它的拷贝只停留在一层,即:拷贝第一层的基本类型值,以及第一层的引用类型地址。如图:
拷贝第一层的基本类型值,以及第一层的引用类型地址。

哪些地方是浅拷贝?

  • Object.assign()

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象,最后再将目标对象返回。

let a = {
    author: "Jack",
    article: {
        title: "Understanding event loop",
        date: "2019-1-1"
    }
}
let b = Object.assign({}, a);
console.log(b);
/* {
    author: "Jack",
    article: {
        title: "Understanding event loop",
        date: "2019-1-1"
    }
} */
a.author = "Bob";
a.article.date = "2019.2.1";
console.log(a);
/* {
    author: "Bob",
    article: {
        title: "Understanding event loop",
        date: "2019-2-1"
    }
} */ 

console.log(b);
/* {
    author: "Jack",
    article: {
        title: "Understanding event loop",
        date: "2019-2-1"
    }
} */

可以看到,因为 a 和 b 有各自的基本类型属性,所以对这种属性的修改是独立的;但是由于 a 和 b 的引用类型属性指向内存中的同一个对象,所以 a 对该对象的修改会反映到 b 上,这是浅拷贝的特点。

  • 展开语法...

展开语法可以在函数调用/数组构造时, 将数组表达式或者字符串在语法层面展开;还可以在构造字面量对象时, 将对象表达式按 key-value 的方式展开。

let a = {
    author: "Jack",
    article: {
        title: "Understanding event loop",
        date: "2019-1-1"
    }
}
let b = {...a};
console.log(b);
/* {
    author: "Jack",
    article: {
        title: "Understanding event loop",
        date: "2019-1-1"
    }
} */
a.author = "Bob";
a.article.date = "2019.2.1";
console.log(a);
/* {
    author: "Bob",
    article: {
        title: "Understanding event loop",
        date: "2019-2-1"
    }
} */ 

console.log(b);
/* {
    author: "Jack",
    article: {
        title: "Understanding event loop",
        date: "2019-2-1"
    }
} */
  • Array.prototype.slice()

slice() 方法返回一个新的数组对象,这一对象是一个由 begin 和 end(不包括 end )决定的原数组的浅拷贝。

let a = [0, "1", [2, 3]];
let b = a.slice(1);
console.log(b);
// ["1", [2, 3]]

a[1] = "99";
a[2][0] = 4;
console.log(a);
// [0, "99", [4, 3]]

console.log(b);
//  ["1", [4, 3]]

可以看到,因为 a 和 b 都有各自的基本类型属性,所以修改 a[1] 对 b 没有影响;但是由于 a 和 b 的引用类型属性指向内存中的同一个数组对象,所以对 a[2][0] 的修改会反映到 b 上。

PS:为什么 slice 会是浅拷贝呢?因为 slice 的内部实现,实际上是以 begin 作为切片起点,end 作为切片终点,这中间 for 循环一次,依次取数组元素赋值给新数组,这个过程中,若元素是数组对象,则只是赋了地址,因此引用的还是同一个数组对象。

3.3 深拷贝

定义:

深拷贝即 deep copy,它会拷贝所有的属性,并拷贝属性指向的动态分配的内存。当对象和它所引用的对象一起拷贝时即发生深拷贝。深拷贝相比于浅拷贝,速度较慢并且花销较大。拷贝前后两个对象互不影响。
简而言之,深拷贝的“深”在于它的拷贝不仅仅停留在一层,而是连同里面的子对象也一同拷贝。如图:

哪些地方是深拷贝?

  • JSON.parse(JSON.stringify(object))

对象(包括数组)的序列化和反序列化

let a = {
    author: "Jack",
    article: {
        title: "Understanding event loop",
        date: "2019-1-1"
    }
}
let b = JSON.parse(JSON.stringfy(a));
console.log(b);
/* {
    author: "Jack",
    article: {
        title: "Understanding event loop",
        date: "2019-1-1"
    }
} */
a.author = "Bob";
a.article.date = "2019.2.1";
console.log(a);
/* {
    author: "Bob",
    article: {
        title: "Understanding event loop",
        date: "2019-2-1"
    }
} */ 

console.log(b);
/* {
    author: "Jack",
    article: {
        title: "Understanding event loop",
        date: "2019-1-1"
    }
} */

改变 a.article.date 之后对 b 没有影响,可见这是深拷贝,a 和 b 有各自的 article 对象。

不过,JSON.parse(JSON.stringify(object)) 有以下几个问题:

1、会忽略 undefined

2、会忽略 symbol

3、会忽略函数(不能序列化函数)

4、不能解决循环引用的对象

5、不能正确处理 new Date()

6、不能处理正则

3.4 总结

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

4.模拟实现 Object.assign() 的浅拷贝

4.1 Object.assign() 浅拷贝的特点

  • 如果目标对象中的属性具有相同的键,则属性将被源对象中的属性覆盖。后来的源对象的属性将类似地覆盖早先的属性;
  • string、symbol、null 和 undefined 类型的属性都会被拷贝

4.2 实现 Object.assign() 的基本思路:

1、判断原生 Object 是否支持该函数,如果不存在的话创建一个 assign 函数,并使用 Object.defineProperty 将该函数绑定到 Object 上。

2、判断参数是否正确(目标对象不能为空,我们可以直接设置 {} 传递进去,但必须设置值)。

3、使用 Object() 转成对象,并保存为 to,最后返回这个对象 to

4、使用 for..in 循环遍历出所有可枚举属性,配合 hasOwnProperty 获取所有可枚举自有(非原型链上的)属性,再复制给新的目标对象。

4.3 具体实现代码:

下面是 MDNassign() 的 polyfill(注意:此 polyfill 不支持 symbol 属性,因为 ES5 中根本没有 symbol):

if (typeof Object.assign != 'function') {
  // Must be writable: true, enumerable: false, configurable: true
  Object.defineProperty(Object, "assign", {
    value: function assign(target, varArgs) { // .length of function is 2
      'use strict';
      if (target == null) { // TypeError if undefined or null
        throw new TypeError('Cannot convert undefined or null to object');
      }

      let to = Object(target);

      for (var index = 1; index < arguments.length; index++) {
        var nextSource = arguments[index];

        if (nextSource != null) { // Skip over if undefined or null
          for (let nextKey in nextSource) {
            // Avoid bugs when hasOwnProperty is shadowed
            if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
              to[nextKey] = nextSource[nextKey];
            }
          }
        }
      }
      return to;
    },
    writable: true,
    configurable: true
  });
}

4.4 重点注意

经过测试,该模拟实现的 assign() 可以达到和原生 Object 的 assign() 一样的浅拷贝效果。下面说几个重点需要注意的地方:

  • 为什么使用 Object.defineProperty() 添加方法,而不是直接挂载?

我们知道,for...in 可以遍历出自身以及原型链上的可枚举属性,而 Object.keys() 只能遍历出自身的可枚举属性

for(var i in Object) {
    console.log(Object[i]);
}
// 无输出

Object.keys(Object);
// []

可见,Object 的属性默认都是不可枚举的,但是,直接挂载在 Object 上面的属性却是可枚举的:

Object.a = 1;
for(var i in Object){
    console.log(Object[i]);
}
// 1 

Object.keys( Object );
// ["a"]

所以,只有 assign() 确实是可枚举属性时,才可以将其直接挂载在 assign() 上。但是, assign() 实际上是不可枚举的。
我们可以使用 2 种方法查看 assign() 是否可枚举:

  • Object.getOwnPropertyDescriptor()
  • Object.propertyIsEnumerable()
    其中,后者会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足 enumerable: true

具体用法如下:

// 方法1
Object.getOwnPropertyDescriptor(Object, "assign");
// {
// 	value: ƒ, 
//  writable: true, 	// 可写
//  enumerable: false,  // 不可枚举,注意这里是 false
//  configurable: true	// 可配置
// }

// 方法2
Object.propertyIsEnumerable("assign");
// false

上面代码说明 Object.assign() 是不可枚举的。因此,这里适合用Object.defineProperty() 给 Object 添加属性,用这个方法时也可以不显式指定 enumerable 为 false,因为它默认就是 false。

  • 参数判断:
if (target === undefined || target === null) {
	throw new TypeError('Cannot convert undefined or null to object');
}

上面这种参数判断也可以,但是由于 nullundefined 的相等性判断返回 true,所以实际上直接写 if(target == null) 即可

  • 为什么要用 Obejct() 将 target 参数包装成对象?

正常情况下传入的参数应该是一个对象,但是模拟实现的时候需要考虑传入参数不一定为对象的情况。

1.如果作为源对象的参数不是对象:

nullundefined 会被忽略;除了这两者之外的原始类型则将被包装成对象,在这种情况下,只有自身拥有可枚举属性的包装对象才会被拷贝,其它类型的包装对象会被忽略

var v1 = "abc";
var v2 = true;
var v3 = 10;
var v4 = Symbol("foo");

Object.keys( v1 ); // [ '0', '1', '2' ]
Object.keys( v2 ); // []
Object.keys( v3 ); // []
Object.keys( v4 ); // []
// 可以看到,只有字符串类型的包装对象,才有自身的可枚举属性
var obj = Object.assign({}, null, undefined, v1, v2, v3, v4); 
// 只有 v1 会被拷贝
console.log(obj); 
// { "0": "a", "1": "b", "2": "c" }

2.如果作为目标对象的参数不是对象:

同理,我们还要考虑 target 参数也可能不是对象的情况,所以在上面的 polyfill 中,要使用 Object() 将 target 参数包装成对象。

  • 为什么要用严格模式?
    使用 Object() 对参数进行包装后,对于得到的包装对象而言,其既有属性的 writable 为 false,也就是说不能对其既有属性进行改写,否则会报错
var str1 = "abc";
var str2 = "def";
Object.assign(str1, str2); 

assign() 的内部实现中将 str1str2 包装为对象,因此这两者在内部是这样的:

// str1
{
    0:"a",
    1:"b",
    2:"c"
}
// str2
{
    0:"d",
    1:"e",
    2:"f"
}

Object.assign(str1, str2) 相当于用 str2 的同名属性依次覆盖 str1 的属性,从而实现拷贝。但是由于 str1 是用 Object() 进行包装的,所以这个拷贝是不生效的,会报错。
但是,如果在 assign() 的实现内部不使用严格模式,则不会报错:

var myObject = Object('abc'); 

Object.getOwnPropertyDescriptor(myObject, '0');
// { 
//   value: 'a',
//   writable: false, // 注意这里
//   enumerable: true,
//   configurable: false 
// }

myObject[0] = 'd';
// 'd'

myObject[0];
// 'a'

这里并没有报错,原因在于 js 对于不可写的属性值的修改静默失败,只有在严格模式下才会提示错误。为了实现正常的报错,我们必须在内部使用严格模式。

  • 为什么要用 Object.prototype.hasOwnProperty.call()

通过 for...in.. 得到的是源对象自身及其原型链上的可枚举属性,但浅拷贝只需要拷贝自身可枚举属性,所以需要用 hasOwnProperty() 筛选,但是直接调用这个方法是不行的。一方面,我们需要考虑到源对象可能重写了这个方法而导致其无法正常调用,另一方面还要考虑到源对象可能是基于 Object.create(null) 创建的,而这种方法创建的对象由于不具有与 Object 原型链的联系,因此不具有 hasOwnProperty() 方法,在调用的时候会报错。

var myObject = Object.create( null );
myObject.b = 2;

myObject.hasOwnProperty( "b" );
// TypeError: myObject.hasOwnProperty is not a function

所以,这里采用 Object.prototype.hasOwnProperty.call(),将 hasOwnProperty() 内部的 this 绑定到源对象上,也可以达到同样的效果。

var myObject = Object.create( null );
myObject.b = 2;

Object.prototype.hasOwnProperty.call(myObject, "b");
// true

参考:
https://github.com/yygmind/blog/issues/25
https://github.com/yygmind/blog/issues/26
https://juejin.im/post/59ac1c4ef265da248e75892b#heading-13