前言
起因是一道面试题:
es6 class 与普通构造函数的区别?
有点好奇别人会怎么答,网上随便搜了个答案:
1.类的内部所有定义的方法,都是不可枚举的(但是在es5中prototype的方法是可以进行枚举的)
2.类的构造函数,不使用new是没法调用的,会报错。这是它跟普通构造函数的一个主要区别,后者不用new也可以执行
3.Class不存在变量提升(hoist),这一点与ES5完全不同
4.ES5的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6的继承机制完全不同,实质是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this。
1、2 点说的倒是没有错误,从第 3 点开始就离谱……
于是本着技术人的探究精神,查阅了很多资料,总结出了这么一篇文章。
Class 的”变量提升”
实际上,无论是 class
声明还是 class
表达式,都是存在变量提升的。
与let
、const
类似,使用class
声明的类也会被提升,然后这个类声明会被保存在词法环境中但处于未初始化的状态,直到 runtime 执行到变量赋值那一行代码,才会被初始化。另外,class
声明的类一样存在**暂时性死区(TDZ)**。看例子:
var Person = function () {
this.name = "noName";
};
{
let peter = new Person("Peter", 25); //Uncaught ReferenceError: Cannot access 'Person' before initialization
console.log(peter);
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
}
如果没有提升,Peter
会是块作用域外的Person
实例。但是由于提升的关系,块作用域内的Person
遮蔽了外层的同名函数。
当然会这么理解完全是 JavaScript 不严谨的锅。直到 ECMAScript® 2015 Language Specification 之前的JavaScript文档中找不到变量提升(Hoisting)这个词。甚至
let
、const
有没有提升都争论了很久。在 Javascript 中所有声明的变量(
var
,const
,let
,function
,class
)都存在变量提升的情况。使用var
声明的变量,在词法环境中会被初始化为undefined
,但用let
和const
声明的变量并不会被初始化。实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中,更准确的说,是词法环境中。变量提升是现象,而不是原因。
es5 VS es6
b 话不多说,上手就是干。
es6
先上个测试用的代码:
let b = [];
class Parent {
constructor(name) {
debugger
this.name = name;
debugger
b.push(this);
}
getName() {
console.log(this.name);
}
}
class Child extends Parent {
constructor(name, age) {
debugger
super(name);
debugger
this.age = age;
}
getAge() {
console.log(this.age);
}
}
var child1 = new Child("a", 1);
debugger
console.log(b[0] === child1); // true
好戏开始:
意料之中,在调用 super
前,子类构造函数中的 this
为 undefined
。
在 JavaScript 中,继承类(所谓的“派生构造器”,英文为 “derived constructor”)的构造函数与其他函数之间是有区别的。派生构造器具有特殊的内部属性
[[ConstructorKind]]:"derived"
。这是一个特殊的内部标签。该标签会影响它的
new
行为:
- 当通过
new
执行一个常规函数时,它将创建一个空对象,并将这个空对象赋值给this
。- 但是当继承的 constructor 执行时,它不会执行此操作。它期望父类的 constructor 来完成这项工作。
继续:
this
突然有值了,而且还是 Child
的实例。
name
属性成功修改。
现在应该回到子类的构造函数了:
原本是 undefined
的 this
变成了这个实例对象。看来 super
方法将 this
传递了下来。
new
结束后的情况:
console.log(b[0] === child1); // true
child1
与整个流程中 this
指代的实例对象引用了同一个内存空间。(new
返回的就是 this
指代的这个对象,跟 class
没关系)
所以,child1
确确实实就是我们需要的 Child
实例,沿着原型链可以访问子类和父类中的属性和方法。class
在实现的结果上的确是无可挑剔。
es5
都说 class 是语法糖,本质上 class 应该是通过 es5 也能实现的,那么 es5 中又是怎么做的?
上 es5 版本:
let b = []
function Parent2(name) {
debugger
this.name = name;
b.push(this)
}
Parent2.prototype.getName = function () {
console.log(this.name);
};
function Child2(name, age) {
debugger
Parent2.call(this, name);
this.age = age;
}
Child2.prototype = new Parent2();
debugger
Child2.prototype.constructor = Child2;
Child2.prototype.getAge = function () {
console.log(this.age);
};
debugger
var child2 = new Child2("b", 2);
debugger;
console.log(b[0] === child2.__proto__); // true
为方便观察,new
一个 Parent2
实例,以作为 Child2
子类的原型对象。
Parent2
的实例上还什么都没有。(getName
方法是属于 Parent2
的原型对象的)
此时 new
结束,Child2
的原型对象被指定为 Parent2
实例。这样就依靠原型链完成了继承。不过 constructor
属性还没修改,这里通过Child2.prototype.constructor = Child2
修正。
此时 Child2
的原型对象还什么都没挂载。
在 new
一个 Child2
实例之前看看发生了什么:
Child2
的原型对象也跟着改变了,挂载了新方法 getAge()
,并且其 constructor
属性指向子类 Child2
。
继续,new
一个 Child2
实例:
与class
不同的是,虽然同为子类,但在Child2
构造函数中this
一开始就是有值的,而且是 Child2
的实例。
调用 call
方法进入父类的构造函数:
new
结束:
console.log(b[0] === child2.__proto__); // true
因为 new
出来的实例的__proto__
指向构造函数的 prototype
(之前 child2
的构造函数的原型指向已经被我们修改了:Child2.prototype = new Parent2()
)
也就是说,child2
的原型指向我们之前 new
出来的 Parent2
的实例(它就像一个中间人,是子类Child2
的原型对象同时也是父类Parent2
的实例,从而实现了子类和父类的链接)。所以child2
也可以沿着原型链找到父类的属性和方法。
揭开迷雾
回到答案,找找看有什么错误:
4.ES5的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6的继承机制完全不同,实质是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this。
上面这段话其实出自阮一峰老师的ECMAScript 6 入门。emmmmm,反正我第一眼看是不太理解的。(也可能是阮一峰老师表达上有误)
this?
在es5 中,在子类的构造函数中 this
是子类的实例对象,之后使用 call
调用父类构造函数,这一步是没问题的。但是,父类的方法(更准确的说是父类原型上的方法,比如上个例子中的 getName()
)是属于父类的原型对象的。在Child2.prototype = new Parent2()
执行后就已经在子类的原型链上可访问了。子类传递的 this
实际上只做了添加属性的工作(用父类构造函数初始化了 name
属性),也就是说,继承和 this
是没有直接关系的(这里能继承到父类的属性完全是 call
的作用)。
在 es6 中,所谓的 “先创造父类的实例对象 this
” 描述不太准确,它确实是在父类中被创建,但是被创建的其实还是子类的实例(它的__proto__
指向子类的prototype
),因为new.target
并没有改变,始终是new
关键字后跟的那个函数。
super?
从我们上面的分析来看,想必是super()
绑定了this
,继续看super
的行为:
简化后的流程图:
newTarget
是new.target
指向的函数
func
是父类的构造函数继续用这些参数调用
Construct
调用
Construct
是递归的,直到当kind
为base
或者某一函数有明确的返回值才会终止。
基于这点我们再来分析一波:
子类的构造函数的 this
是不会赋值为子类的实例对象的(一开始是空对象),而是到父类的构造函数中做这一步。super
在这个过程中起到很大作用(当然super
还有其他的魔法)。super
实际上只是递归地调用父类构造函数,这个过程中会将最终得到的实例(这个实例始终是 new
关键字后的类的实例)赋值给 this
然后 return
出来。
es6 的实现没有黑魔法,不能凭空变出来一个继承父类的子类实例。那么es6 的继承是靠的什么?
不得不说的 new
new
做了什么?
- 创建了一个全新的对象。
- 这个对象会被执行
[[Prototype]]
(也就是__proto__
)链接。 - 生成的新对象会绑定到函数调用的
this
。(如果是 es6 子类则不会) - 通过
new
创建的每个对象将最终被[[Prototype]]
链接到这个函数的prototype
对象上。 - 如果函数没有返回对象类型
Object
(包含Functoin
,Array
,Date
,RegExg
,Error
),那么new
表达式中的函数调用会自动返回这个新的对象。
跟 new
类似的:
ES5提供的 Object.create
Object.create(proto, [propertiesObject])
方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__
。 它接收两个参数,不过第二个可选参数是属性描述符(不常用,默认是undefined
)。
对于不支持
ES5
的浏览器,MDN
上提供了ployfill
方案。 MDN Object.create()
// 简版:也正是应用了new会设置__proto__链接的原理。
if(typeof Object.create !== 'function'){
Object.create = function(proto){
function F() {}
F.prototype = proto;
return new F();
}
}
ES6提供的 Object.setPrototypeOf
Object.setPrototypeOf()
方法设置一个指定的对象的原型 ( 即内部[[Prototype]]
属性)到另一个对象或 null
。 Object.setPrototypeOf(obj, prototype)
`ployfill`
// 仅适用于Chrome和FireFox,在IE中不工作:
Object.setPrototypeOf = Object.setPrototypeOf || function (obj, proto) {
obj.__proto__ = proto;
return obj;
}
nodejs
源码就是利用这个实现继承的工具函数的。 nodejs utils inherits
function inherits(ctor, superCtor) {
if (ctor === undefined || ctor === null)
throw new ERR_INVALID_ARG_TYPE('ctor', 'Function', ctor);
if (superCtor === undefined || superCtor === null)
throw new ERR_INVALID_ARG_TYPE('superCtor', 'Function', superCtor);
if (superCtor.prototype === undefined) {
throw new ERR_INVALID_ARG_TYPE('superCtor.prototype',
'Object', superCtor.prototype);
}
Object.defineProperty(ctor, 'super_', {
value: superCtor,
writable: true,
configurable: true
});
Object.setPrototypeOf(ctor.prototype, superCtor.prototype);
}
关键的 extends
ES6 的 extends 继承做了什么操作?
我们先看看这段包含静态方法的ES6
继承代码:
// ES6
class Parent{
constructor(name){
this.name = name;
}
static sayHello(){
console.log('hello');
}
sayName(){
console.log('my name is ' + this.name);
return this.name;
}
}
class Child extends Parent{
constructor(name, age){
super(name);
this.age = age;
}
sayAge(){
console.log('my age is ' + this.age);
return this.age;
}
}
let parent = new Parent('Parent');
let child = new Child('Child', 18);
console.log('parent: ', parent); // parent: Parent {name: "Parent"}
Parent.sayHello(); // hello
parent.sayName(); // my name is Parent
console.log('child: ', child); // child: Child {name: "Child", age: 18}
Child.sayHello(); // hello
child.sayName(); // my name is Child
child.sayAge(); // my age is 18
其实这段代码里有两条原型链。
// 1、构造器原型链
Child.__proto__ === Parent; // true
Parent.__proto__ === Function.prototype; // true
Function.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true
// 2、实例原型链
child.__proto__ === Child.prototype; // true
Child.prototype.__proto__ === Parent.prototype; // true
Parent.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true
一图胜千言:
结合代码和图可以知道。 ES6 extends
继承,主要就是:
- 把子类构造函数(
Child
)的原型(__proto__
)指向了父类构造函数(Parent
),**(所以super
能找到父类构造函数)** - 把子类实例
child
的原型对象(Child.prototype
) 的原型(__proto__
)指向了父类parent
的原型对象(Parent.prototype
)。
//和之前实验中的继承方式比较
child2.__proto__ === Child2.prototype // true
new Parent2().__proto__ === Parent2.prototype // true
Child2.prototype = new Parent2(); // 在上图中相当于把Child.prototype和实例(Parent{})连起来
//在效果上等同于下面的方式,不过更耗内存,一般情况不推荐。
//es6 extends的继承方式
Child2.prototype.__proto__ = Parent2.prototype; //在上图中相当于直接把Child.prototype和Parent.prototype连起来
extends的 ES5 版本实现
知道了ES6 extends 继承做了什么操作和设置__proto__
的知识点后,把上面ES6
例子的用ES5
就比较容易实现了,也就是说实现寄生组合式继承,简版代码就是:
// ES5 实现ES6 extends的例子
function Parent(name){
this.name = name;
}
Parent.sayHello = function(){
console.log('hello');
}
Parent.prototype.sayName = function(){
console.log('my name is ' + this.name);
return this.name;
}
function Child(name, age){
// 相当于super
Parent.call(this, name);
this.age = age;
}
// new
function object(){
function F() {}
F.prototype = proto;
return new F();
}
function _inherits(Child, Parent){
// Object.create
Child.prototype = Object.create(Parent.prototype);
// __proto__
// Child.prototype.__proto__ = Parent.prototype;
Child.prototype.constructor = Child;
// ES6
// Object.setPrototypeOf(Child, Parent);
// __proto__
Child.__proto__ = Parent;
}
_inherits(Child, Parent);
Child.prototype.sayAge = function(){
console.log('my age is ' + this.age);
return this.age;
}
var parent = new Parent('Parent');
var child = new Child('Child', 18);
console.log('parent: ', parent); // parent: Parent {name: "Parent"}
Parent.sayHello(); // hello
parent.sayName(); // my name is Parent
console.log('child: ', child); // child: Child {name: "Child", age: 18}
Child.sayHello(); // hello
child.sayName(); // my name is Child
child.sayAge(); // my age is 18
我们完全可以把上述ES6的例子
通过babeljs 转码成ES5
来查看,更严谨的实现。
// 对转换后的代码进行了简要的注释
"use strict";
// 主要是对当前环境支持Symbol和不支持Symbol的typeof处理
function _typeof(obj) {
if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
_typeof = function _typeof(obj) {
return typeof obj;
};
} else {
_typeof = function _typeof(obj) {
return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
};
}
return _typeof(obj);
}
// _possibleConstructorReturn 判断Parent。call(this, name)函数返回值 是否为null或者函数或者对象。
function _possibleConstructorReturn(self, call) {
if (call && (_typeof(call) === "object" || typeof call === "function")) {
return call;
}
return _assertThisInitialized(self);
}
// 如果 self 是void 0 (undefined) 则报错
function _assertThisInitialized(self) {
if (self === void 0) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return self;
}
// 获取__proto__
function _getPrototypeOf(o) {
_getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) {
return o.__proto__ || Object.getPrototypeOf(o);
};
return _getPrototypeOf(o);
}
// 寄生组合式继承的核心
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function");
}
// Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。
// 也就是说执行后 subClass.prototype.__proto__ === superClass.prototype; 这条语句为true
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
writable: true,
configurable: true
}
});
if (superClass) _setPrototypeOf(subClass, superClass);
}
// 设置__proto__
function _setPrototypeOf(o, p) {
_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {
o.__proto__ = p;
return o;
};
return _setPrototypeOf(o, p);
}
// instanceof操作符包含对Symbol的处理
function _instanceof(left, right) {
if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) {
return right[Symbol.hasInstance](left);
} else {
return left instanceof right;
}
}
function _classCallCheck(instance, Constructor) {
if (!_instanceof(instance, Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
// 按照它们的属性描述符 把方法和静态属性赋值到构造函数的prototype和构造器函数上
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);
}
}
// 把方法和静态属性赋值到构造函数的prototype和构造器函数上
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
return Constructor;
}
// ES6
var Parent = function () {
function Parent(name) {
_classCallCheck(this, Parent);
this.name = name;
}
_createClass(Parent, [{
key: "sayName",
value: function sayName() {
console.log('my name is ' + this.name);
return this.name;
}
}], [{
key: "sayHello",
value: function sayHello() {
console.log('hello');
}
}]);
return Parent;
}();
var Child = function (_Parent) {
_inherits(Child, _Parent);
function Child(name, age) {
var _this;
_classCallCheck(this, Child);
// Child.__proto__ => Parent
// 所以也就是相当于Parent.call(this, name); 是super(name)的一种转换
// _possibleConstructorReturn 判断Parent.call(this, name)函数返回值 是否为null或者函数或者对象。
_this = _possibleConstructorReturn(this, _getPrototypeOf(Child).call(this, name));
_this.age = age;
return _this;
}
_createClass(Child, [{
key: "sayAge",
value: function sayAge() {
console.log('my age is ' + this.age);
return this.age;
}
}]);
return Child;
}(Parent);
var parent = new Parent('Parent');
var child = new Child('Child', 18);
console.log('parent: ', parent); // parent: Parent {name: "Parent"}
Parent.sayHello(); // hello
parent.sayName(); // my name is Parent
console.log('child: ', child); // child: Child {name: "Child", age: 18}
Child.sayHello(); // hello
child.sayName(); // my name is Child
child.sayAge(); // my age is 18
继承的缺陷
ES5 继承存在的问题
回到_possibleConstructorReturn
函数。
function _possibleConstructorReturn(self, call) {
if (call && (_typeof(call) === "object" || typeof call === "function")) {
return call;
}
return _assertThisInitialized(self);
}
// 如果 self 是void 0 (undefined) 则报错
function _assertThisInitialized(self) {
if (self === void 0) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return self;
}
设想如下场景,在ES5中,继承Array
构造函数。
那么在我们的ES5实现中,我们调用Array.call(this, args)
,然而,这样调用不会对this
作出任何修改,因为Array
会忽略传入的this
,这样我们 new
出来的就是一个空对象。
function MyArr() {
Array.call(this, arguments);
}
MyArr.prototype = Object.create(Array.prototype, {
constructor: {
value: MyArr,
writable: true,
configurable: true,
enumerable: true,
},
});
const arr = new MyArr();
arr[0] = "JS";
console.log(arr.length); // 0
当我们想用ES5继承一个内置类型如Array
类型时我们会丢失一些特殊的行为,例如length
属性与数组中存储的元素个数的绑定,因为我们的实例并不是使用Array
创建的。
但是我们也知道,ES5中这两种调用方式其实是一样的。
Array(5) // [empty × 5]
new Array(5) // [empty × 5]
而在上一节对new
的分析中我们知道构造函数其实可以指定自己的返回值的,所以当遇到这种有明确返回值的父类,babel
的做法是用父类的返回值替换子类的实例,这样就避免了出现上文所说的丢失特殊行为的现象。
但是这样我们的继承就无效了,因为返回的不是this
,因此子类原型链上的东西全部丢失了。
ES6如何解决这一问题
函数对象之所以被称为函数对象,是因为在内部有[[call]]
和[[construct]]
两个方法,在 ES5 的做法中,对父类构造函数其实调用的是[[call]]
,而在ES6中,如果你仔细看了前文,就会发现super
关键字调用的是[[construct]]
。
这两个调用的最显著区别就是new.target
的值(与环境变量有关)
- 通过
[[call]]
调用构造函数,构造函数内部的new.target
为undefined
- 通过
[[construct]]
调用构造函数,构造函数内部的new.target
为new
关键字调用的那个函数
上文也说了创建的实例的__proto__
是根据new.target
确认的,所以当以ES6的方式(super
)来继承Array
时,在我们碰不到的Array
构造函数内部完成了原型链的处理,而这是polyfill
无法做到的事。
此时,再回想一下_possibleConstructorReturn
这个函数的行为,实在是一种无奈之举啊。
后记
水平有限,文中难免有错误,欢迎指正。
一直在准备面试太忙了,摸了好久才写出来这么一篇寒碜的东西,精力有限还有好多重点没有介绍(比如super
、[[HomeObject]]
、实时委托模型等等)
真的很感谢前端领域的大佬们,在查资料的过程中也能明显感觉到自己对知识点有了更深层次的理解,希望以后也能坚持下去。
还有一点体会就是,对于人云亦云的观点,我们也应该有质疑精神,尤其是 IT 领域,没有所谓绝对正确的答案。
- Post link: https://blog.sticla.top/2021/08/14/class-and-this/
- Copyright Notice: All articles in this blog are licensed under unless otherwise stated.
GitHub Issues