一般印象中的原型

原型真是个奇妙的东西。我现在已经工作了6个念头,依然可以学到一些东西。我不知道面试过多少人对原型一只半解,却觉得在面试中被问到原型是一件非常『丢份』的事情。有些人甚至没有办法说清楚原型链到底是怎么样的东西。

扯远了,我们来看看原型。

我们每创建一个函数,js引擎都会帮这个函数创造一个属于它自己的原型对象。

function Animal() {} Animal.prototype // { constructor: f Animal(), [[Prototype]]: Object} Animal.__proto__ == Function.prototype // true

在原型没有被修改过的前提下,我们可以认为,对象的__proto__指向哪里,该对象就是__proto__.constructor的实例。

这句话听起来可能比较绕口,我们来个例子;

function Animal() {} let dog = new Animal dog.__proto__.constructor == Animal // true Animal.__proto__.constructor == Function // true

现在看看,dog确实是 Animal的实例,这很好理解;而 Animal 是函数的实例,所以 它的 __proto__ 指向 Function.prototype 。我们常用的 apply 等方法就来自这里。

Function 的原型链

到目前为止都很简单,现在问题来了:

Function.__proto__.constructor == Function // true

这可就有点意思了:难不成 Function 是自己的实例?

如果你看过哪个著名的原型图,就应该可以发现 Function 的 __proto__指向极其不自然。一般来说,对象的 __proto__会指向原型,然后原型和其对应的函数有 prototypeconstructor 相互关联,差不多是这样的结构:

foo ----__proto__----> Foo.prototype ----constructor----> Foo

然而,对于函数,这一原则被打破了。

Function ----__proto__----> Function.prototype ----constructor----> Function

原型链

这个函数本来应该是另外的某个东西(通常对应构造函数)的实例,但是在这里却把这个任务直接丢给了 Function。这其实也很好理解,Function 本来就是定义函数用的,我们总不能用另一个函数来定义函数吧?

换句话说,Function是JS继承的一个特例!Function.prototype是一个特殊的对象。如果你把它打印出来,会发现它其实是一个函数。

深度解析原型中的各个难点

我们知道函数都是通过 new Function() 生成的,难道 Function.prototype 也是通过 new Function() 产生的吗?答案也是否定的,这个函数也是引擎自己创建的。首先引擎创建了 Object.prototype ,然后创建了 Function.prototype ,并且通过 __proto__ 将两者联系了起来。这里也很好的解释了上面的一个问题,为什么 let fun = Function.prototype.bind() 没有 prototype 属性。因为 Function.prototype 是引擎创建出来的对象,引擎认为不需要给这个对象添加 prototype 属性。

所以我们又可以得出一个结论,不是所有函数都是 new Function() 产生的。

有了 Function.prototype 以后才有了 function Function() ,然后其他的构造函数都是 function Function() 生成的。

现在可以来解释 Function.__proto__ === Function.prototype 这个问题了。因为先有的 Function.prototype 以后才有的 function Function() ,所以也就不存在鸡生蛋蛋生鸡的悖论问题了。对于为什么 Function.__proto__ 会等于 Function.prototype ,个人的理解是:其他所有的构造函数都可以通过原型链找到 Function.prototype ,并且 function Function() 本质也是一个函数,为了不产生混乱就将 function Function()__proto__ 联系到了 Function.prototype 上。

从这里我们可以知道,至少有两个东西是 JS 引擎『凭空』创造出来的,它们就是 Object.prototype.__proto__(null) 以及 function Function。然后,Function.prototype.__proto__ -> Object.Prototype。这不啻为 JS 原型原理的基石。

不可靠的 constructor

现在让我们看一个继承实现:

function Animal() {} Animal.prototype = { walk() { console.log('walk') } } function Dog() {} Dog.prototye = new Animal()

现在看看这个继承有什么问题?

如果我们打印 Dog.constructor,会发现输出是 Object!

这是怎么回事?难道不应该是 Animal 吗?

这就是 constructor 不可靠的地方了。之前说了,当我们创建函数的时候,js引擎将帮我们创建一个 prototype。此时这个prototype里有一个constructor属性,在这个例子中指向Animal

但是,我们在上面的代码中,直接替换了prototype。替换的对象没有constructor。于是,根据原型链规则,找到了 Object.prototype.constructor。这便是原型链的破环。

所以,我们不应该直接替换原型链对象。如果这么做了,应给重新赋予constructor属性。

另:constructor 是非引用类型时是只读的。这个鸡肋限制实在是很有槽点

更多的缺点和较好的写法

再观察一下上面的继承实现,假设 Animal 需要接受参数,这种方式就无法处理了。如果我们用的是 Class ,则可以通过 super 传递。

优化的写法:

function Animal(name) { this.name = name } Animal.prototype = { walk() { console.log('walk') } } function Dog(name) { Animal.call(this, name) } Dog.prototype = new Animal() Dog.prototype.constructor = Dog

它可以解决上述问题,同时重新指定了 constructor . 但是父函数,在此例中也就是 Animal ,被调用了两次。

最终的写法:

function Animal(name) { this.name = name } Animal.prototype = { walk() { console.log('walk') } } function Dog(name) { Animal.call(this, name) } Dog.prototype = Object.create(Animal.prototype) Dog.prototype.constructor = Dog

es6 的 Class 会被 babel 编译成什么样?

可以直接在 babel 的官网试试看。target: ie11

class Foo { static a = 'a' static b () {} constructor() { this.foo = 42; } bar () { console.log('bar') } get b () { return this.a } } const foo = new Foo();

