写点什么

JavaScript 概念 - 原型与继承

作者:yuanyxh
  • 2024-09-14
    中国香港
  • 本文字数:4866 字

    阅读完需:约 16 分钟

JavaScript 概念 - 原型与继承

简述

JavaScript 虽然采用面向对象的编程模式,但不同于其他基于面对对象的语言一样,拥有类的概念,JavaScript 是基于原型的,虽然 ES6 提出了类,但其本质还是原型与原型继承,ES6 中的类只是一个语法糖。本文将理解并讲述 JavaScript 中的原型与继承。

概念

原型:即原型对象 prototype,存在于所有非箭头函数的函数身上,而每个通过函数构造出来的实例对象身上都有一个内部 [[Prototype]] 属性,该属性被浏览器厂商实现为 __proto__ 属性,指向对应函数的原型


function Test() {}const t = new Test();
// t.__proto__ 链接到 Test.prototypeconsole.log(t.__proto__ === Test.prototype); // true
复制代码


原型链:一个实例对象的 __proto__ 指向构造该实例的函数的原型,该原型的 __proto__ 指向构造该原型对象的函数的原型,级级链接形成链条。


function A() {}function B() {}
// a 是 A 的实例,a 的 __proto__ 链接到 A 的 prototypeconst a = new A();
// 将 B 的原型设置为 aB.prototype = a;B.prototype.constructor = B;
// b 是 B 的实例,b 的 __proto__ 链接到 B 的 prototypeconst b = new B();
console.log(b.__proto__ === B.prototype); // trueconsole.log(b.__proto__.__proto__ === A.prototype); // true
复制代码


原型继承:访问一个对象身上不存在的属性时,会查找该对象的 __proto__ 属性,即原型对象,未查找到时继续沿着原型对象的 __proto__ 属性,层层向上直到 Object 的原型的 __proto__,即 null 身上时会返回 undefined,表示属性不存在。


属性遮蔽:了解了原型继承,我们知道 JS 引擎是层层向上查找对象属性的,也就意味着当找到对应属性时就不再继续向上查找,即使原型链上层拥有相同属性。


构造属性:即函数原型对象身上的 constructor 属性;函数、原型、实例对象的关系如下:


  • 函数的 prototype 属性指向原型

  • 原型的 constructor 属性指向函数

  • 实例的 __proto__ 属性指向原型


function Test() {}const t = new Test();
// 实例属性与原型链接console.log(t.__proto__ === Test.prototype); // true
// 原型属性与函数链接console.log(Test.prototype.constructor === Test); // true
// 注意,此处是实例通过原型链访问到了原型的 constructor// 即 t.__proto__.constructor === Testconsole.log(t.constructor === Test); // true
复制代码


实例与函数之间没有直接关系,实例能够通过原型的 constructor 属性访问函数。

作用

在创建对象时,可能需要很多相似的对象,我们通常使用构造函数[^构造函数]来创建,构造函数抽象了某一类对象的相同特性与行为,如


function Person(name, age) {  this.name = name;  this.age = age;  this.sayHi = () => {    console.log('hi, i am' + this.name + ', nice to meet you!');  };}
const me = new Person('yuanyxh', 22);console.log(me); // { name: 'yuanyxh', age: 22 }me.sayHi(); // hi, i am yuanyxh, nice to meet you!
复制代码


上述代码创建了一个泛指 Person 的构造函数,每次调用都会构造出一个独一无二的 Person 实例,每个实例又都有 nameage 属性与 sayHi 方法。


但是这样的代码是有问题的,在每次调用 Person 构造实例时都会创建出一个新的 sayHi 方法


const me = new Person('yuanyxh', age);me.sayHi(); // hi, i am yuanyxh, nice to meet you!const you = new Person('unknown', 18);you.sayHi(); // hi, i am unknown, nice to meet you!
// 两个 sayHi 方法不相等console.log(me.sayHi === you.sayHi); // false
复制代码


可以看到,每个实例身上的 sayHi 方法都是不同的,但做的是相同的事,创建多个 sayHi 方法做相同的事是不必要的,我们可以通过在函数原型身上添加公有方法来避免。


