前言
ES6 新增了 class 语法,但是不同于传统面向对象编程(OOP)类的实现,其本质上是基于原型实现的语法糖,本文主要介绍其中的实现原理。
类必须通过 new 实例化
类是通过调用构造函数来创建对象的,在 JavaScript 中构造函数与普通函数的区别主要有以下两点:
- 约定构造函数名必须大写,可以很好的与普通函数区别开发,增强代码的可读性
- 构造函数必须通过 new 关键字调用
为了确保构造函数是通过 new 关键字调用,可以通过 「instanceof 运算符来检测当前实例的原型链上是否包含该构造函数的 prototype 属性」:
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
要理解这里为什么可以通过 instanceof 运算符来实现,需要理解以下两点:
原型链
「原型链是 JavaScript 中查找对象属性的一种机制,在一定程度上使得对象之间产生联系。」
当访问一个对象的属性时,JavaScript 引擎首先会查找该对象自身是否包含该属性,如果没有,则会查找该对象 [[Prototype]] 属性指向的原型对象,直到找到这个属性或者 [[Prototype]] 属性为 null 时,终止查找。
而 [[Prototype]] 属性对于开发者是透明的,在 ES6 之前,部分浏览器实现了 「proto」 私有属性来读取和设置 [[Prototype]] 属性的指向。
ES6 之后可以使用更友好的 Object.getPrototypeOf 和 Object.setPrototypeOf 方法来替代,但是对于更改对象的 [[Prototype]] 属性是一个影响性能的操作,应该避免这样的操作。
new 运算符
细心的同学会发现:通常构造函数上都会携带 Prototype 属性,那么这个属性和原型链是什么关系呢?
要想解决上述疑问,就需要理解 new 运算符的执行过程。
function $new(Constructor, ...restArgs) {
// 创建新对象
const instance = Object.create(null);
// 设置 [[prototype]] 属性
instance.__proto__ = Constructor.prototype;
// 执行构造函数并且绑定 this
const result = Constructor.apply(instance, restArgs);
// 返回对象
return result instanceof Object ? result : instance;
}
new 运算符在执行构造函数的过程中默默地做了很多事情,「而更改 instance(也就是构造函数中的 this) 的 [[Prototype]] 属性就是能够通过 instanceof 来判断当前构造函数的调用方式的原因。」
模拟实现 instanceof
理解上述两个知识点之后,可以通过模拟 instanceof 运算符来更好的理解其查找的过程:
function instance_of(instance, constructor) {
let currentPrototype = Object.getPrototypeOf(instance);
while(currentPrototype !== null) {
if (currentPrototype === constructor.prototype) {
return true;
}
currentPrototype = Object.getPrototypeOf(currentPrototype);
}
return false;
}
借用构造函数
「借用构造函数主要解决实例属性的继承问题。」
对于类的每一个实例应该有属于自己的一份实例属性副本,这样才能保证实例的隔离性。
function SuperClass(name) {
this.name = name;
}
这里利用 call 方法改变父类构造函数执行时的 this 指向,从而将父类构造函数中的实例属性赋值到指定的实例上:
function SubClass(name, color) {
const _this = SuperClass.call(this, name) || this;
_this.color = color;
return _this;
}
❝如果这里不显式的改变 this 的指向,那么在非严格模式下,SuperClass 构造函数中的实例属性会复制到 window 上面。
❞
原型链
「对于类的成员函数则是采用基于原型的继承方式,这样每个实例可以共享方法的引用,最大限度地节约了内存。」
在构造函数的 prototype 属性上添加方法有很多种方式:
SuperClass.prototype.sayHi = function () {}
SuperClass.prototype.xxx = function() {}
这种方式虽然可读性极高,「但是冗余的 SuperClass.prototype 不免让人看了觉得不够优雅,并且不支持 setter 和 getter 方法的设置。」
为了解决上述问题,你可能会想到这种方式:
SuperClass.prototype = {
sayHi: function() {},
get xxx() {},
set xxx() {}
}
利用对象字面量的确解决了上述问题,但是它重新赋值了 prototype 属性,只能在恰当的时机使用一次,否则会造成预期之外的 Bug。
解决上述问题的最佳实践:
- 约定 JSON Schema
- Object.defineProperty
function _createClass(Constructor, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(Constructor.prototype, descriptor.key, descriptor);
}
}
_createClass(SuperClass , [
{
key: 'sayHi',
value: function sayHi() {}
},
{
key: 'xxx',
get: function get() {},
set: function set() {}
}
])
子类继承这些共享方法时,只需要将子类构造函数的 prototype 属性关联到父类构造函数的 prototype 即可以利用原型链查找的机制达到方法共享的目的,并且在子类构造函数的 prototype 上定义同名方法,提前终止原型链的搜索,从而完成父类方法的重写。
但是这个关联操作非常地讲究,也就是下面要介绍的原型继承
原型继承
原型继承主要解决的问题是:高效且无副作用地实现子类构造函数 prototype 属性和父类构造函数 prototype 属性的关联。
直接赋值引用
SubClass.prototype = SuperClass.prototype;
「这种直接引用父类构造函数 prototype 属性的行为存在副作用。」
当子类构造函数在 prototype 属性上修改属性时,相当于直接修改父类构造函数的 prototype 属性,会对父类以及其他子类造成预期之外的影响。
修改 [[prototype]]
「为了让子类不能直接操作父类构造函数的 prototype 属性,可以采用直接更改 [[prototype]] 的方式完成关联操作。」
ES6 之前,可以采用浏览器实现的私有 API:
SubClass.prototype.__proto__ = SuperClass.prototype;
ES6 可以直接利用新增的原型指定方法:
Object.setPrototypeOf(SubClass.prototype, SuperClass.prototype);
但是「更改对象的 [[Prototype]] 在各个浏览器和 JavaScript 引擎上都是一个很慢的操作」,不建议直接更改,应该采用创建一个带有指定 [[Prototype]] 属性的新对象的方式来替代。
new 运算符
前文提到的 new 运算符就可以快速地得到一个带有指定 [[Prototype]] 属性的对象:
SubClass.prototype = new SuperClass();
这种方式同样不完美,new 运算符生成的实例是包含构造函数中的实例属性的,而这些实例属性对于 SubClass.prototype 是无用的。
原型继承
为了解决 new 运算符的缺陷,只需要将父类构造函数替换成一个不包含实例属性的构造函数即可:
function create(prototype) {
const F = function() {};
F.prototype = prototype;
return new F();
}
SubClass.prototype = create(SuperClass.prototype);
寄生式继承
上述提到的原型继承,除了 Object.setPrototypeOf 方式,其它的方式都存在 prototype.constructor 指向不正确的问题。
这时就需要修正子类构造函数 prototype 上的 constructor 属性的指向,而寄生式继承就是一种增强对象的方式。
function inheritPrototype(SubClass, SuperClass) {
const prototype = create(SuperClass.prototype);
Object.defineProperty(prototype, 'constructor', {
value: subClass, writable: true, configurable: true
})
SubClass.prototype = prototype;
}
利用 ES5 的 Object.create 方法可以简化上述寄生式继承函数:
subClass.prototype = Object.create(superClass.prototype, {
constructor: { value: subClass, writable: true, configurable: true }
});
总结
以上就是 JavaScript 模拟类行为涉及到的知识点,在实际的开发过程中,一般都是利用 Babel 或者 TypeScript 来转译 class 语法,他们转化的方式实际上就是目前社区中实现类的最佳实践,本文也是基于其转译代码的分析,感兴趣的读者可以自行研究。
参考资料
- 《JavaScript高级程序设计(第三版)》
- 《你不知道的 JavaScript 上下卷》
- Babel 和 TypeScript 转译代码
本文使用 mdnice 排版