JavaScript 中 new 一个对象时到底都做了什么

在手动实现 new 函数的过程中,使用了 Object.create 方法来 “实例化” 给定原型,在代码的注释中,我将 Object.create 注释为 “拷贝”,本篇博文便整理一下有关对象的一些理解,大致包含了如下概念:

  • JavaScript中的基本类型和引用类型;
  • JavaScript中对象和基本变量的本质区别;
  • JavaScript中的引用与拷贝;
  • new 操作符的本质;
  • 不使用 Object.create ,完全手写一个 new 方法

由于我本身也是半个嵌入式工程师,平时的开发中也经常使用 C 语言,所以我将会从 C 语言的角度来解释上述问题。

数据类型

众所周知的,JavaScript 中一共有 6 种常用数据类型,分别是:

  • number;
  • string;
  • boolean;
  • object;
  • undefined
  • null;

后来 ES6 中新增了 symbol 类型,有时也会增加一种 bigInt 类型,这里这些暂时不提,主要看常用的 6 中类型。在这 6 种常用类型中,object 有可以继续扩展出三种常见常用的类型:

  • Array;
  • Function;
  • Date;

一般会将除了 object 以外的类型叫基本类型,将 object 类型叫引用类型,所以将上面的几种类型合并一下,将 object 的子类型合并到大类中,可以分类中基本类型和引用类型的具体类:

常用基本类型

  • number;
  • string;
  • boolean;
  • undefined
  • null;

常用引用类型

  • Array;
  • Function;
  • Date;

基本类型

基本类型类似于 C 语言中的基本变量,使用基本类型的变量时,实际上使用的变量的值,这点非常好理解,看下面的例子:

let a = 1;
let b = 2;
let c = a + b;
console.log(c);
// 3

a 和 b 两个变量在相加时,是变量的值在做加法,所以得到 3 是完全符合预期的,同理:

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

上面这段等于判断也是符合预期的,因为使用基本变量是在使用变量的值,因为 a 和 b 的值是相等的,所以判断的结果为真。

基本变量在函数中也是使用值,当函数的某个参数传递的基本类型的变量时,实际上传递的是这个变量的拷贝,所以在函数中修改参数的值并不会影响原来变量的值:

function add (a, b) {
  a = a + b;
  return a;
}

let val = 1;
console.log(add(val, 2));
// 3
console.log(val);
// 1

上述代码中 add 函数的形参 a 接受来自 val 的拷贝,所以基本形参 a 在 add 函数中发生了变化,但不会对原变量 val 产生影响。

基本类型是非常好理解的,到目前为止一切都符合思维的预期。

引用类型

引用类型与基本类型不同,引用类型再使用时传递的不是变量的值,而是变量的引用,或者说是变量的地址。由于 JavaScript 不能直接输出变量的地址,所以只能使用 = 来进行检查,试看下面的例子:

let a = {val:1};
let b = {val:1};

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

虽然变量 a 与变量 b 从视觉上看起来是一样的,但是比对的结果是 false ,原因是此时的变量 a 和 b 是引用类型,对于引用类型的变量,使用的是地址,此时变量 a 和 b 存放的不是具体的 {val:1} ,而是 {val:1} 的地址,所以此时比对 a 和 b 的值实际上比对其存放的地址,由于JavaScript 处理对象时的机制,导致 a 和 b 所存放的地址必然不相同,所以比对为 false 。

似乎有点饶,看下面的表格:

基本类型

变量名 变量的地址 变量的值
a 0x0001 1
b 0x0005 1

熟悉 C 语言的话上面的表格很容易看懂。每一个变量在内存中都对应有一片区域,这篇区域的起始地址就是这个变量的地址,这篇区域的值就是变量的值。我们在程序中使用变量,默认使用的是变量的值,也就是变量在内存地址那篇区域里存放的数据,对于基本类型的变量,这个数据就是它的值。

从上面的表格可以看出,所以 a 和 b 两个变量的地址不同,但是变量的值是相同的,所以 a = b 的结果是 true 。

引用类型