原型对象只是函数身上的一个属性,JavaScript 并没有限制我们对它进行修改,我们能够对某一个函数原型进行扩展,甚至是替换


function Person(name, age) {  this.name = name;  this.age = age;}
// prototype 原型对象Person.prototype.sayHi = function () { console.log('hi, i am' + this.name + ', nice to meet you!');};
const me = new Person('yuanyxh', 22);me.sayHi(); // hi, i am yuanyxh, nice to meet you!const you = new Person('unknown', 18);you.sayHi(); // hi, i am unknown, nice to meet you!
console.log(me.sayHi === you.sayHi); // true
复制代码


因为原型继承,访问实例身上不存在的 sayHi 方法时,向上在 Person 的原型对象上找到了,两个实例访问的都是同一个方法。


我们还可以直接替换函数原型,达到最大程度的扩展


function Person (name, age) {  this.name = name;  this.age = age;}
const obj = { run() { console.log('runing'); } sayHi() { console.log('hi, i am' + this.name + ', nice to meet you!'); }}
// 直接覆盖原型Person.prototype = obj;// 将原型的 constructor 重新指向 PersonPerson.prototype.constructor = Person;
const me = new Person('yuanyxh', 22);me.run(); // runingme.sayHi(); // hi, i am yuanyxh, nice to meet you!
复制代码


上述代码直接替换了 Person 的原型,但需要手动将替换后的原型 constructor 属性指回该函数。


在扩展函数原型时,应对新增属性进行必要的限制,可以使用 Object.definePropertyObject.defineProperties 进行属性 是否可配置是否可枚举是否可写settergetter 的配置。

模式

基于原型的继承方式虽然有用,但依旧存在一些问题,如引用数据的修改


function Person() {}Person.prototype.lists = [  { name: 'jack', age: 34 },  { name: 'rose', age: 28 }];
const a = new Person();const b = new Person();
a.lists[0].name = 'Alexander';
console.log(a.lists); // [{name: 'Alexander', age: 34}, {name: 'rose', age: 28}]console.log(b.lists); // [{name: 'Alexander', age: 34}, {name: 'rose', age: 28}]
复制代码


可以看到实例 alists 的修改影响到了实例 b,因为他们引用的是同一个值;此外,我们无法在子构造函数中给父构造函数传参。


鉴于此,一些新的实现继承的方式被研究出来用于代替原型继承,下面一一介绍。

盗用构造函数

原型继承的问题在于引用值的修改与子构造函数对父构造函数的传参,而盗用构造函数的基本思路是:子构造函数中通过 callapply 方法对父构造函数进行调用并修改父构造函数中的 this 指向,并传递参数


