ARTICLE DETAIL

资讯详情

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

深入解析JavaScript原型链继承:从原理到ES6 Class的底层实现

深入解析JavaScript原型链继承:从原理到ES6 Class的底层实现 1. 从“对象”到“继承”为什么我们需要原型链如果你写过一段时间的JavaScript尤其是从其他语言比如Java、C#转过来的大概率会对JS的“面向对象”感到困惑。我们明明可以用function定义一个“类”用new关键字创建“实例”看起来和经典OOP语言很像。但当你试图实现一个简单的“继承”时麻烦就来了为什么我定义在父类function里的方法子类的实例访问不到为什么修改了父类的原型子类的实例也跟着变了这一切的根源都指向了JavaScript中一个独特且核心的机制——原型链Prototype Chain。在ES6的class语法糖普及之前理解原型链是掌握JavaScript面向对象编程的基石。即便现在class用起来很顺手但它的底层依然是原型链在运作。很多看似诡异的行为比如instanceof的原理、for...in循环的遍历范围以及一些高级技巧如混入Mixin、对象关联都离不开对原型链的深刻理解。今天我们就抛开class回到最本质的JavaScript彻底拆解“原型链继承”是如何工作的它的优势在哪又有哪些“坑”在等着我们。2. 原型与原型链JavaScript的“血脉”系统要理解继承必须先理解原型。在JavaScript中每个函数除了箭头函数和某些特殊函数都有一个prototype属性它是一个对象。当你使用new操作符调用这个函数时会创建一个新对象这个新对象的内部[[Prototype]]在大多数浏览器中可以通过__proto__属性访问会指向构造函数的prototype对象。这听起来有点绕我们来看代码function Person(name) { this.name name; } // 在Person的prototype上添加一个方法 Person.prototype.sayHello function() { console.log(Hello, Im ${this.name}); }; const alice new Person(Alice); const bob new Person(Bob); alice.sayHello(); // 输出: Hello, Im Alice bob.sayHello(); // 输出: Hello, Im Bob这里发生了什么Person是一个构造函数它有一个prototype属性是一个空对象最初只有constructor属性指回Person。我们给Person.prototype添加了一个sayHello方法。通过new Person(Alice)创建alice对象。alice对象本身只有一个属性name: Alice。当调用alice.sayHello()时JavaScript引擎首先在alice对象自身查找sayHello属性没找到。接着引擎会沿着alice.__proto__即Person.prototype去查找找到了于是调用该方法并且函数内部的this指向alice对象。这个查找的路径——从对象自身到它的__proto__再到__proto__.__proto__直至null——就是原型链。null是原型链的终点。这就像一条家族血脉对象自身没有的能力可以向上溯源从它的“祖先”原型对象那里继承。注意__proto__是一个非标准但被广泛实现的属性用于访问对象的内部[[Prototype]]。在ES5中更推荐使用Object.getPrototypeOf(obj)来获取用Object.setPrototypeOf(obj, prototype)来设置。但理解__proto__这个直观概念对学习很有帮助。3. 实现原型链继承连接两条“血脉”理解了单个构造函数的原型我们就可以让两个构造函数父类和子类的原型链连接起来实现继承。核心目标是让子类实例的原型链上能找到父类原型上的属性和方法。最经典也是问题最多的实现方式如下// 父类构造函数 function Animal(name) { this.name name; this.colors [black, white]; } Animal.prototype.sayName function() { console.log(My name is ${this.name}); }; // 子类构造函数 function Dog(name, breed) { // 1. 调用父类构造函数继承实例属性 Animal.call(this, name); // 关键这步继承“实例属性” this.breed breed; } // 2. 核心步骤将子类的原型指向父类的一个实例 Dog.prototype new Animal(); // 这步建立了原型链连接 // 3. 修正子类原型对象的constructor指针 Dog.prototype.constructor Dog; // 4. 在子类原型上添加自己的方法 Dog.prototype.bark function() { console.log(Woof! Woof!); }; // 测试 const myDog new Dog(Buddy, Golden Retriever); myDog.sayName(); // 输出: My name is Buddy (从Animal.prototype继承) myDog.bark(); // 输出: Woof! Woof! (Dog.prototype自有) console.log(myDog instanceof Dog); // true console.log(myDog instanceof Animal); // true (原型链生效)我们来拆解每一步的意图和潜在问题第一步在子类构造函数中调用父类构造函数 (Animal.call(this, name))这行代码解决了“实例属性”的继承。它在新创建的Dog实例this的上下文中执行Animal函数从而将Animal中定义的this.name和this.colors属性“复制”到Dog实例上。如果不做这一步Dog实例将不会有name和colors属性。第二步Dog.prototype new Animal()这是实现原型链继承的关键。它将Dog.prototype这个对象替换成了Animal的一个实例。这意味着Dog.prototype的原型链__proto__指向了Animal.prototype。因此所有Dog的实例如myDog在查找属性时如果在自身和Dog.prototype上找不到就会继续沿着Dog.prototype.__proto__即Animal.prototype向上找从而访问到sayName方法。第三步Dog.prototype.constructor Dog由于第二步完全重写了Dog.prototype导致Dog.prototype.constructor属性丢失转而指向了Animal。这不符合预期一个Dog实例的构造器应该是Dog所以我们需要手动修正它。虽然这个属性在语言内部使用不多但保持正确是一种良好的实践某些库或代码可能会依赖它。第四步在子类原型上添加方法这一步必须在完成原型重写和修正constructor之后进行否则新添加的方法会被覆盖掉。4. 经典原型链继承的致命缺陷与实战避坑上面看似完美的实现在实际项目中却隐藏着几个严重的“坑”。我早期在项目中使用这种模式时就曾因此导致过难以排查的Bug。4.1 缺陷一父类实例属性成为共享的“原型属性”这是最经典的问题。看下面的代码const dog1 new Dog(Dog1, Breed1); const dog2 new Dog(Dog2, Breed2); dog1.colors.push(brown); console.log(dog1.colors); // [black, white, brown] console.log(dog2.colors); // [black, white, brown] !!! 问题出现为什么dog2的colors也被修改了因为我们在第二步Dog.prototype new Animal()时创建了一个父类Animal的实例作为子类的原型。这个实例有自己的colors属性值为[black, white]。注意这个属性是存在于Dog.prototype这个对象自身上的而不是Dog.prototype.__proto__即Animal.prototype上。对于所有Dog的实例dog1,dog2来说当它们访问colors属性时自身没有就会去Dog.prototype上找而Dog.prototype上正好有一个colors数组。于是所有实例都共享了同一个数组引用修改其中一个自然会影响所有。解决方案这个问题的根源在于我们把本该是“实例属性”的colors通过new Animal()无意中变成了子类所有实例共享的“原型属性”。解决方法就是避免在创建子类原型时调用父类构造函数。这正是“构造函数继承”第一步和“原型链继承”第二步需要组合使用的原因。第一步的Animal.call(this, name)已经为每个实例单独创建了colors属性所以我们应该让Dog.prototype上不要有colors属性。改进方法见下一节。4.2 缺陷二无法向父类构造函数传参在Dog.prototype new Animal()这一行我们创建父类实例时无法传递参数。在上面的例子中我们调用的是new Animal()参数是空的。这意味着如果父类构造函数有一些必需的参数逻辑在这里就无法执行。虽然这通常不影响原型方法的继承但不够优雅也可能在某些场景下引发问题。4.3 缺陷三性能与设计瑕疵每次创建子类构造函数时都需要调用一次父类构造函数来创建原型对象new Animal()。如果父类构造函数执行了复杂的操作如DOM操作、网络请求这可能会带来不必要的性能开销。而且从设计上讲子类的原型对象本身不应该是一个“实例”它应该是一个干净的对象只用于挂载子类共享的方法。5. 优化方案组合继承与寄生组合式继承为了解决上述缺陷社区在实践中演化出了更好的模式。5.1 组合继承Combination Inheritance组合继承结合了“构造函数继承”和“原型链继承”是早期最常用的模式。function Animal(name) { this.name name; this.colors [black, white]; } Animal.prototype.sayName function() { console.log(this.name); }; function Dog(name, breed) { // 1. 构造函数继承继承实例属性 Animal.call(this, name); // 第二次调用父类构造函数 this.breed breed; } // 2. 原型链继承继承原型方法 Dog.prototype new Animal(); // 第一次调用父类构造函数 Dog.prototype.constructor Dog; Dog.prototype.bark function() { console.log(Woof!); }; const dog1 new Dog(Max, Husky); const dog2 new Dog(Rocky, Labrador); dog1.colors.push(gray); console.log(dog1.colors); // [black, white, gray] console.log(dog2.colors); // [black, white] // 问题解决 console.log(dog1.sayName dog2.sayName); // true方法共享优点解决了引用类型属性共享的问题colors现在是每个实例自己的。子类实例既能拥有独立的实例属性又能共享父类原型上的方法。缺点父类构造函数被调用了两次一次在创建子类原型时new Animal()一次在创建子类实例时Animal.call(this, ...)。这导致子类原型对象Dog.prototype上也有一份父类的实例属性name和colors但这些属性被实例自身的属性屏蔽了造成了不必要的冗余。5.2 寄生组合式继承Parasitic Combination Inheritance—— 终极方案这是目前公认的、基于原型链的最理想的继承方式。ES6的class语法糖在Babel等工具转译成ES5代码时使用的就是这种模式。function inheritPrototype(subType, superType) { // 1. 创建一个以父类原型为原型的新对象 const prototype Object.create(superType.prototype); // 关键没有调用构造函数 // 2. 修正这个新对象的constructor指针 prototype.constructor subType; // 3. 将这个新对象赋值给子类的原型 subType.prototype prototype; } function Animal(name) { this.name name; this.colors [black, white]; } Animal.prototype.sayName function() { console.log(this.name); }; function Dog(name, breed) { // 只在这里调用一次父类构造函数继承实例属性 Animal.call(this, name); this.breed breed; } // 使用辅助函数实现原型链连接避免调用父类构造函数 inheritPrototype(Dog, Animal); // 添加子类自己的方法 Dog.prototype.bark function() { console.log(Woof!); }; // 测试 const dog1 new Dog(Charlie, Poodle); const dog2 new Dog(Daisy, Beagle); dog1.colors.push(gold); console.log(dog1.colors); // [black, white, gold] console.log(dog2.colors); // [black, white] console.log(dog1 instanceof Dog); // true console.log(dog1 instanceof Animal); // true核心剖析Object.create(superType.prototype)是这里的魔法。它创建了一个全新的空对象并将这个空对象的[[Prototype]]即__proto__直接设置为superType.prototype。这样我们得到了一个纯净的、只连接了父类原型链的对象用它作为子类的原型。完美避免了在子类原型上创建不必要的父类实例属性也只需要调用一次父类构造函数。优点只调用一次父类构造函数效率高。原型链保持干净子类原型上不再有冗余的父类实例属性。是引用类型属性继承的最优解。6. 原型链继承的边界与instanceof的奥秘理解了原型链instanceof操作符的原理就一目了然了。obj instanceof Constructor的逻辑是沿着obj的__proto__链向上查找如果某个节点的__proto__等于Constructor.prototype则返回true否则直到null返回false。console.log(myDog instanceof Dog); // true // 检查: myDog.__proto__ Dog.prototype? 是返回true。 console.log(myDog instanceof Animal); // true // 检查: myDog.__proto__ Animal.prototype? 否 (myDog.__proto__是Dog.prototype) // 继续: myDog.__proto__.__proto__ Animal.prototype? 是返回true。 console.log(myDog instanceof Object); // true // 原型链: myDog - Dog.prototype - Animal.prototype - Object.prototype - null这也解释了为什么直接修改一个对象的__proto__或使用Object.setPrototypeOf可以动态改变其行为但这是一个非常慢的操作通常不推荐在生产中使用。7. 从原型链到ES6 Class语法糖下的本质ES6引入的class关键字让面向对象编程在语法上清晰了很多。class Animal { constructor(name) { this.name name; this.colors [black, white]; } sayName() { console.log(this.name); } } class Dog extends Animal { constructor(name, breed) { super(name); // 相当于之前的 Animal.call(this, name) this.breed breed; } bark() { console.log(Woof!); } }class语法并没有引入新的继承模型它只是上述寄生组合式继承的语法糖。extends关键字自动帮我们建立了正确的原型链。super()调用对应着在子类构造函数中调用父类构造函数。类中定义的方法实际上就是添加到类的prototype属性上。你可以用Object.getPrototypeOf来验证console.log(Object.getPrototypeOf(Dog.prototype) Animal.prototype); // true因此学习原型链继承不仅是为了理解历史代码更是为了透彻理解现代JavaScript面向对象编程的根基。当你遇到class相关的问题比如super的使用限制、静态方法的继承时回溯到原型链的视角往往能豁然开朗。
返回列表