变量名 变量的地址 变量的值
a 0x0001 0x0009
b 0x0005 0x000d
a.val 0x0009 1
b.val 0x000d 1

表格仅仅示例,逻辑是正确的,是为了辅助理解,但不完全代表实际。

引用类型的变量对应的内存地址中存放的不是实际的值,而是所引用的那个变量的地址,这个概念相当于 C 语言里的指针。从表格上可以看到,变量 a 和 b 对应的内存空间所保存的数据不再是 1,而是各自属性 val 的地址,虽然各自的 val 属性都是 1 ,但因为我们比对的 a 和 b ,此时 a 和 b 存在的并不是 1 ,所以得不到 true 。理解了这层指向关系,就能理解 a === b 为什么事 false ,同理,如果我们比对 a.val 和 b.val 的话,应该是可以得到 true 的:

let a = {val:1};
let b = {val:1};

console.log(a.val === b.val);
// ture

同理,基本两个变量的属性名不相同,只要属性的值相同,比对也是 true :

let a = {val_a:1};
let b = {val_b:1};

console.log(a.val_a === b.val_b);
// ture

因为在编程时使用的变量,本质上都是在使用变量的值,只要这两个变量的值相同,那么这两个变量就相等。所以,如果某个函数的形参接受了一个引用类型的变量,那么修改这个形参属性的值,一定是会影响到原变量本身的。

function add (param) {
  param.a += param.b;
  let res = param.a;
  return res;
}

let val = {a:1, b:2};
console.log(add(val));
// 3
console.log(val.a);
// 3

理解了引用的本质,上面的例子就很容易明白。

数据类型小结

总结一下,虽然 JavaScript 的数据类型本质上还是 C 语音那一套,但是因为 JavaScript 是高级语言,所以封装程度更高,了解数据类型的本质后在使用过程中才不会犯错误。从上面的分析可以看到,JavaScript中创建变量类似于 C 语言中的 malloc 函数,是一定会开辟新的内存空间的,只要开辟了新的内存空间,那么就可以理解为是实例,后面理解 new 的本质的时候会用到这个概念。

Object.create()

在上篇博文中,手写 new 的方法是这样的:

function newProto(proto, ...params) {
  // "拷贝"一份proto
  let obj = Object.create(proto.prototype);
  // 定义 __proto__ 指向
  obj.__proto__ = proto.prototype;
  // 定义 constructor 构造函数
  obj.constructor = proto;
  // 修改实例的 this 指向并实例化
  let res = proto.bind(obj)(...params);
  return res instanceof Object ? res : obj;
}

里面用到了 Object.create ,注释我写的是 “拷贝”。因为 new 函数是要实现实例化,实例化的本质是新开辟一块内存空间,存放一个变量,变量的长相与其原型一致,实例化的过程类似于 C 语言中先 malloc 再 memcpy 的过程,所以这里的 Object.create 本质上就是拷贝,只不过是对 prototype 的 浅拷贝

如何判断时浅拷贝还是深拷贝,可以看下面的例子:

function Student (name) {
  this.name = name;
}
Student.prototype.ver = {
  a: 1,
  b: 2
};
Student.prototype.hello = function () {
  console.log("hello", this.name);
}

let std1 = new Student("Alice");
let std2 = new Student("Sean");

// 给 std1 的 ver.a + 1
std1.ver.a = std1.ver.a + 1;

// 检查 std2 的 ver.a 是否也跟着变化
console.log(std2.ver.a);
// 2

可以看到实际输出结果,虽然变动是产生在 std1 上的,但是 std2 对应的值也发生了变化,所以 new 的结果仅是对第一层属性值的拷贝,即 浅拷贝

那么基于这个理解,也就可以手动实现 Object.create 函数了:

function objCreate (proto) {
  let protoObj = {};
  for (let key in proto) {
    protoObj[key] = proto[key];
  }
  return protoObj;
}

所以上一篇博文中有关手写 new 方法的代码可以继续改为:

function Student (name = "") {
  this.name = name;
}
Student.prototype.hello = function () {
  console.log("hello", this.name);
}