function Father(name, age) {  this.name = name;  this.age = age;}function Son(name, age, sex) {  // 修改 Father 的this 指向为当前实例,并传递参数  Father.call(this, name, age);  this.sex = sex;}
const me = new Son('yuanyxh', 22, '男');console.log(me.name); // yuanyxh
复制代码


但我们很容易发现上面的问题,这相当于直接在 Son 中进行数据的赋值,构造出来的 Son 实例的原型链上并不存在 Father 函数的原型,也就意味着无法共享父构造函数原型上的方法。


function Father(name, age) {  this.name = name;  this.age = age;}// Father 原型上添加一个方法Father.prototype.sayHi = function () {  console.log('hi');};
function Son(name, age, sex) { // 修改 Father 的this 指向为当前实例,并传递参数 Father.call(this, name, age); this.sex = sex;}
const me = new Son('yuanyxh', 22, '男');// 原型链上不存在 sayHi 方法,所以报错me.sayHi(); // TypeError
// 检测实例的原型链上是否存在对应原型console.log(me instanceof Father); // falseconsole.log(me.__proto__.__proto__ === Father.prototype); // false
复制代码

组合继承

针对盗用构造函数的缺点,又衍生出了组合继承:子构造函数的原型被赋值为父构造函数的实例,这样子构造函数的实例就能够访问父构造函数的原型,再通过盗用构造函数实现引用值隔离与参数传递


function Father(name, age) {  this.name = name;  this.age = age;}// Father 原型添加 sayHi 方法Father.prototype.sayHi = function () {  console.log('hi');};
function Son(name, age, sex) { // 修改 Father this 指向并传参 Father.call(this, name, age); this.sex = sex;}// 覆盖原型Son.prototype = new Father();
const me = new Son('yuanyxh', 22, '男');me.sayHi(); // hi
// 检测实例的原型链上是否存在对应原型console.log(me instanceof Father); // trueconsole.log(me.__proto__.__proto__ === Father.prototype); // true
复制代码


这样就实现了子构造函数原型与父构造函数原型的链接,还实现了引用值隔离和参数传递,但组合继承依旧存在瑕疵,我们将上述代码分解查看


function Father(name, age) {  this.name = name;  this.age = age;}// Father 原型添加 sayHi 方法Father.prototype.sayHi = function () {  console.log('hi');};
function Son(name, age, sex) { // 修改 Father this 指向并传参 Father.call(this, name, age); this.sex = sex;}// 创建 Father 实例const f = new Father();console.log(f); // { name: undefined, age: undefined }
// 覆盖原型Son.prototype = f;
复制代码


查看上述代码发现,构造出来的 Father 实例拥有两个属性,随后该实例被赋值为 Son 的原型对象,但我们是不希望原型上有多余属性的,可以使用 寄生式组合继承 解决

原型式继承与寄生式继承

这两种继承方式息息相关,寄生式继承可以看作原型式继承的加强版。


原型式继承:将一个临时构造函数的原型赋值为指定对象,返回这个临时构造函数的实例,这个实例能够使用指定对象的数据,又能添加属于自己的数据,实现了信息共享


function object(o) {  // 临时构造函数  function F() {}  // 替换原型  F.prototype = o;  // 返回临时构造函数的实例  return new F();}
const person = { name: 'yuanyxh', age: 22};
const n = object(person);n.sex = '男';
console.log(n.name); // yuanyxhconsole.log(n.__proto__ === person); // true
复制代码


寄生式继承:即对原型式继承得到的实例进行增强


function createAnother(original) {  // 利用原型式继承得到新实例  // 新实例 __proto__ 指向 original  let clone = object(original);  // 添加方法  clone.sayHi = function () {    console.log('hi');  };  // 返回  return clone;}
复制代码


ES5 中的 Object.create 对原型式继承与寄生式继承进行了实现,该方法接收一个需要共享信息的对象,以及一个可选的扩展对象。

寄生式组合继承

关于组合继承,我们知道它虽然解决了很多继承相关的问题,但也有一点小瑕疵,接下来我们通过寄生式组合继承来解决。


寄生式组合继承:通过寄生式继承直接复制父构造函数的原型并扩展,再使用盗用构造函数进行引用值隔离与参数传递


function Father(name, age) {  this.name = name;  this.age = age;}Father.prototype.sayHi = function () {  console.log('hi');};
function Son(name, age, sex) { // 修改 Father this 指向并传参 Father.call(this, name, age); this.sex = sex;}
Son.prototype = Object.create(Father.prototype, { constructor: Son });
复制代码


这样,我们不必通过创建父构造函数的实例来替换子构造函数的原型,而是直接复制父构造函数的原型,避免了子构造函数原型上多余的属性。

结语

JavaScript 的原型与原型继承虽然提供了强大的扩展能力,但不应该滥用,不建议扩展原生对象的原型,思考一下:如果在项目的某个位置,某个代码修改了原生对象的一些功能,而你并不知晓,当你使用这个对象出现 bug 时是多么痛啊:sob:。


本文介绍了 JavaScript 中的原型与原型继承相关概念,内容有误请指出,内容有缺请补充。

参考资料

  • 《JavaScript 高级程序设计》

  • 《你不知道的 JavaScript》

  • MDN


[^构造函数]: JavaScript 中的构造函数并不是特殊的函数,任何以 new 调用的函数都可以看作是构造函数,构造函数应以大写字母开头。


发布于: 刚刚阅读数: 4
用户头像

yuanyxh

关注

站在巨人的肩膀上 2023-08-19 加入

web development

评论

发布
暂无评论
JavaScript 概念 - 原型与继承_js_yuanyxh_InfoQ写作社区