“继承”这道菜,你还在直接上手?JS 原型链的正确打开方式

36 阅读6分钟

“继承”这道菜,你还在直接上手?JS 原型链的正确打开方式


在前端工程师的面试中,JavaScript 的继承机制几乎是必考题。无论是阿里 P7 的“手写继承”,还是字节跳动的“原型链深度解析”,都离不开对 prototype__proto__、构造函数调用等核心概念的理解。

今天,我们就从一段看似“能跑就行”的继承代码出发,一步步拆解 JavaScript 继承的本质,并深入探讨:为什么很多人的继承写法其实存在严重隐患?如何写出既安全又可维护的继承结构?以及,大厂面试官到底想考察什么?


一、问题引入:你以为的“继承”,其实是“共享污染”

先看第一段代码:

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.species = '动物';

function Cat(name, age, color) {
    Animal.apply(this, [name, age]);
}

Cat.prototype = Animal.prototype; // ⚠️ 危险操作!
Cat.prototype.constructor = Cat;

const cat = new Cat('加菲猫', 2, '黄色');

这段代码看起来实现了继承:Cat 能访问 species,也能通过 apply 调用父类构造函数初始化属性。

但问题出在这一行:

Cat.prototype = Animal.prototype;

❌ 错误点:直接赋值导致原型共享

这意味着 Cat.prototypeAnimal.prototype 指向同一个对象—— 即 Animal 函数的原型对象(Animal.prototype 本身)。一旦你在 Cat.prototype 上添加方法(比如 Cat.prototype.meow = ...),Animal 的所有实例也会拥有这个方法!

Cat.prototype.say = function() { console.log('喵喵喵'); };
const dog = new Animal('旺财', 3);
dog.say(); // 🤯 居然能调用!这不是狗该有的技能啊!

这违背了继承的基本原则:子类扩展不应影响父类

大厂面试高频考点
“请指出 Child.prototype = Parent.prototype 的问题,并给出正确写法。”


二、进阶方案:空函数中介 —— 隔离原型链污染

为了解决上述问题,第二、三段代码引入了一个经典技巧:使用空函数作为中介

var F = function () {};
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.constructor = Cat;

🔍 原理剖析:为什么 new F() 能隔离?

  • F 是一个空构造函数,不执行任何逻辑。

  • F.prototype = Animal.prototype:让 F 的原型指向 Animal.prototype

  • new F() 创建一个新对象,其 __proto__ 指向 Animal.prototype,但自身是一个独立对象

  • 因此,Cat.prototype = new F() 后:

    • cat instanceof Cat → true
    • cat instanceof Animal → true
    • Cat.prototype 上添加方法,不会污染 Animal.prototype

这就是经典的 “寄生组合式继承” 的雏形。这种方式是寄生组合式继承中用于安全设置原型链的关键技巧

🔍 延伸知识:Object.create vs 空函数 —— 以及对象与函数的本质差异 ES5 引入了 Object.create(proto) 方法,让我们能更语义化地创建一个以指定对象为原型的新对象。因此,我们可以用:

Cat.prototype = Object.create(Animal.prototype);

替代传统的空函数中介写法(new F()),不仅代码更简洁,意图也更明确: “我要一个新对象,它的原型是 Animal.prototype

但这里有一个常被忽略的底层细节:为什么早期要用“空函数”而不是直接用普通对象?这背后其实涉及 JavaScript 中“对象”和“函数”的根本区别 —— 它们的原型链起点不同。

🧬 关键点:Object 和 Function 的原型不一样

在 JavaScript 中:

  • 所有普通对象(如 {}new Object())的原型是 Object.prototype
  • 所有函数(包括构造函数、箭头函数等)的原型是 Function.prototype,而 Function.prototype 本身又继承自 Object.prototype

这意味着:

({}).__proto__ === Object.prototype;        // true
(function(){}).__proto__ === Function.prototype; // true
Function.prototype.__proto__ === Object.prototype; // true

当我们使用 new F() 创建实例时,虽然 F 是空函数,但 new F() 返回的是一个普通对象(其 __proto__ 指向 F.prototype)。而 F.prototype 被我们手动设置为 Animal.prototype,于是这个新对象就“挂”在了正确的原型链上。

如果直接用字面量对象 {}Object.create(...),虽然也能达到类似效果,但必须确保新对象的 constructor 被正确重置,否则会丢失构造器信息。

