乐趣区

你真的理解JS的继承了吗

噫吁嚱,js 之难,难于上青天

学习 js 的这几年,在原型链和继承上花了不知道多少时间,每当自以为已经吃透它的时候,总是不经意的会出现各种难以理解的幺蛾子。也许就像 kyle 大佬说的那样,js 的继承真的是‘蠢弟弟’设计模式吧。

本文小纲介绍

  • es5 寄生组合继承
  • es6 的 class … extends … 继承
  • kyle 大佬倡导的行为委托
阅读本文之前先约定,本文中称 __proto__ 为 内置原型,称 prototype 为 原型对象,构造函数 SubType 和 SuperType 分别称为子类和父类。

请先看下图,如果各位觉得 soeasy,请直接 插队这里。

es5 寄生组合继承

function SuperType(name){
  this.name = name;
  this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){alert(this.name);
};
function SubType(name, age){SuperType.call(this, name);
  this.age = age;
}
SubType.prototype = Object.create(SuperType.prototype, {
  constructor: {
    value: SubType,
    enumerable: false,
    writable: true,
    configurable: true
  }
})
SubType.prototype.sayAge = function(){alert(this.age);
}; 
let instance = new SubType('gim', '17');
instance.sayName(); // 'gim'
instance.sayAge(); // '17'
  1. 首先,这段代码声明了父类 SuperType
  2. 其次,声明了父类的原型对象方法 sayName
  3. 再次,声明了子类 SubType,并在未来将要新创建的 SubType 实例环境上调用父类 SuperType.call,以获取父类中的 namecolors 属性。
  4. 再次,用 Object.create() 方法把子类的原型对象上的内置对象 __proto__ 指向了父类的原型对象,并把子类构造函数重新赋值为子类。
  5. 然后,给子类的原型对象上添加方法 sayAge。
  6. 最后初始化实例对象 instance。(调用new SubType('gim', '17') 的时候会生成一个 __proto__ 指向 SubType.prototype 的空对象,然后把 this 指向这个空对象。在添加完 name、colors、age 属性之后,返回这个‘空对象’,也就是说 instance 最终就是这个‘空对象’。)

此时,代码中生成的原型链关系如下图所示(下面三张图撸了一下午,喜欢的帮忙点个赞谢谢):

  • 子类的原型对象的 __proto__ 指向父类的原型对象。 图中有两种颜色的带箭头的线,红色的线是我们生成的实例的原型链,是我们之所以能调用到 instance.sayName()instance.sayAge() 的根本所在。当调用 instance.sayName() 的时候,js 引擎会先查找 instance 对象中的自有属性。未找到 sayName 属性,则继续沿原型链查找,此时 instance 通过内置原型 __proto__ 链到了 SubType.prototype 对象上。但在 SubType.prototype 上也未找到 sayName 属性,继续沿原型链查找,此时 SubType.prototype__proto__链到了 SuperType.prototype 对象上。在对象上找到了 sayName 属性,于是查找结束,开始调用。因此调用 instance.sayName() 相当于调用了 instance.__proto__.__proto__.sayName(),只不过前者中sayName 函数内 this 指向 instance 实例对象,而后者 sayName 函数内的 this 指向了 SuperType.prototype(instance.__proto__.__proto__ === SuperType.prototype) 对象。
  • 在 es5 的实现中,子类的 __proto__ 直接指向的是 Function.prototype 黑色的带箭头的线则是 es5 继承中产生的‘副作用’,使得所有的函数的 __proto__ 指向了 Function.prototype,并最终指向 Object.prototype,从而使得我们声明的函数可以直接调用 toString(定义在 Function.prototype 上)、hasOwnProperty(定义在 Object.prototype 上) 等方法,如:SubType.toString()、SubType.hasOwnProperty() 等。

    下面看看 es6 中有哪些不同吧。

es6 的 class … extends …

class SuperType {constructor(name) {
    this.name = name
    this.colors = ["red", "blue", "green"];
  }
  sayName() {alert(this.name)
  }
}
class SubType extends SuperType {constructor(name, age){super(name)
    this.age = age
  }
  sayAge() {alert(this.age)
  }
}
let instance = new SubType('gim', '17');
instance.sayName(); // 'gim'
instance.sayAge(); // '17'

可以明显的发现这段代码比之前的更加简短和美观。es6 class 实现继承的核心在于使用关键字 extends 表明继承自哪个父类,并且在子类构造函数中必须调用 super 关键字,super(name)相当于 es5 继承实现中的 SuperType.call(this, name)

