尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

彻底搞懂 JavaScript 继承:从原型链到 ES6 class 底层原理

彻底搞懂 JavaScript 继承:从原型链到 ES6 class 底层原理 先说明一点继承这个话题看着基础但每次面试都能挂一批人。不是挂在“会不会写”而是挂在“能不能讲透”。很多人能背出几种继承方式的名字但问到“为什么组合继承要调两次父类构造函数”“ES6 class 的 super 到底做了什么”“原型链的终点是什么”就开始含糊了。这篇文章我把继承从底层原理到面试实战完整拆一遍尽量用大白话讲清楚顺便把 C、Dart 这些语言的继承差异也带一下看完你再去面试这块基本不会丢分。1. 先搞清楚继承到底在解决什么问题1.1 没有继承的时代代码是怎么写的假设你要开发一个后台管理系统里面有管理员、普通用户、访客三种角色。没有继承的时候你写三个类每个类里都复制一份login、logout、getAvatar方法。代码一多问题就来了改一个登录逻辑要改三处漏改一处线上就出 bug。这就是典型的代码复用困境。继承的核心目的就是解决这个问题把公共的逻辑抽到父类里子类通过继承自动获得这些能力同时还能按需扩展自己的特有逻辑。这就像你家里的工具箱螺丝刀、扳手、钳子这些公用的放一层自己额外买的电动螺丝刀放自己那一层用的时候不用重新买一套。但 JavaScript 的继承和其他语言有个本质区别Java、C 这些语言是基于类的继承类是模板对象是模板的实例而 JavaScript 是基于原型的继承对象直接关联对象。这个区别是理解后面所有内容的地基。1.2 继承的本质是对象之间的关联不是复印很多人有个误区觉得继承是“把父类的东西复制一份到子类”。如果真是这样那子类修改属性不应该影响父类但实际开发中你会遇到各种诡异的问题比如修改一个子类实例的属性其他实例也跟着变了。这就是因为没有搞清楚 JavaScript 继承的本质。继承的本质是建立一条关联链。子类对象没有的属性会顺着链条去父类对象上找。这个链条就是原型链。理解了这个你就明白为什么 JavaScript 的继承叫“委托式继承”而不叫“复制式继承”——子类没有自己的方法它只是把请求委托给了父类。记住一句话JavaScript 中继承不是拷贝是关联。对象之间通过原型链形成委托关系属性查找是沿着链条向上走的。2. JavaScript 的继承原型链才是真正的根2.1 每个对象都有一条通往 null 的链路随便在浏览器控制台创建一个对象然后打印它的__proto__你会看到一层一层往上直到null。这就是原型链。它解决了 JavaScript 里“对象怎么找到自己没有的属性”这个问题。举个例子你创建一个数组const arr [1, 2, 3]然后调用arr.map()。数组身上并没有map这个方法它怎么找到的顺着原型链arr的原型是Array.prototypeArray.prototype上有map、forEach、filter这些方法。如果Array.prototype上找不到再往上找Object.prototype比如toString方法就在这层。如果Object.prototype上也没有再往上就是null就返回undefined或者报错。这就是为什么Object.prototype上的方法所有对象都能用因为所有对象的原型链最终都会经过它。这条链的终点是null不是Object.prototype这点很多面试官喜欢挖坑。2.2 prototype、proto、constructor 三角关系这块绝对是重灾区我面试别人的时候十个人里至少有五个搞混这三个概念。先记一个口诀函数身上有prototype实例身上有__proto__prototype身上有constructor。具体拆开讲function Person(name) { this.name name; } const person new Person(张三);这段代码会创建出两个跟Person相关的对象一个是Person.prototype另一个是person实例。它们的关系是person.__proto__ Person.prototype实例的原型指向构造函数的原型对象Person.prototype.constructor Person原型对象上的 constructor 指回构造函数Person.__proto__ Function.prototype构造函数本身也是一个对象它的原型指向Function.prototypeconsole.log(person.__proto__ Person.prototype); // true console.log(Person.prototype.constructor Person); // true console.log(Person.__proto__ Function.prototype); // true console.log(Person.prototype.__proto__ Object.prototype); // true console.log(Object.prototype.__proto__); // null这个三角关系搞清楚了后面的继承方式全都是建立在它之上的。面试的时候能把这个链条画出来并且讲清楚每一步就已经超过大半的人了。3. 六种继承方式逐个拆解3.1 原型链继承最朴素的方案原型链继承的核心就一句话让子类的原型指向父类的实例。function Parent() { this.name parent; this.hobbies [coding, reading]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child() {} Child.prototype new Parent(); Child.prototype.constructor Child; const child1 new Child(); const child2 new Child(); child1.hobbies.push(gaming); console.log(child2.hobbies); // [coding, reading, gaming]这个方式的问题很明显父类的引用类型属性会被所有子类实例共享。child1改了hobbieschild2也变了。因为hobbies在原型上不在实例上。这是原型链继承最大的坑也是面试考得最多的点。另外还有两个小问题创建子类实例的时候没法往父类构造函数传参数而且Child.prototype被完全覆盖了需要手动把constructor指回来。整体来说这种继承方式理解起来简单但实际开发中基本不用。3.2 构造函数继承解决引用共享问题既然问题出在属性放在原型上了那把属性放在实例上不就行了构造函数继承就是干这个的。function Parent(name) { this.name name; this.hobbies [coding, reading]; } function Child(name) { Parent.call(this, name); this.age 18; } const child1 new Child(张三); const child2 new Child(李四); child1.hobbies.push(gaming); console.log(child2.hobbies); // [coding, reading] console.log(child2.name); // 李四核心就是Parent.call(this, name)这一行。call改变了Parent里this的指向让它在Child的实例上执行一遍这样每个实例都有自己的name和hobbies互不干扰。而且还能给父类传参了。但这种方式有个致命伤父类原型上的方法子类实例拿不到。因为Child.prototype并没有跟Parent.prototype建立任何关联。你在Parent.prototype上定义的方法child1根本访问不到。所以这种继承方式只是把构造函数里的属性复制了一份并没有形成继承关系。3.3 组合继承经典方案但不够完美把原型链继承和构造函数继承结合起来就得到了组合继承这也是红宝书里推荐的经典方式。function Parent(name) { this.name name; this.hobbies [coding, reading]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); // 第二次调用 Parent this.age age; } Child.prototype new Parent(); // 第一次调用 Parent Child.prototype.constructor Child; const child new Child(张三, 18); child.sayName(); // 张三这种方式既解决了属性共享的问题又能继承父类原型上的方法看起来完美了。但注意注释里写的Parent被调用了两次。第一次是new Parent()创建一个父类实例作为Child.prototype第二次是Child构造函数里Parent.call(this)。这两次调用导致了一个问题子类实例和子类原型上都有name和hobbies属性。只不过实例上的属性覆盖了原型上的你平时感知不到但确实造成了内存浪费和属性冗余。这就是组合继承被诟病的地方。3.4 原型式继承无构造函数的继承有些场景下你手里只有一个现成的对象想基于它创建新对象不想写构造函数。这时候可以用原型式继承。function object(o) { function F() {} F.prototype o; return new F(); } const person { name: 张三, hobbies: [coding] }; const obj1 object(person); const obj2 object(person); obj1.hobbies.push(reading); console.log(obj2.hobbies); // [coding, reading]这个方式的核心是创建一个临时构造函数F把传入的对象赋值给F.prototype然后返回F的实例。返回的实例原型就是传入的对象。ES5 之后有了标准化版本Object.create()用法一样const obj1 Object.create(person);原型式继承适合不需要构造函数、单纯想在对象之间建立关联的场景。但它跟原型链继承一样存在引用类型属性共享的问题。3.5 寄生式继承在原型式基础上加点料寄生式继承是在原型式继承的基础上给对象添加自己属性和方法类似于工厂模式。function createAnother(o) { const clone Object.create(o); clone.sayName function() { console.log(this.name); }; return clone; } const person { name: 张三 }; const obj createAnother(person); obj.sayName(); // 张三这种方式本质上就是“基于一个对象创建一个新对象然后给新对象增强能力”。它的问题也很明显每个实例上都有sayName方法没法复用浪费内存。这种方式在实际开发中用得不多但它为寄生组合式继承提供了思路。3.6 寄生组合式继承最终的王者寄生组合式继承通过一个技巧避免组合继承中调用两次父类构造函数的问题。function inheritPrototype(Child, Parent) { const prototype Object.create(Parent.prototype); prototype.constructor Child; Child.prototype prototype; } function Parent(name) { this.name name; this.hobbies [coding, reading]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); this.age age; } inheritPrototype(Child, Parent); const child new Child(张三, 18); console.log(child.name); // 张三 child.sayName(); // 张三 console.log(child instanceof Parent); // true关键在inheritPrototype这个函数用Object.create(Parent.prototype)创建一个新对象这个对象的原型指向Parent.prototype然后把它赋值给Child.prototype。这样就只有一次Parent.call(this)子类原型上干净了没有多余的父类实例属性。这套方案是目前最完美的继承实现方式也是ES6 class继承的底层基础。面试官如果让你手写继承写这个方案基本就是满分答案。4. ES6 class 继承语法糖背后的真相4.1 extends 和 super 到底做了什么ES6 引入了class语法很多人以为终于可以用上“真正的面向对象”了但说到底它还是基于原型链的语法糖。class Parent { constructor(name) { this.name name; } sayName() { console.log(this.name); } } class Child extends Parent { constructor(name, age) { super(name); this.age age; } }extends关键字背后做的是Object.setPrototypeOf(Child.prototype, Parent.prototype)和Object.setPrototypeOf(Child, Parent)。前者让子类实例能访问父类原型上的方法后者让子类能访问父类的静态方法。super关键字有两层含义在构造函数里调用它表示父类的构造函数在静态方法里调用它表示父类本身。super(name)实际上相当于Parent.call(this, name)但严格来说它做的事情比call更多它会确认this已经被正确初始化了。4.2 子类构造函数为什么必须先调用 super()这是面试题里一个高频考点。答案很简单ES6 的 class 继承this的初始化是由父类完成的。在普通函数构造函数里this是new的时候自动创建的。但在 class 继承中子类没有自己的this它必须通过super()去父类那里拿this。如果你在子类构造函数里先访问了this再调用super()就会直接报错。class Child extends Parent { constructor(name, age) { this.age age; // ReferenceError: Must call super constructor in derived class super(name); } }如果子类不写构造函数等同于写了一个默认的constructor(...args) { super(...args); }。这一点用刚才讲的原型链可以解释子类的this是从父类实例创建的没有父类的初始化子类的实例都建立不起来。注意在class继承中new.target指向的是实际被new的那个类。super()调用时this的new.target是子类而不是父类这就是为什么父类内部可以通过this instanceof 子类判断出当前是谁在继承。4.3 继承内置对象ES6 之前的构造函数继承你没法真正继承数组、正则这些内置对象。比如你想做一个MyArray试图通过new Array()来继承很多内置方法会出问题因为Array的内部槽位比如length没有被正确初始化。ES6 class 解决了这个问题class MyArray extends Array { last() { return this[this.length - 1]; } } const arr new MyArray(1, 2, 3); console.log(arr.last()); // 3 console.log(arr.map(x x * 2)); // [2, 4, 6]这得益于 class 继承中this绑定和内置对象构造机制的改进。而且map返回的数组类型也还是MyArray因为 Symbol.species 默认返回值就是当前构造函数。这个知识点面试中偶尔会挖能答出来是加分项。5. 横向对比C、Dart、Java 的继承差异5.1 访问控制public、protected、private前端开发做到后面多少会接触一点后端语言或者 DartFlutter 项目。这些语言跟 JavaScript 最大的区别之一就是访问控制。C 里有三种继承方式公有继承public、保护继承protected、私有继承private。它们的区别在于公有继承保持父类的访问权限不变保护继承把父类的公有成员变成保护的私有继承把父类的公有成员和保护成员都变成私有的。class Parent { public: int publicValue; protected: int protectedValue; private: int privateValue; }; class ChildPublic : public Parent { // publicValue 还是 public // protectedValue 还是 protected // privateValue 访问不到 }; class ChildPrivate : private Parent { // publicValue 变成 private // protectedValue 变成 private // privateValue 访问不到 };Java 里没有继承方式的概念只有访问修饰符的区别。JavaScript/TypeScript 里有public、protected、private关键词但private在 JavaScript 里只是编译期的约束运行时照样能访问到。最新的#前缀私有字段是真正运行时层面的私有这个面试偶尔会问。5.2 多继承与菱形继承问题JavaScript 只有单继承链一个类只能有一个继承来源。但 C 支持多继承一个类可以同时继承多个父类。多继承会引出经典问题菱形继承。class A { public: int value; }; class B : public A {}; class C : public A {}; class D : public B, public C {};这里的继承结构像菱形A 是顶端B 和 C 都继承 AD 又同时继承 B 和 C。直接的问题是D 里有两份 A 的value成员访问d.value会报歧义你得用d.B::value或d.C::value显式指定。如果想让 D 只有一份 A要用虚继承class B : virtual public A {}; class C : virtual public A {}; class D : public B, public C {};JavaScript 没有这种问题因为它压根不支持多继承。如果你想实现类似多继承的能力思路就变成了 Mixin 模式——把多个对象的方法混入一个对象身上。函数式混入最简单的方式就是Object.assignconst canEat { eat() { console.log(eating); } }; class Person {} Object.assign(Person.prototype, canEat);5.3 Dart 的继承与混入MixinDart 是 Flutter 的底层语言它有三种代码复用方式继承extends、接口实现implements、混入with。很多前端转 Flutter 的人容易把extends和with搞混。extends只允许单继承跟 JavaScript 一样。with用来混入多个混入类Mixin普通类也可以作为 Mixin 使用只要它没有显式构造器。这解决了“想把多个功能组合到一个类里”的需求。mixin Swimmer { void swim() { print(swimming); } } mixin Runner { void run() { print(running); } } class Athlete with Swimmer, Runner {}Dart 的 Mixin 和 JavaScript 的 Mixin 思路类似语法层面更好用。面试官问到这个区别其实是想考察你继承是 is-a 关系混合是 can-do 关系。一个是纵向的体系一个是横向的能力组合这个回答思路同样适用于 CSS 的类组合、React 的 HOC 组合。6. 面试实战高频继承题速查6.1 手写继承的标准答案面试让你手写继承最优解是寄生组合式继承。我给出一个可以直接背下来的版本function inheritPrototype(Child, Parent) { Child.prototype Object.create(Parent.prototype); Child.prototype.constructor Child; Object.setPrototypeOf(Child, Parent); } function Parent(name) { this.name name; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); this.age age; } inheritPrototype(Child, Parent);注意Object.setPrototypeOf(Child, Parent)这一行它让子类能访问父类的静态方法。如果要考察 class 继承的手写底层把extends翻译成函数等价实现核心也是这两行Object.setPrototypeOf(Child.prototype, Parent.prototype); Object.setPrototypeOf(Child, Parent);6.2 new 的过程与继承的关系继承的手写题往往跟 new 的过程绑定在一起考。new 一个实例经历了四个步骤创建一个新对象把它的原型指向构造函数的prototype把构造函数的this指向这个新对象执行构造函数里的代码给this添加属性如果构造函数返回了对象就返回它否则返回thisfunction myNew(Constructor, ...args) { const obj Object.create(Constructor.prototype); const result Constructor.apply(obj, args); return (typeof result object result ! null) ? result : obj; }理解了 new 的过程你就知道为什么这种写法能实现继承Object.create(Child.prototype)保证了实例对象能通过原型链访问Child.prototype上的一切而Child.prototype又通过Object.create(Parent.prototype)关联了父类原型。6.3 继承与组合怎么选最后聊一个面试官喜欢问的开放题什么场景用继承什么场景用组合我的经验是能组合就组合继承只在严格的 is-a 关系下用。前端领域尤其注意因为 JavaScript 的继承基于原型链层级一深链路就长排错成本高。React 官方之前也明确建议过组件复用优先用组合而不是继承。举两个直观例子一个Button组件想复用它的样式逻辑这应该是组合不是继承。因为你要的是它的能力不是想成为它。一个Animal类和一个Dog类Dog 确实是一种 Animal这才是继承关系。而且在这种关系里父类应该只放真正通用的逻辑否则层级结构会越来越臃肿。如果你发现自己为了复用两个方法而硬造继承关系说明你该用组合了。这个判断标准在面试里主动说出来是很加分的。7. 我踩过的坑和总结的经验聊点实战中踩过的坑。第一永远不要在子类实例上直接修改继承来的引用类型属性。我调试过一个线上 bug某个弹窗组件的配置列表不对查了半天发现问题出在某个基于父类原型共享的数组被污染了。排查原型链上的引用类型属性是个很隐蔽的坑建议在团队规范里直接禁止在原型上定义可变引用类型。第二constructor指向一定要手动修正。很多继承里的隐性 bug 都是因为Child.prototype.constructor被覆盖成了Parent导致instanceof和类型判断结果异常。虽然大多数业务代码不依赖constructor但一些库比如判断实例类型、序列化、深拷贝框架会用到它你永远不知道哪里会踩雷。第三能上 ES6 class 就上 class。新项目我基本不写传统的寄生组合式继承了直接用 class 语法可读性高、写法规范。但写 class 继承时子类构造函数里super()一定要放在第一行这个规则没有例外不知道为什么的人永远在踩这个坑。最后继承只是一个工具不是银弹。我见过一些刚学完继承的同事业务代码里到处造继承关系最后改需求的时候动一个父类崩一片子类痛不欲生。多想想组合、多想想接口和协议继承只用在真正符合 is-a 关系的地方你会少写很多 bug。
返回列表