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;
}
...
}