function newProto(proto, ...params) {
  // 原型拷贝
  function objCreate (proto) {
    let protoObj = {};
    for (let key in proto) {
        protoObj[key] = proto[key];
    }
    return protoObj;
  }

  // "拷贝"一份proto
  let obj = objCreate(proto.prototype);
  // 定义 __proto__ 指向
  obj.__proto__ = proto.prototype;
  // 定义 constructor 构造函数
  obj.constructor = proto;
  // 修改实例的 this 指向并实例化
  let res = proto.bind(obj)(...params);
  return res instanceof Object ? res : obj;
}

let std1 = new Student("Alice");
let std2 = newProto(Student, "Sean");

std1.hello();
// hello Alice
std2.hello();
// hello Sean

// 检查一下 std1 的原型与 std2 的原型是否一致
console.log(std1.__proto__ === std2.__proto__);
// true true

// 检查一下 std1 与 std2 是否都在 Student 的原型链上
console.log(std1 instanceof Student, std2 instanceof Student);
// true true

// 检查一下 std1 与 std2 是否为同一个构造函数
console.log(std1.constructor, std2.constructor, std1.constructor === std2.constructor);
// [Function: Student] [Function: Student] true

new 的本质

通过上面分析,可以看出,new 操作符的本质是对给定对象进行定制化拷贝,这里有两个概念需要区分一下,首先是对对象原型的拷贝,另一个是对对象的拷贝,举个例子:

function Student (name = "") {
  this.name = name;

  this.hello = function () {
    console.log("hello", this.name);
  }
}

假设有上述函数对象,Student 并没有定义 prototype ,此时获取 Student 的 prototype 将为空对象,所以通过 Object.create 来创建新对象的应该也是空的:

function Student (name = "") {
  this.name = name;

  this.hello = function () {
    console.log("hello", this.name);
  }
}

let std = Object.create(Student);
console.log(std);
// Function {}

但是,如果对当前的 Student 执行 new 操作,却是可以得到一个符合预期的结果:

function Student (name = "") {
  this.name = name;

  this.hello = function () {
    console.log("hello", this.name);
  }
}

let std = new Student();
console.log(std);
// Student { name: '', hello: [Function] }

// 使用手写的 newProto 方法,也得到了类似的实例
std = newProto(Student);
console.log(std)
// Student { constructor: [Function: Student], name: '', hello: [Function] }

分析到这里其实又带来了两个问题:

  • 使用 this 定义在 function 内部的属性和使用 prototype 定义在原型上的属性具体有什么区别?
  • newProto 方法又是怎样在 prototype 不存在的情况下创建出了一个符合预期的实例的?

本小结主要解释第二个问题,第一个问题计划在下一篇博文中仔细讲解。现在回顾一下,newProto 实例化给定对象的时候,是如何在没有 prototype 原型定义的情况下创建出了符合预期的对象的。这里离不开这句话,即对 this 的重定向:

...
// 修改实例的 this 指向并实例化,以下三种方式等价
// let res = proto.bind(obj)(...params);
// let res = proto.call(obj, ...params);
let res = proto.apply(obj, params);
...

使用 bind、call 或者 apply 修改 this 指向的时候,其实执行了一个构造函数,这个构造函数就是 function 本身,如果 call 和 apply 看的不明显,那么来看下 bind :

let res = proto.bind(obj)(...params);

这句话再明显不过了,bind 函数可以绑定新的他 this 指向,同时返回一个修改后的函数,上面这句话执行了这个函数,所以相当于执行了 Student() 函数,而 Student 函数是 constructor 指向的构造器,所以相当于变相的执行了 Student 函数,这才得到了真正的实例。如果要让 newProto 看起来和 new 的真实结果完全一致,可以通过检查对象是否存在 prototype 原型来实现,修改 newProto 函数如下:

// 定义 __proto__ 指向
function newProto(proto, ...params) {
  ...
  obj.__proto__ = proto.prototype;
  if (Object.keys(proto.prototype).length > 0) {
    // 定义 constructor 构造函数
    obj.constructor = proto;
  }
  ...
}

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注