虽然结果可能如你所料的实现了原型链继承,但是这里还是有个需要注意的点值得一说。

如图,es6 中的 class 继承存在两条继承链:

  1. 子类 prototype 属性的 __proto__ 属性,表示方法的继承,总是指向父类的 prototype 属性。

这点倒和经典继承是一致的。
如红线所示,子类 SubTypeprototype属性的 __proto__ 指向父类 SuperTypeprototype属性。
相当于调用 Object.setPrototypeOf(SubType.prototype, SuperType.prototype);
因为和经典继承相同,这里不再累述。

  1. 子类的 __proto__ 属性,表示构造函数的继承,总是指向父类。

这是个值得注意的点,和 es5 中的继承不同,如蓝线所示,子类 SubType__proto__指向父类 SuperType。相当于调用了Object.setPrototypeOf(SubType, SuperType);
es5 继承中子类和父类的内置原型直接指向的都是Function.prototype,所以说Function 是所有函数的爸爸。而在 es6class...extends...实现的继承中,子类的内置原型直接指向的是父类。
之所以注意到这点,是因为看 kyle 大佬的《你不知道的 javascript 下》的时候,看到了 class MyArray extends Array{}var arr = MyArray.of(3)这两行代码,很不理解为什么 MyArray 上面为什么能调到 of 方法。因为按照 es5 中继承的经验,MyArray.__proto__应该指向了 Function.prototype,而后者并没有 of 方法。当时感觉世界观都崩塌了,为什么我以前的认知失效了?第二天重翻阮一峰老师的《ECMAScript6 入门》才发现原来class 实现的继承是不同的。

知道了这点,就可以根据需求灵活运用 Array 类构造自己想要的类了:

class MyArray extends Array {[Symbol.toPrimitive](hint){if(hint === 'default' || hint === 'number'){return this.reduce((prev,curr)=> prev+curr, 0)
    }else{return this.toString()
    }
  }
}
let arr = MyArray.of(2,3,4);
arr+''; //'9'

元属性 Symbol.toPrimitive 定义了 MyArray 的实例发生强制类型转换的时候应该执行的方法,hint的值可能是 default/number/string 中的一种。现在,实例 arr 能够在发生加减乘除的强制类型转换的时候,数组内的每项会自动执行加性运算。

以上就是 js 实现继承的两种模式,可以发现 class 继承和 es5 寄生组合继承有相似之处,也有不同的地方。虽然 class 继承存在一些问题(如暂不支持静态属性等),但是子类的内置原型指向父类这点是个不错的改变,这样我们就可以利用原生构造函数(Array 等)构建自己想要的类了。

kyle 大佬提到的行为委托

在读《你不知道的 javascript 上》的时候,感触颇多。这本书真的是本良心书籍,让我学会了 LHS/RHS,读懂了闭包,了解了词法作用域,彻底理解了 this 指向,基本懂了 js 的原型链继承。所以当时就忍不住又从头读了一遍。如果说诸多感受中最大的感受是啥,那一定是行为委托了。我第一次见过有大佬能够如此强悍(至少没见过国内的大佬这么牛叉的),强悍到直接号召读者抵制 js 的继承模式(无论寄生组合继承还是 class 继承),并且提倡使用行为委托模式实现对象的关联。我真的被折服了,要知道 class 可是 w3c 委员会制定出的标准,并且已经广泛的应用到了业界中。关键的关键是,我确实认为行为委托确实更加清晰简单(如有异议请指教)。

let SuperType = {initSuper(name) {
    this.name = name
    this.color = [1,2,3]
  },
  sayName() {alert(this.name)
  }
}
let SubType = {initSub(age) {this.age = age},
  sayAge() {alert(this.age)
  }
}
Object.setPrototypeOf(SubType,SuperType)
SubType.initSub('17')
SubType.initSuper('gim')
SubType.sayAge() // 'gim'
SubType.sayName() // '17'

这就是模仿上面 js 继承的两个例子,利用行为委托实现的对象关联。行为委托的实现非常超级极其的简单,就是把父对象关联到子对象的内置原型上,这样就可以在子对象上直接调用父对象上的方法。行为委托生成的原型链没有 class 继承生成的原型链的复杂关系,一目了然。当然 class 有其存在的道理,但是在些许场景下,应该是行为委托更加合适吧。希望 safari 尽快实现 Object.setPrototypeOf() 方法,太 out 了连 ie 都支持了。

小子愚钝,如果行为委托完全能够实现实现 class 继承的功能,而且更加简单和清晰,我们开发的过程中为什么不愉快的尝试用一下呢?

退出移动版