编译后:

"use strict"; // 一个检查。显然 class 虽然被编译成了 function ,但是不能直接调用,只能用 `new` 调用 function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } // 这是一个工具函数。用来给对应的对象增加属性。它会遍历 props ,依次声明 // definedProperty 大家应该都不陌生了。这里需要注意 enumerable 默认为 false function _defineProperties(target, 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(target, descriptor.key, descriptor); } } // 这是真正处理构造函数的地方。可以看到静态属性被定义在了 Function 上,其它属性被定义在了原型上。 function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; } // 另一个定义属性的方法。在定义非函数的静态属性时会用到 function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } var Foo = /*#__PURE__*/ (function () { function Foo() { // constructor 就是这个函数的内容 _classCallCheck(this, Foo); this.foo = 42; // console.log('hi') } // 接着,静态方法被写入函数,方法则被写入 prototype _createClass(Foo, [ { key: "bar", value: function bar() { console.log("bar"); } // get b () { return this.a } } ], [ { key: "b", value: function b() {} } ]); return Foo; })(); // 最后,静态属性被写入 Foo _defineProperty(Foo, "a", "a");

这样处理之后,和普通的构造函数有什么不同呢?

  1. class 所有的方法(包括静态方法和实例方法)都是不可枚举的。
  2. 对 Foo 的 prototype 赋值是无效的。
  3. 由于 Foo 被包裹在一个 IIFE 里面,所以虽然会变量提升,但是不会赋值。(准确来说是存在一个临时死区,类似 let)。
  4. 在函数里对 Foo 进行赋值是无效的。
  5. class 只能用 new 调用

接下来,我们看看继承。

class Foo { constructor() { this.foo = 42; } bar () { console.log('bar') } } class Sub extends Foo { constructor() { super() super.bar() } } const foo = new Foo(); const sub = new Sub()

编译后:

"use strict"; function _typeof(obj) { // ... 为了正确返回 Symbol 做的 hack,基本等于 typeof } // 真正的继承 function _inherits(subClass, superClass) { // 首先是一个校验 if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } // 然后创建一个父类原型的实例,并进行原型链接 subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); // 一样这是不可重新赋值的 Object.defineProperty(subClass, "prototype", { writable: false }); // 赋予 __proto__. 那之后,Sub.__proto__ === Foo if (superClass) Object.setPrototypeOf(subClass, superClass); } // Derived 这里是 Sub。 function _createSuper(Derived) { // 调用 Super 时,即调用此方法. 调用时指定了 this 为 Sub 实例。 // 我们也可以看到,调用结果也是一个子类实例 return function _createSuperInternal() { // Super: Foo var Super = Object.getPrototypeOf(Derived), result; // this: <Sub>{},NewTarget: Sub Function var NewTarget = Object.getPrototypeOf(this).constructor; result = Super.apply(this, arguments); // 这里省略了校验代码 return result; }; } function _classCallCheck(instance, Constructor) { // ... } function _defineProperties(target, props) { // ... } function _createClass(Constructor, protoProps, staticProps) { // ... } var Foo = /*#__PURE__*/ (function () { // ... })(); var Sub = /*#__PURE__*/ (function (_Foo) { _inherits(Sub, _Foo); // <Sub>{} var _super = _createSuper(Sub); function Sub() { var _thisSuper, _this; _classCallCheck(this, Sub); _this = _super.call(this); // 对应 Super.bar Reflect.get.apply( (_thisSuper = _this), // Foo.prototype _getPrototypeOf(Sub.prototype)), "bar", _thisSuper ).call(_thisSuper); return _possibleConstructorReturn(_this); } return _createClass(Sub); })(Foo); var foo = new Foo(); var sub = new Sub();

这里的代码有点复杂,不过重点只有一个:ES6 Class,子类构造函数使用 __proto__指向了父类的构造函数。调用Super时,使用原型链找到父类的原型,从中取值。

class Foo { static staticValue = 43 constructor() { this.foo = 42; } bar () { console.log('bar') } } class Sub extends Foo { constructor() { console.log(super().foo) // 42 console.log(super.foo) // undefined console.log(super.staticValue) // undefined console.log(super.bar()) // 'bar' } } const foo = new Foo(); const sub = new Sub() console.log(Sub.__proto__===Foo) // true

关于原生构造函数

这是一个被广为人知的区别。使用 es5 的经典继承方式无法继承内置构造函数,es6class 则可以。然而,生产环境中的 class 大抵是转换成 es5 运行的。所以,这其实依然是代码层面的变化。

然而,真的是这样的吗?究竟是什么让经典继承无法继承内置构造函数?如果我们仔细观察,就会发现是这一句的作用:

Parent.call(this, args)

也就是在子类构造函数中,调用父类构造函数的这一步。

内置类型常常有严格的检查,如果是非原生构造函数对象调用原生构造函数方法会报错。

继承 String 失败

很多文章宣称这是因为 this 声明顺序的问题,这其实是值得商榷的。比如,我们可以尝试继承 Array

继承Array成功

当然,一般提出的 this 差别是没有问题的:

ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this。

所以如果我们正在构造我们自己的构造函数,那么我们必须调用 super,否则具有 this 的对象将不被创建,并报错。

从源码中,我们可以看到,调用 super后,其中被赋值给了 _this 。我们在代码中进行的操作,实际上是对 _this 的操作。同时,class 确实可以解决原生构造函数的继承问题。