💡 补充:Object.create(null) 会创建一个完全没有原型的对象(即 __proto__null),常用于实现纯净的哈希表(如缓存、字典),避免 hasOwnPropertytoString 等原型方法造成干扰。

✅ 最佳实践建议

现代开发中,优先使用 Object.create 实现原型继承:

function extend(Parent, Child) {
    Child.prototype = Object.create(Parent.prototype);
    Child.prototype.constructor = Child; // 修复 constructor
}

它既避免了手动定义空函数的冗余,又清晰表达了“基于原型创建”的意图,同时天然规避了函数与对象原型差异带来的潜在混淆。

通过理解“object 和 function 的原型不一样”,我们不仅能写出更安全的继承代码,还能深入把握 JavaScript 对象模型的设计哲学:万物皆对象,但函数是特殊的对象


三、封装成通用函数:写出可复用的继承工具

第四段代码将上述逻辑封装为 extend 函数:

function extend(Parent, Child) {
    var F = new Function(); // 等价于 function(){}
    F.prototype = Parent.prototype;
    Child.prototype = new F();
    Child.prototype.constructor = Child;
}

✅ 优点:

  • 复用性强
  • 隔离原型污染
  • 修复 constructor 指向(否则 cat.constructor === Animal

📌 设计模式视角
这种“中介对象”思想,本质上是 代理模式(Proxy)桥接模式(Bridge) 的体现——通过中间层解耦两个对象的直接依赖。


四、属性遮蔽(Shadowing):你改的是实例属性,不是原型!

第三段独立代码揭示了一个重要概念:

function Cat() {}
Cat.prototype.species = '猫科动物';
const cat = new Cat();
cat.species = 'hello'; // 👈 注意!这是给实例加属性
console.log(cat.species); // 'hello'
console.log(Cat.prototype.species); // '猫科动物'(未变)

🔑 关键点:属性查找顺序

当访问 cat.species 时,JS 引擎按以下顺序查找:

  1. 实例自身属性(cat.hasOwnProperty('species') → true)
  2. cat.__proto__(即 Cat.prototype
  3. Cat.prototype.__proto__(即 Object.prototype
  4. ……直到 null

因此,给实例赋值不会修改原型,而是“遮蔽(shadow)”了原型上的同名属性。

💡 面试加分项
如果被问“如何真正修改原型上的属性?”,回答:

Cat.prototype.species = '新值';
// 或
Object.getPrototypeOf(cat).species = '新值';

五、现代 JavaScript 的继承:class 语法糖下的真相

以上代码基于 ES5 构造函数,但必须提一句:ES6 的 class 本质仍是原型继承

class Animal {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
}
Animal.prototype.species = '动物';

class Cat extends Animal {
    constructor(name, age, color) {
        super(name, age);
        this.color = color;
    }
    eat() { console.log("eat jerry"); }
}

现代 JavaScript 引擎和 Babel 在实现 class extends 时,底层仍基于 Object.createObject.setPrototypeOf 来链接原型链,其思想与我们手动实现的寄生组合继承一脉相承。包括使用 _inherits 工具函数实现原型链链接。

结论:理解 ES5 继承,是掌握 ES6 class 的基石。


六、高频面试题 & 总结

🧠 面试官可能追问:

  1. 组合继承 vs 寄生组合继承的区别?

    • 组合继承:Child.prototype = new Parent() → 会调用两次父类构造函数(一次在设置原型时,一次在子类构造中)。
    • 寄生组合继承:通过空函数中介,只调用一次,最优解
  2. 如何判断一个对象是否继承自某个构造函数?

    • instanceof
    • Object.prototype.isPrototypeOf()
    • constructor(不可靠,易被覆盖)
  3. 原型链过长会影响性能吗?

    • 会!属性查找是线性遍历,深度越大越慢。合理设计继承层级。

结语:继承不是“复制”,而是“委托”

JavaScript 的继承本质是基于原型的委托机制,而非类的拷贝。理解 __proto__prototype 的关系,掌握安全的继承写法,不仅能写出健壮代码,更能从容应对大厂面试中的“原型链连环问”。

记住
直接赋值原型 = 共享污染
空函数中介 = 安全隔离
属性赋值 = 实例遮蔽,非原型修改

下次写继承时,别再“直接上手”了——用对方法,才能写出经得起 review 的代码。