ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript继承:从原型链到Class,掌握面向对象编程核心

JavaScript继承:从原型链到Class,掌握面向对象编程核心 1. 从“百炼成仙”到“原型链”为什么继承是JavaScript的基石如果你在搜索引擎里敲下“JavaScript 继承”大概率会看到一堆关于“原型链”、“组合继承”、“寄生组合继承”的术语然后被各种Object.create、call和new绕得头晕。但如果你换个角度看看那些热门的搜索词——“javascript百炼成仙”、“javascript 闭包应用”、“javascript 隐式转换 5 大场景解析”——你会发现大家真正关心的不是枯燥的语法而是如何用JavaScript写出更强大、更优雅、更不容易出错的代码。继承恰恰是连接这些高级主题的桥梁是让你从“写脚本”迈向“设计程序”的关键一步。想象一下你正在开发一个电商网站。你需要“商品”这个大类下面有“图书”、“服装”、“电子产品”等子类。所有商品都有名称、价格、库存等共性但图书有作者、出版社服装有尺码、颜色电子产品有型号、保修期。如果没有继承你可能会为每个品类都从头定义一个对象或类大量重复的代码会让你维护起来痛不欲生。而继承就是让你能定义一个通用的“商品”模板父类然后让“图书”、“服装”等子类自动获得这些通用属性和方法同时还能添加或覆盖自己特有的部分。这不仅仅是代码复用更是对现实世界关系的一种建模是构建复杂应用架构的起点。很多初学者觉得JavaScript的继承特别“怪”尤其是从Java或C转过来的朋友。这是因为JavaScript在ES6引入class语法之前其继承机制是基于“原型”的这是一种与大多数主流语言不同的面向对象实现方式。理解它不仅是掌握一种语法更是理解JavaScript这门语言的设计哲学和运行机制。今天我们就抛开那些教科书式的定义从一个实践者的角度彻底拆解JavaScript继承的“为什么”和“怎么做”让你不仅能写出正确的继承代码更能理解其背后的设计意图从而在遇到“闭包问题”、“隐式转换”等高级场景时也能游刃有余。2. 原型与原型链JavaScript继承的底层引擎要理解继承必须先搞懂原型。你可以把JavaScript中的每个对象包括函数都想象成有一个隐藏的“爹”这个“爹”就是它的原型对象。当你试图访问一个对象的属性或方法时如果这个对象自身没有JavaScript引擎就会自动去它的“爹”原型对象身上找如果“爹”也没有就去“爹的爹”原型的原型身上找这条寻亲的链路就是“原型链”。继承的实现本质上就是通过操作这条链来建立对象之间的关系。2.1__proto__与prototype两个容易混淆的关键点这是最让人困惑的地方。我们通过一个具体的构造函数例子来看。function Person(name) { this.name name; } Person.prototype.sayHello function() { console.log(Hello, Im ${this.name}); }; const alice new Person(Alice);现在我们来拆解其中的关系构造函数Person它是一个函数函数也是对象所以它有一个prototype属性。这个prototype属性是一个对象我们称之为“原型对象”。它的作用是所有通过new Person()创建出来的实例对象都会自动链接到这个原型对象上。实例对象alice由new Person(Alice)创建。它自身拥有name属性来自构造函数内部的this.name name。同时它有一个内部属性[[Prototype]]在大多数浏览器环境中可以通过__proto__属性访问这个属性指向了其构造函数的prototype属性即Person.prototype。访问链路当执行alice.sayHello()时引擎首先在alice对象自身查找sayHello方法。没找到于是通过alice.__proto__找到Person.prototype在这里找到了sayHello方法于是成功调用。用代码验证一下关系console.log(alice.__proto__ Person.prototype); // true console.log(Person.prototype.constructor Person); // true // constructor 属性是原型对象指向构造函数的引用注意__proto__是一个非标准但被广泛实现的属性用于访问对象的内部[[Prototype]]。在ES5中更推荐使用Object.getPrototypeOf(obj)来获取用Object.setPrototypeOf(obj, prototype)来设置。但在理解概念时用__proto__更直观。为什么这样设计这是一种极其高效的内存共享机制。如果把sayHello方法定义在构造函数Person内部那么每创建一个实例都会在内存中生成一个新的函数对象。而将其定义在Person.prototype上无论创建多少个Person实例它们都共享同一个sayHello函数大大节省了内存。这就是原型模式的核心优势。2.2 原型链的尽头Object.prototype原型链不是无限的。Person.prototype本身也是一个对象它也有__proto__。默认情况下一个普通对象的原型指向Object.prototype。而Object.prototype的__proto__是null这标志着原型链的终点。console.log(Person.prototype.__proto__ Object.prototype); // true console.log(Object.prototype.__proto__); // null所以对于alice对象完整的查找链是alice-Person.prototype-Object.prototype-null。这意味着alice不仅可以调用sayHello还可以调用定义在Object.prototype上的通用方法比如toString()、hasOwnProperty()等。console.log(alice.toString()); // 输出: [object Object] console.log(alice.hasOwnProperty(name)); // true name是alice自身的属性 console.log(alice.hasOwnProperty(sayHello)); // false sayHello来自原型理解了这个链条你就掌握了JavaScript对象系统的命脉。所有的继承方案无论看起来多复杂最终都是在巧妙地设置这个__proto__链接。3. 五种经典继承模式的深度剖析与实战踩坑理解了原型链我们就可以动手实现继承了。下面我们逐一拆解五种最常见的继承方式我会重点讲清每种方式的实现原理、优缺点以及我实际开发中踩过的坑。3.1 原型链继承最直接但问题也最突出这是最直观的方式让子类的原型对象等于父类的一个实例。function Parent() { this.names [kevin, daisy]; } function Child() {} // 关键步骤继承 Child.prototype new Parent(); var child1 new Child(); child1.names.push(yayu); console.log(child1.names); // [kevin, daisy, yayu] var child2 new Child(); console.log(child2.names); // [kevin, daisy, yayu] 问题出现原理Child.prototype new Parent()使得Child.prototype的__proto__指向了Parent.prototype。同时new Parent()这个实例也成为了Child.prototype这意味着Parent构造函数中定义的实例属性如this.names [...]变成了Child.prototype上的属性也就是所有Child实例共享的原型属性。致命缺陷引用类型属性被所有实例共享如上例所示child1修改了names数组child2看到的names也随之改变。这几乎在99%的场景下都是不可接受的。无法向父类构造函数传参在创建Child实例时无法灵活地初始化从Parent继承来的属性。踩坑实录早期我在做一个树形菜单组件时使用了这种方式每个菜单项都有一个children数组。结果所有菜单项的children都莫名其妙地串联在了一起调试了半天才发现是这个坑。结论原型链继承基本不具备实用价值仅用于理解概念。3.2 构造函数继承经典继承解决共享问题但失去原型链为了解决共享问题我们可以在子类构造函数内部调用父类构造函数。function Parent(name) { this.name name; this.colors [red, blue, green]; } function Child(name) { // 关键步骤在子类实例的上下文中执行父类构造函数 Parent.call(this, name); this.age 18; } var child1 new Child(child1); child1.colors.push(black); console.log(child1.name); // child1 console.log(child1.colors); // [red, blue, green, black] var child2 new Child(child2); console.log(child2.colors); // [red, blue, green] 完美互不干扰原理通过Parent.call(this, ...)我们在创建一个新的Child实例时强制在这个新实例的上下文中运行了一遍Parent构造函数。这样Parent内部定义的实例属性this.xxx就被直接添加到了child1和child2这两个独立的实例上而不是它们的原型上。优点避免了引用类型属性被所有实例共享。可以在子类构造函数中向父类构造函数传递参数。致命缺陷方法无法复用父类原型上定义的方法子类实例无法访问。因为这种方式只继承了父类构造函数的实例属性没有建立起子类原型与父类原型之间的链接。Parent.prototype.sayName function() { console.log(this.name); }; var child3 new Child(child3); // child3.sayName(); // TypeError: child3.sayName is not a function这意味着所有方法都必须在父类构造函数中定义即变成实例方法每次创建实例都会创建一遍函数违背了代码复用的初衷。适用场景仅当你明确知道父类没有或不需要在原型上定义方法且属性都是基本类型或不需共享的引用类型时。这种场景极少。3.3 组合继承融合两者成为早期最佳实践既然两种方式各有优劣很自然就想到结合它们用构造函数继承属性用原型链继承方法。function Parent(name) { this.name name; this.colors [red, blue, green]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { // 第二次调用 Parent Parent.call(this, name); // 继承实例属性 this.age age; } // 第一次调用 Parent Child.prototype new Parent(); // 继承方法建立原型链 Child.prototype.constructor Child; // 修复constructor指向 Child.prototype.sayAge function() { console.log(this.age); }; var child1 new Child(Kevin, 18); child1.colors.push(black); console.log(child1.colors); // [red, blue, green, black] child1.sayName(); // Kevin child1.sayAge(); // 18 var child2 new Child(Daisy, 20); console.log(child2.colors); // [red, blue, green] child2.sayName(); // Daisy child2.sayAge(); // 20原理Parent.call(this, name)在子类实例创建时将父类的实例属性“拷贝”一份到子类实例上。Child.prototype new Parent()将子类的原型对象替换为父类的一个实例从而让子类实例能通过原型链访问到父类原型上的方法如sayName。优点融合了构造函数继承和原型链继承的优点是JavaScript中最常用的继承模式解决了属性共享和传参问题同时实现了方法复用。一个不完美的小缺陷父类构造函数被调用了两次。第一次是new Parent()用于创建子类原型第二次是Parent.call()用于创建子类实例。这导致子类原型对象Child.prototype上也有一份父类的实例属性name,colors而子类实例自身又通过call创建了另一份。虽然实例属性会屏蔽原型上的同名属性访问时没问题但这造成了些许的内存浪费和原型对象上存在多余属性。在实际项目中这个缺点的影响微乎其微组合继承在ES5时代是绝对的主流和推荐做法。3.4 原型式继承以对象为蓝本的轻量继承Object.create()方法ES5是这种模式的规范实现。它创建一个新对象使用现有的对象作为新创建对象的原型。var person { name: Kevin, friends: [Shelby, Court] }; var person1 Object.create(person); person1.name Greg; person1.friends.push(Rob); var person2 Object.create(person); person2.name Linda; person2.friends.push(Barbie); console.log(person.friends); // [Shelby, Court, Rob, Barbie]原理Object.create(person)创建了一个空对象person1并将person1.__proto__设置为person。所以person1能访问到person的所有属性。特点与缺陷和原型链继承几乎一样它基于一个现有对象进行“浅拷贝”式继承。引用类型的属性依然会被所有实例共享。它非常适合不需要构建构造函数仅仅想让一个对象与另一个对象保持类似的场景。一个实用的场景当你有一个配置对象想基于它创建多个略有不同的配置时。const baseConfig { apiUrl: /api, timeout: 5000 }; const devConfig Object.create(baseConfig); devConfig.apiUrl http://localhost:3000/api; // 覆盖 const prodConfig Object.create(baseConfig); // prodConfig 保持 baseConfig 的配置3.5 寄生组合式继承终极的完美方案这是组合继承的优化版旨在解决“调用两次父类构造函数”的问题。被广泛认为是引用类型最理想的继承范式。function inheritPrototype(child, parent) { // 关键步骤1创建父类原型的一个副本 var prototype Object.create(parent.prototype); // 关键步骤2修复副本的constructor指向 prototype.constructor child; // 关键步骤3将副本设置为子类的原型 child.prototype prototype; } function Parent(name) { this.name name; this.colors [red, blue, green]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); // 只在这里调用一次 Parent 构造函数 this.age age; } // 使用我们封装的函数而不是 new Parent() inheritPrototype(Child, Parent); Child.prototype.sayAge function() { console.log(this.age); };原理拆解Object.create(parent.prototype)这行代码是精髓。它创建了一个以parent.prototype为原型的新空对象。这个新对象prototype拥有了父类原型的所有方法但它本身不是一个父类的实例所以它身上没有父类构造函数定义的实例属性name,colors。prototype.constructor child因为上一步操作切断了原有原型与Child构造函数的联系我们需要手动将constructor属性指回来这是一个良好的实践。child.prototype prototype将子类的原型指向我们刚创建的这个干净的对象。优点只调用一次父类构造函数在Parent.call(this, name)时效率更高。原型链保持不变能正常使用instanceof和isPrototypeOf。子类的原型对象非常“干净”没有多余的父类实例属性。这是最理想的继承方式也是ES6的classextends语法在底层实现继承时采用的方式或类似方式。理解它你就理解了JavaScript继承的终极形态。4. ES6的Class语法糖更优雅的继承书写方式ES6引入的class关键字并没有引入新的面向对象继承模型它只是基于现有原型链的语法糖让写法更清晰、更像传统面向对象语言。4.1 基本语法与继承class Parent { constructor(name) { this.name name; this.colors [red, blue, green]; } sayName() { console.log(this.name); } } class Child extends Parent { constructor(name, age) { super(name); // 相当于 Parent.call(this, name) this.age age; } sayAge() { console.log(this.age); } } const child1 new Child(Kevin, 18); child1.colors.push(black); child1.sayName(); // Kevin child1.sayAge(); // 18 const child2 new Child(Daisy, 20); console.log(child2.colors); // [red, blue, green]关键点解析extends关键字用于声明一个类继承自另一个类。constructor方法类的构造方法。在子类中你必须先调用super()才能使用this否则会报错。super在这里既可作为函数调用在构造函数中代表父类的构造函数也可作为对象使用在普通方法中指向父类的原型对象。类中定义的方法默认都是挂在原型上的如Parent.prototype.sayName。它本质上做了什么Babel等工具可以将ES6的class代码转译成ES5代码其核心就是实现了我们上面提到的寄生组合式继承。所以class继承在功能上是完备且高效的。4.2 静态方法与属性的继承class语法也清晰地支持静态方法和属性的继承。class Parent { static staticMethod() { return Hello from Parent; } } class Child extends Parent {} console.log(Child.staticMethod()); // Hello from Parent静态属性和方法属于类本身而不是类的实例。子类可以通过原型链访问父类的静态成员因为子类的__proto__指向父类。4.3super关键字的两种角色与实战坑super的使用是class继承中的一个重点和易错点。作为函数调用只能在子类的constructor中使用代表父类的构造函数。ES6要求子类的constructor必须执行一次super()否则新建实例时会报错。这是因为子类实例的构建是基于对父类实例的加工只有super()才能返回父类的this即子类实例的雏形然后子类再对其进行修饰。class Child extends Parent { constructor(name, age) { // 不调用 super() 会报错Must call super constructor in derived class... // super(name); // 正确 this.age age; // 错误在super()之前使用this } }作为对象使用在子类的普通方法中super指向父类的原型对象在子类的静态方法中super指向父类。class Parent { sayHi() { return Hi from Parent prototype; } } class Child extends Parent { sayHi() { // 在普通方法中super.sayHi() 调用的是 Parent.prototype.sayHi return super.sayHi() , and from Child; } static staticSayHi() { // 在静态方法中super.staticSayHi() 调用的是 Parent.staticSayHi return super.staticSayHi() , and from Child static; } }一个实战中的大坑在箭头函数中使用super。箭头函数没有自己的this也没有super。如果你在类的方法中使用了箭头函数并在其中尝试访问super会报错。class Child extends Parent { sayHi () { // 这是实例属性箭头函数的写法不是原型方法 console.log(super.sayHi()); // SyntaxError: super keyword unexpected here }; }正确的做法是使用普通函数语法来定义需要访问super的方法。5. 从继承看JavaScript高级特性闭包、作用域与This绑定继承不是一个孤立的概念它与JavaScript的其他核心特性紧密相连。理解这些联系能让你更深刻地把握这门语言。5.1 继承与闭包的结合场景闭包是指函数能够记住并访问其词法作用域即使该函数在其词法作用域之外执行。在实现继承时我们有时会利用闭包来创建“私有”变量。function Parent(secret) { // 私有变量通过闭包访问 const privateSecret secret; // 特权方法可以访问私有变量的公有方法 this.getSecret function() { return privateSecret; }; } Parent.prototype.publicMethod function() { // 这里无法直接访问 privateSecret console.log(I can only call getSecret:, this.getSecret()); }; function Child(secret, childProp) { Parent.call(this, secret); this.childProp childProp; } // 使用寄生组合式继承 inheritPrototype(Child, Parent); const child new Child(top-secret, hello); console.log(child.getSecret()); // top-secret console.log(child.privateSecret); // undefined child.publicMethod(); // I can only call getSecret: top-secret分析这里privateSecret是Parent构造函数作用域内的一个局部变量。getSecret函数形成了一个闭包使得每个通过new Parent或new Child创建的实例都能通过自己的getSecret方法访问到各自独立的privateSecret副本。这是一种模拟私有变量的模式。在继承时通过构造函数继承Parent.call(this, secret)子类实例也获得了自己的闭包环境从而拥有了独立的“私有”变量。5.2 方法重写与Super调用中的This问题在子类重写父类方法时this的指向需要特别注意。class Parent { constructor() { this.name Parent; } getName() { return this.name; } } class Child extends Parent { constructor() { super(); this.name Child; } getName() { // 如果直接调用父类原型上的方法this可能出错 // const parentName Parent.prototype.getName(); // 错误this指向全局或undefined const parentName super.getName(); // 正确super.getName() 内部的this指向当前子类实例 return Child: ${this.name}, Parent said: ${parentName}; } } const obj new Child(); console.log(obj.getName()); // Child: Child, Parent said: Child关键点使用super.method()的方式调用父类方法JavaScript引擎会确保method函数内部的this绑定到当前的子类实例上。如果使用Parent.prototype.method()直接调用你需要手动绑定this如Parent.prototype.getName.call(this)否则getName内部的this将不再指向子类实例导致错误。5.3 继承链与属性屏蔽Shadowing当子类实例访问一个属性时JavaScript引擎会沿着原型链查找。如果实例自身有该属性则直接使用这称为“屏蔽”了原型链上的同名属性。function Parent() {} Parent.prototype.value 1; function Child() { this.value 2; // 屏蔽了原型上的 value } inheritPrototype(Child, Parent); const obj new Child(); console.log(obj.value); // 2来自自身属性 console.log(obj.hasOwnProperty(value)); // true delete obj.value; // 删除自身属性 console.log(obj.value); // 1现在来自原型链这个机制解释了为什么组合继承中实例自身的属性来自Parent.call会屏蔽掉原型对象上同名的属性来自new Parent()。这也是JavaScript动态性的一个体现你可以随时修改或屏蔽原型链上的行为但要谨慎使用因为它会影响所有依赖该原型的对象。6. 面向对象设计实践何时使用与如何选择继承方案学完了所有技术细节最后我们要回答一个更根本的问题在实际项目中我们到底该不该用继承该怎么用6.1 组合优于继承Composition over Inheritance这是软件工程中一条著名的原则。它的核心思想是优先使用对象组合/聚合而不是类继承来达到代码复用的目的。为什么继承会带来很强的耦合性。子类与父类紧密绑定父类的任何内部实现变更都可能“撕裂”子类Fragile base class problem。而且继承关系是静态的在编译时或代码写定时就确定了缺乏灵活性。组合则是将功能单元作为独立的类或对象然后在需要的类中持有它们的实例通过委托来调用功能。举例说明假设我们有一个Robot类需要行走和攻击功能。继承的思路Robot继承自Walker和AttackerJavaScript不支持多继承会很别扭。或者创建一个WalkingAttackingRobot父类但如果有新的SwimmingRobot呢类爆炸。组合的思路class Walker { walk() { console.log(Walking...); } } class Attacker { attack() { console.log(Attacking...); } } class Robot { constructor() { this.walker new Walker(); this.attacker new Attacker(); } operate() { this.walker.walk(); this.attacker.attack(); } } // 如果需要会游泳的机器人很简单 class Swimmer { swim() { console.log(Swimming...); } } class AmphibiousRobot { constructor() { this.walker new Walker(); this.attacker new Attacker(); this.swimmer new Swimmer(); } operate() { this.walker.walk(); this.attacker.attack(); this.swimmer.swim(); } }组合的方式更灵活耦合度低功能模块可以独立测试和替换。在JavaScript这种动态语言中组合的优势更加明显。6.2 合理使用继承的场景尽管组合更受推崇但继承并非一无是处。在以下场景继承仍然是清晰且合理的选择清晰的“是一个is-a”关系当子类确实是父类的一种特殊类型并且两者在概念上有严格的层次关系时。例如Square正方形extendsRectangle矩形Cat猫extendsAnimal动物。这种关系稳定不易变化。需要多态当你希望统一通过父类接口操作一系列子类对象并依赖运行时动态决定具体行为时继承提供的原型链机制能天然支持。框架或库的设计在提供基础类供用户扩展时继承是常见的模式。例如React的Component类Vue的Vue构造函数。用户通过继承这些基类快速获得框架的核心能力。我的经验法则在动手写extends之前先问自己“B 真的是 A 的一种吗未来会不会有 C它既是 A 又是 B 的变体” 如果答案不确定优先考虑组合。对于UI组件、业务模型等易变的部分我几乎总是选择组合或混入Mixin模式。对于像错误类型ValidationErrorextendsError这样稳定且层次明确的结构我会放心使用继承。6.3 在现代JavaScript项目中的选择对于今天的项目我的建议非常明确语法层面一律使用 ES6class和extends。它语法简洁意图明确底层实现高效寄生组合继承工具链支持好TypeScript、Babel等。不要再手动写寄生组合继承的样板代码了。设计层面谨慎评估是否真的需要继承。多用组合少用继承。对于代码复用可以优先考虑函数组合将小功能封装成纯函数。模块导入利用ES6 Modules导入功能模块。对象混合使用Object.assign()或展开运算符...来混合功能。依赖注入将依赖作为参数传入。JavaScript的灵活性给了我们很多工具继承只是其中之一而且往往是威力较大但需要谨慎使用的那一个。理解其原理是为了在正确的时机以正确的方式使用它而不是被它束缚住设计的思路。
返回列表