如果你问一个前端工程师“JavaScript 里最难讲清楚的是什么”,答案大概率是原型与继承。它不像闭包那样有个直观的名字,也不像事件循环那样能画成一张流程图——它藏在每一个对象背后,你写 arr.map() 的时候它在工作,你写 class A extends B 的时候它在工作,你打印一个对象看到一串“看不见的属性”时它也在工作。原型是 JavaScript 唯一的继承机制,也是理解这门语言对象模型的唯一入口。不夸张地说,把原型吃透,你对 JS 的认知会从“会写”跃迁到“知道为什么”。这篇长文会从最基本的 [[Prototype]] 讲起,一路走到 class 的语法糖真相、六种继承方案的演进史、原型污染的安全风险,以及 V8 引擎在背后做的性能优化。建议留出完整的时间,边读边在控制台里敲一遍——这篇内容值得你花 40 分钟。
一、为什么 JavaScript 选择了原型
要理解原型,先要理解 JavaScript 的设计出发点。1995 年,Brendan Eich 受命为 Netscape 浏览器设计一门“看起来像 Java 的脚本语言”,但他只有十天时间。他没有照搬 Java 的类继承体系,而是借鉴了 Self 语言——一门基于原型的面向对象语言。
这带来一个根本性的差异:
所以在 JavaScript 里,对象不是从类里“造”出来的,而是从另一个对象那里“继承”来的。每一个对象都有一个隐藏的引用,指向它的“父对象”。当你访问一个属性,如果自己身上没有,就去父对象那里找;父对象没有,就去父对象的父对象那里找——这条链就是原型链。
三个容易混淆的概念,先分清楚
在往下走之前,必须先厘清三个长得像但完全不同的东西。这是初学者最大的坑:
| 名称 | 是什么 | 谁有 | 怎么访问 |
|---|---|---|---|
[[Prototype]] |
对象的内部槽,指向它的原型对象 | 所有对象 | Object.getPrototypeOf(obj) |
__proto__ |
一个历史遗留的访问器属性,可读写 [[Prototype]] |
继承了 Object.prototype 的对象 |
obj.__proto__(不推荐) |
prototype |
一个普通属性,值是对象,将来会成为实例的原型 | 只有函数(箭头函数除外) | Fn.prototype |
Fn.prototype 就是 Fn 的原型。”——错。Fn.prototype 是将来由 new Fn() 创建出来的那些实例的原型,跟 Fn 自己的原型完全是两回事。
Fn.prototype 是“妈妈给孩子准备的嫁妆”,Object.getPrototypeOf(Fn) 才是“Fn 自己的爸爸”。
// Foo 作为对象,它自己的原型是谁?
Object.getPrototypeOf(Foo) === Function.prototype; // true
// Foo.prototype 是给实例用的
const f = new Foo();
Object.getPrototypeOf(f) === Foo.prototype; // true
// 三者互不相等
Foo.prototype !== Object.getPrototypeOf(Foo); // true
记住这张图,后面所有的内容都建立在这个区分之上。
二、[[Prototype]]:属性查找的完整过程
理解了 [[Prototype]],就能理解 JavaScript 中几乎所有“对象行为”的来源。当你写 obj.foo 的时候,引擎执行的是这样一个算法:
Object.hasOwn(obj, 'foo') 检查对象自身是否有这个属性。有就直接返回,查找结束,不会继续往上找。[[Prototype]] 走到原型对象,重复第 1 步。这个原型对象自己也有 [[Prototype]],于是形成一条链。[[Prototype]] 是 null。这条链的长度理论上没有限制,但实践中不会超过几层。null 都没找到,返回 undefined。亲手把这条链“点亮”
下面是一个真实的原型链。点击“逐级查找”,观察 p.greet() 是怎么找到方法的。
this.name = name; // 自身属性
}
Person.prototype.greet = function () {
return '你好,' + this.name;
};
const p = new Person('张玥');
p.greet(); // 这个调用经过了哪些对象?
遮蔽(Shadowing):自身属性永远优先
如果实例上写了一个跟原型同名的属性,原型上的那个就被“遮住”了:
const a = new Person('张玥');
const b = new Person();
a.name; // '张玥' —— 自身属性遮蔽了原型属性
b.name; // '默认名字' —— 自身没有,去原型上找
Object.hasOwn(a, 'name'); // true
Object.hasOwn(b, 'name'); // false
'name' in b; // true —— in 运算符会查原型链
Object.hasOwn() 与 in 的区别,是判断“自身属性”和“可访问属性”的关键。前者只看自己,后者会沿着原型链一直找。旧代码里常见 obj.hasOwnProperty(k),但这个方法本身可能被覆盖,也不适用于 Object.create(null) 造出来的对象,所以现在推荐 Object.hasOwn()。
getPrototypeOf 与 setPrototypeOf
const child = {};
// 读:永远用 getPrototypeOf
Object.getPrototypeOf(child) === Object.prototype; // true
// 写:setPrototypeOf 会拖慢性能,避免在热路径上使用
Object.setPrototypeOf(child, parent);
child.greet(); // 'hi'
// 更好的做法:创建时就指定原型
const child2 = Object.create(parent);
// 判断“是不是原型”:isPrototypeOf 沿链检查
parent.isPrototypeOf(child2); // true
// 彻底断链,得到一个纯净对象
Object.setPrototypeOf(child2, null);
Object.getPrototypeOf(child2); // null
关于 __proto__:它确实能读写原型,但它其实是定义在 Object.prototype 上的一个访问器属性。这意味着两件事:一是 Object.create(null) 得到的对象没有它;二是如果你不小心把一个对象当作字典,用户传进来的键名恰好是 __proto__,就可能造成安全问题(后面第十二节会详细讲)。生产代码里请一律使用 Object.getPrototypeOf / Object.setPrototypeOf / Object.create。
三、构造函数、prototype 与 new 的真相
在没有 class 关键字的年代,JavaScript 靠“构造函数 + prototype”来模拟类。这套机制至今仍是 class 的底层实现,必须搞清楚。
函数天生自带 prototype
当你声明一个普通函数时,引擎会悄悄做两件事:
- 给这个函数对象加一个
prototype属性,值是一个新对象。 - 这个新对象上有一个
constructor属性,指回函数本身。
typeof Person.prototype; // 'object'
Person.prototype.constructor === Person; // true
// 箭头函数没有 prototype
const arrow = () => {};
arrow.prototype; // undefined
// 简写方法也没有
const obj = { method() {} };
obj.method.prototype; // undefined
// 内置函数大多有
typeof Array.prototype; // 'object'
new 到底做了什么
点击下面四个步骤,看清 new Person('张玥') 的完整过程。
{}。此时它还没有任何属性,[[Prototype]] 是 Object.prototype。[[Prototype]] 指向构造函数的 prototype 属性,也就是 Person.prototype。这一步是继承的关键。this,执行构造函数体。构造函数里写的 this.name = name 因此落在了新对象自己身上。手写一个 myNew
理解了这四步,你自己就能实现 new:
// 1 + 2:创建对象并链接原型
const obj = Object.create(Ctor.prototype);
// 3:以 obj 为 this 执行构造函数
const result = Ctor.apply(obj, args);
// 4:返回对象则用它,否则用 obj
return (result !== null && typeof result === 'object') ? result : obj;
}
// 验证
function Cat(name) { this.name = name; }
const c = myNew(Cat, '橘子');
c.name; // '橘子'
c instanceof Cat; // true
返回值陷阱
function A() { return 42; }new A() 的结果不是 42,而是一个空对象。因为原始值会被丢弃。
function B() { return { x: 1 }; }new B() 的结果就是 { x: 1 },原型链接被完全绕过。
这个特性偶尔被用来实现“单例模式”:构造函数里返回一个缓存好的对象,多次 new 得到同一个实例。
方法应该放在 prototype 上
这是构造函数模式最重要的实践原则:
function User(name) {
this.name = name;
this.say = function () {
return this.name;
};
}
const u1 = new User('a');
const u2 = new User('b');
u1.say === u2.say; // false,浪费内存
function User(name) {
this.name = name;
}
User.prototype.say = function () {
return this.name;
};
const u1 = new User('a');
const u2 = new User('b');
u1.say === u2.say; // true,所有实例共享
规则很简单:每个实例都不同的数据放构造函数里(this.xxx),所有实例共享的行为放 prototype 上。
四、原型链的终点在哪里
任何一条原型链,最终都会走到 null。理解链的尽头,才能理解为什么有些属性“人人都有”。
Object.getPrototypeOf(arr) === Array.prototype; // true
Object.getPrototypeOf(Array.prototype) === Object.prototype; // true
Object.getPrototypeOf(Object.prototype) === null; // true —— 链的终点
// 所以数组能用这些方法,是因为它们都定义在 Object.prototype 上
arr.toString();
arr.hasOwnProperty('length');
// 而 map / filter 来自 Array.prototype
Object.hasOwn(Array.prototype, 'map'); // true
一张完整的链地图
Object.prototype → nullArray.prototype → Object.prototype → nullFunction.prototype → Object.prototype → nullFoo.prototype → Object.prototype → nullnull,没有任何继承的方法String.prototype → Object.prototype → null注意最后一条:'abc'.toUpperCase() 能工作,是因为访问属性时引擎会临时把原始值包装成对象(new String('abc')),访问完立刻销毁。这就是所谓的“装箱”。Number、Boolean、Symbol 同理。null 和 undefined 没有任何包装对象,所以访问它们的属性会直接抛错。
不要把 Object.prototype 当作“公共仓库”
曾经有一个流行的“猴子补丁”写法:给 Object.prototype 加工具方法。这是极其危险的做法,因为:
- 所有对象都会继承它,包括你依赖的第三方库内部的对象。
- 如果用
for...in遍历对象,这个属性会被枚举出来,造成“幽灵键”。 - 如果方法名与未来的标准冲突,会直接破坏页面。
Object.prototype.myMethod = function () {};
// ✅ 要扩展就用独立的工具函数或 Symbol
function myMethod(obj) { /* ... */ }
// ✅ 或者用 Symbol 避免命名冲突
const METHOD = Symbol('myMethod');
Object.prototype[METHOD] = function () {};
// Symbol 属性不会被 for...in 枚举,也不会出现在 JSON 中
五、创建对象的七种方式
JavaScript 创建对象的手段多到令人困惑,每一种背后都对应着不同的原型关系。
| 方式 | 原型是谁 | 适用场景 | 评价 |
|---|---|---|---|
对象字面量 {} |
Object.prototype |
配置、临时数据、命名空间 | 首选 |
new Object() |
Object.prototype |
几乎无场景 | 避免 |
构造函数 + new |
Fn.prototype |
需要多个同类实例 | 可用 |
Object.create(proto) |
指定的 proto | 精确控制原型、实现继承、纯净字典 | 强大 |
class |
Class.prototype |
现代面向对象代码 | 推荐 |
Object.assign({}, src) |
Object.prototype |
浅拷贝合并(不是继承) | 注意浅拷贝 |
{ ...src } |
Object.prototype |
浅拷贝合并 | 简洁 |
Object.create 的三种用法
const animal = {
breathe() { return '呼吸'; }
};
const dog = Object.create(animal);
dog.breathe(); // '呼吸'
Object.hasOwn(dog, 'breathe'); // false,来自原型
// 2. 创建纯净字典,不受原型污染影响
const dict = Object.create(null);
dict['__proto__'] = '安全'; // 这里只是一个普通字符串键
dict.toString; // undefined,没有继承任何东西
// 3. 带属性描述符创建,默认不可枚举
const obj = Object.create(Object.prototype, {
id: {
value: 1,
writable: false,
enumerable: false,
configurable: false
}
});
obj.id = 2; // 静默失败(严格模式下抛错)
obj.id; // 1
属性描述符:比原型更底层的一层
说到属性,就不能不提描述符。每个属性其实不是一个值,而是一组特性:
Object.getOwnPropertyDescriptor(o, 'a');
// {
// value: 1,
// writable: true,
// enumerable: true,
// configurable: true
// }
Object.defineProperty(o, 'b', {
get() { return this._b; },
set(v) { this._b = v * 2; },
enumerable: true
});
o.b = 10;
o.b; // 20
enumerable 这个特性特别重要:for...in 会遍历原型链上所有可枚举属性,而 Object.keys() 只看自身可枚举属性。这也是为什么内置原型上的方法不会被 Object.keys() 列出来——它们被定义为不可枚举。
六、instanceof、isPrototypeOf 与类型判断
instanceof 的语义其实非常简单:右边构造函数的 prototype,是否出现在左边对象的原型链上?
function Dog() {}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
const d = new Dog();
d instanceof Dog; // true
d instanceof Animal; // true —— Animal.prototype 在链上
d instanceof Object; // true
d instanceof Array; // false
手写 myInstanceOf
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
return false;
}
let proto = Object.getPrototypeOf(obj);
const target = Ctor.prototype;
while (proto !== null) {
if (proto === target) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}
instanceof 的四个坑
Array.prototype,iframeArr instanceof Array 为 false。用 Array.isArray() 代替。
false:'abc' instanceof String 是 false,因为原始值不是对象。
Fn.prototype = {} 之后,旧实例的 instanceof 立刻变成 false。
Symbol.hasInstance 自定义,也可能被恶意代码劫持。
class Even {
static [Symbol.hasInstance](n) {
return typeof n === 'number' && n % 2 === 0;
}
}
4 instanceof Even; // true
5 instanceof Even; // false
// isPrototypeOf:反向判断,更精确
Animal.prototype.isPrototypeOf(d); // true
Object.prototype.isPrototypeOf(d); // true
为什么 constructor 不可靠
const f = new Foo();
f.constructor === Foo; // true,看起来能用
// 但它只是原型上的一个普通属性,可以被改
Foo.prototype.constructor = Object;
f.constructor === Foo; // false —— 完全不可信
// 而且 Object.create(null) 的对象连 constructor 都没有
Object.create(null).constructor; // undefined
结论:判断类型优先用 Array.isArray、typeof、Object.prototype.toString.call(),或者干脆用 Symbol.toStringTag。只有在需要判断“继承关系”时才用 instanceof。
七、继承的演进史:六种方案的兴衰
这一节是本文的核心。理解这段历史,你就能看懂任何老代码里的继承写法,也能明白为什么 ES6 最终要引入 class。
方案一:原型链继承
this.colors = ['red', 'blue'];
}
Parent.prototype.say = function () { return 'parent'; };
function Child() {}
Child.prototype = new Parent(); // 关键一行
Child.prototype.constructor = Child;
const c1 = new Child();
const c2 = new Child();
c1.colors.push('green');
c2.colors; // ['red', 'blue', 'green'] —— 被污染了!
方案二:构造函数继承(借用构造函数)
this.name = name;
this.colors = ['red'];
}
Parent.prototype.say = function () { return 'parent'; };
function Child(name) {
Parent.call(this, name); // 借用父类构造函数
}
const c1 = new Child('a');
const c2 = new Child('b');
c1.colors.push('green');
c2.colors; // ['red'] —— 各自独立,问题解决
c1.say(); // ❌ TypeError:say 不存在
方案三:组合继承(最经典,但有缺陷)
把前两种结合起来——这是 ES6 之前最流行的写法,也是无数教程里的标准答案:
this.name = name;
this.colors = ['red'];
}
Parent.prototype.say = function () { return this.name; };
function Child(name, age) {
Parent.call(this, name); // 第一次调用父类构造函数
this.age = age;
}
Child.prototype = new Parent(); // 第二次调用父类构造函数
Child.prototype.constructor = Child;
const c = new Child('张玥', 28);
c.say(); // '张玥' —— 方法继承成功
c instanceof Parent; // true
组合继承看起来完美,但它有一个隐藏的浪费:父类构造函数被调用了两次。第一次在 Parent.call(this),第二次在 new Parent() 创建子类原型时。结果是子类原型上多了一份永远用不到的实例属性副本。
Object.hasOwn(Child.prototype, 'colors'); // true —— 原型上有一份多余的
Child.prototype.colors; // ['red'] 永远被实例属性遮蔽
方案四、五:原型式继承与寄生式继承
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
// 等价于 Object.create(o)
// 寄生式继承:在原型式基础上增强对象
function createAnother(original) {
const clone = Object.create(original);
clone.sayHi = function () { return 'hi'; };
return clone;
}
寄生式继承的问题和构造函数模式一样:方法定义在实例上,无法复用。
方案六:寄生组合继承(ES6 之前的终极答案
核心思路是:用 Object.create(Parent.prototype) 替代 new Parent(),只继承原型,不执行父类构造函数。这样既拿到了原型方法,又避免了二次调用。
function Parent(name) {
this.name = name;
this.colors = ['red'];
}
Parent.prototype.say = function () {
return this.name;
};
function Child(name, age) {
Parent.call(this, name); // ① 第一次调用
this.age = age;
}
Child.prototype = new Parent(); // ② 第二次调用(浪费)
Child.prototype.constructor = Child;
// 结果:Child.prototype 上多了一份无用的 colors
| 方案 | 实例属性独立 | 原型方法共享 | 可传参 | 父类调用次数 | 评价 |
|---|---|---|---|---|---|
| 原型链继承 | 否 | 是 | 否 | 1 | 不推荐 |
| 构造函数继承 | 是 | 否 | 是 | 1 | 不推荐 |
| 组合继承 | 是 | 是 | 是 | 2 | 可用但浪费 |
| 寄生组合继承 | 是 | 是 | 是 | 1 | ES5 最佳 |
| class extends | 是 | 是 | 是 | 1 | 现代首选 |
八、class 语法:糖衣之下的真相
ES6 的 class 被很多人误解为“JavaScript 终于有了类”。事实是:class 是纯粹的语法糖,底层依然是原型链。它没有引入任何新的对象模型。
证明 class 就是函数
constructor(name) { this.name = name; }
greet() { return 'hi ' + this.name; }
}
typeof Person; // 'function'
Person.prototype.greet; // [Function: greet]
Object.getPrototypeOf(Person) === Function.prototype; // true
// 手动实现完全等价的版本
function Person2(name) { this.name = name; }
Object.defineProperty(Person2.prototype, 'greet', {
value: function () { return 'hi ' + this.name; },
enumerable: false, // class 方法默认不可枚举
writable: true,
configurable: true
});
class 与构造函数的九个差异
new 调用,直接调用会抛 TypeError
constructor 属性本身不可写、不可枚举、不可配置
extends、super、static、私有字段等语法
name 属性不可写(但可配置)
extends 建立了两条链
这是理解 class 继承最关键的一点。class Child extends Parent 实际上同时做了两件事:
Child.prototype 的 [[Prototype]] 指向 Parent.prototype。效果:
new Child() 出来的实例能访问父类原型方法。
Child 本身的 [[Prototype]] 指向 Parent。效果:子类能继承父类的 静态方法。
static create() { return new this(); }
hello() { return 'parent'; }
}
class Child extends Parent {}
// 静态方法继承
Child.create; // [Function: create]
Object.getPrototypeOf(Child) === Parent; // true
// 实例方法继承
new Child().hello(); // 'parent'
Object.getPrototypeOf(Child.prototype) === Parent.prototype; // true
// 经典用法:静态工厂方法用 this,子类调用时创建子类实例
Child.create() instanceof Child; // true
注意 static create() 里的 new this()——因为 Child.create() 调用时 this 是 Child,所以创建的是子类实例。这是一个非常实用的模式。
九、super 的完整语义
super 是 class 里最容易被误用的关键字。它有两副面孔,行为完全不同。
面孔一:super() 调用父类构造函数
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // 必须先调用,才能用 this
this.breed = breed;
}
}
// 派生类不写 constructor 时,等价于自动生成:
// constructor(...args) { super(...args); }
为什么派生类里 this 必须在 super() 之后才能用?因为派生类的实例是由父类构造函数“创造”的。super() 执行时,才会创建 this 并把它绑定给当前构造函数。在此之前访问 this 会直接抛 ReferenceError。
constructor(name) {
this.name = name; // ❌ ReferenceError
super(name);
}
}
// ✅ 但可以先做不依赖 this 的事情
class Dog2 extends Animal {
constructor(name) {
const upper = name.toUpperCase(); // 合法
super(upper);
}
}
面孔二:super.method() 沿原型链查找
这是最微妙的部分。super.method() 不是“调用父类原型上的 method”,而是“从当前方法的 [[HomeObject]] 的原型开始查找 method,并用当前 this 调用它”。
greet() { return 'A'; }
}
class B extends A {
greet() { return 'B → ' + super.greet(); }
}
class C extends B {
greet() { return 'C → ' + super.greet(); }
}
new C().greet(); // 'C → B → A'
// super 中的 this 始终是当前实例
class Base {
who() { return this.constructor.name; }
}
class Sub extends Base {
who() { return super.who(); }
}
new Sub().who(); // 'Sub',不是 'Base'
第三条输出最能说明问题:super.who() 找到了 Base.prototype.who,但调用时的 this 仍然是 Sub 的实例。所以 this.constructor.name 是 'Sub'。super 决定的是“去哪里找方法”,不改变“用谁当 this”。
对象字面量里的 super
greet() { return 'base'; }
};
const derived = {
__proto__: base,
greet() {
return 'derived → ' + super.greet();
}
};
derived.greet(); // 'derived → base'
// 如果把方法摘出来,super 就失效了
const fn = derived.greet;
fn(); // ❌ SyntaxError 不会有,但运行时报错:
// 'super' keyword unexpected here
super 是词法绑定的——它在定义时就已经确定指向哪里,不能通过 call / apply 改变,也不能被解构后单独使用。
十、静态成员、私有字段与访问器
static:挂在类本身,而非实例上
static PI = 3.14159;
static square(x) { return x * x; }
static {
// 静态初始化块:可以做复杂初始化
this.VERSION = '1.0';
this.CACHE = new Map();
}
}
MathUtil.square(4); // 16
new MathUtil().square; // undefined,实例拿不到
// static 方法里的 this 指向类本身
class A {
static name() { return this.constructor; }
}
class B extends A {}
B.name() === B; // true —— this 是 B,不是 A
真正的私有:# 前缀
在 # 出现之前,JS 只能靠命名约定(_private)或 WeakMap 来模拟私有。现在有了真正的私有字段:
#count = 0; // 私有实例字段
static #instances = 0; // 私有静态字段
constructor() {
Counter.#instances++;
}
get value() { return this.#count; }
increment() {
return ++this.#count;
}
// 私有方法
#log(msg) { console.log(msg); }
// 检查某个对象是否有这个私有字段
static isCounter(obj) {
return #count in obj;
}
}
const c = new Counter();
c.increment();
c.value; // 1
c.#count; // ❌ SyntaxError:外部无法访问
Object.keys(c); // [] —— 私有字段不出现
JSON.stringify(c); // '{}'
私有字段的三个关键特性:
- 真正的不可访问:不是命名约定,而是语言层面的硬性限制,外部代码无论如何都拿不到。
- 不参与原型:每个实例都有一份独立的私有字段,不会像原型方法那样共享。
- 必须先声明:不能在构造函数里临时创建
this.#x,否则抛SyntaxError。
getter / setter 与字段初始化顺序
#celsius = 0;
get celsius() { return this.#celsius; }
set celsius(v) {
if (v < -273.15) throw new RangeError('低于绝对零度');
this.#celsius = v;
}
get fahrenheit() { return this.#celsius * 9 / 5 + 32; }
set fahrenheit(v) { this.celsius = (v - 32) * 5 / 9; }
}
const t = new Temperature();
t.fahrenheit = 212;
t.celsius; // 100
t.celsius = -300; // ❌ RangeError
字段初始化的顺序非常重要:父类字段先于子类字段初始化,都在 super() 返回之后、构造函数体执行之前完成。
name = 'base';
constructor() { console.log('base ctor:', this.name); }
}
class Sub extends Base {
name = 'sub'; // 在 super() 之后、Sub 构造函数体之前执行
constructor() {
super(); // 这里打印 'sub',因为 Base 构造函数里 this 已经是 Sub 实例
console.log('sub ctor:', this.name);
}
}
new Sub();
// base ctor: sub
// sub ctor: sub
new.target:判断是否被 new 调用
if (!new.target) {
return new Flexible(); // 没写 new 时自动补上
}
this.created = true;
}
Flexible(); // 正常工作,不会污染全局
new Flexible(); // 正常工作
// 抽象基类:禁止直接实例化
class AbstractShape {
constructor() {
if (new.target === AbstractShape) {
throw new TypeError('不能直接实例化抽象类');
}
}
}
十一、组合优于继承:Mixin 与函数式思路
继承不是银弹。深层的继承链会带来一系列问题:
- 脆弱的基类问题:修改父类可能意外破坏所有子类。
- 层级爆炸:当需要“会飞的”“会游泳的”组合时,继承树会迅速失控(经典的“菱形继承”问题)。
- 强耦合:子类被迫继承父类的所有行为,包括不需要的。
- 不可理解:五层继承链上,一个方法的真实来源需要逐层查找。
Mixin:把能力“混”进来
const CanFly = {
fly() { return `${this.name} 在飞`; }
};
const CanSwim = {
swim() { return `${this.name} 在游`; }
};
const CanWalk = {
walk() { return `${this.name} 在走`; }
};
// 按需组合
class Duck {
constructor(name) { this.name = name; }
}
Object.assign(Duck.prototype, CanFly, CanSwim, CanWalk);
class Fish {
constructor(name) { this.name = name; }
}
Object.assign(Fish.prototype, CanSwim);
new Duck('唐老鸭').fly(); // '唐老鸭 在飞'
new Fish('尼莫').swim(); // '尼莫 在游'
new Fish('尼莫').fly; // undefined
用函数式工厂替代继承
fly: () => `${state.name} 在飞`
});
const createSwimmer = (state) => ({
swim: () => `${state.name} 在游`
});
const createDuck = (name) => {
const state = { name };
return {
...createFlyer(state),
...createSwimmer(state),
name
};
};
const duck = createDuck('唐老鸭');
duck.fly(); // '唐老鸭 在飞'
duck.swim(); // '唐老鸭 在游'
什么时候该用继承,什么时候不该
class Circle extends Shapeclass ApiError extends Error
class Car extends Engine ❌ 应该用组合class User extends Logger ❌ 应该用 Mixin
一个实用的判断方法:如果你在子类构造函数里写了很多 super() 之后的重写逻辑,或者子类只用到父类的一小部分能力,那大概率应该改成组合。
十二、原型污染:一个被低估的安全漏洞
原型链机制有一个危险的一面:如果你能往 Object.prototype 上写东西,所有对象都会受影响。这就是“原型污染”(Prototype Pollution)。
漏洞是怎么产生的
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// 攻击者构造的 payload
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, payload);
// 现在,任何对象都"是管理员"了
({}).isAdmin; // true ⚠️
const user = {};
if (user.isAdmin) { /* 权限检查被绕过 */ }
注意:JSON.parse 本身是安全的——它不会把 __proto__ 当作原型设置器。危险的是后续的递归合并操作。
五道防线
__proto__、constructor、prototype 三个键。Object.create(null),它没有原型,自然不会被污染。Map 的键完全独立于原型链,是最彻底的解决方案。Object.freeze(Object.prototype) 可以一劳永逸,但可能影响第三方库。if (obj.isAdmin),要写 Object.hasOwn(obj, 'isAdmin')。function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const val = source[key];
if (Object.hasOwn(target, key) &&
typeof val === 'object' && val !== null) {
safeMerge(target[key], val);
} else {
Object.defineProperty(target, key, {
value: val,
writable: true,
enumerable: true,
configurable: true
});
}
}
return target;
}
用 Map 替代对象字典
dict.set('__proto__', '完全安全');
dict.get('__proto__'); // '完全安全'
({}).__proto__ === Object.prototype; // true,毫发无损
// Map 的其他优势
dict.size; // 直接获取大小
dict.has('key'); // 不会误判继承属性
dict.clear(); // 一键清空
十三、性能:V8 引擎眼中的原型
原型不只是语义机制,它还是引擎性能优化的核心。理解这一点,能帮你写出更快的代码。
隐藏类(Hidden Class / Shape)与内联缓存(Inline Cache)
V8 为了快速访问对象属性,会给每个对象分配一个“隐藏类”,记录属性的名称、顺序和偏移量。当你访问 obj.x 时,引擎会缓存“在这个隐藏类下,x 在第 3 个槽位”,下次直接命中。
function Point(x, y) { this.x = x; this.y = y;}所有实例属性顺序一致,共享同一个隐藏类,访问速度极快。
const a = new Point(1, 2);a.z = 3; // 新增属性,切换隐藏类delete a.x; // 删除属性,更糟不同实例的“形状”各不相同,内联缓存退化。
四条实用的性能建议
null 也先占位,避免后续动态添加
delete obj.prop,它会强制改变隐藏类;把值设为 null 更友好
Object.setPrototypeOf 会让引擎丢弃所有相关优化,创建时就确定原型
原型链长度的影响
属性查找需要沿着原型链逐层向上。链越长,查找越慢。实践中:
- 继承层级控制在 2 到 3 层以内。
- 访问频繁的热点属性,考虑直接放在实例上。
- 避免在原型链上放置 getter——每次访问都会触发函数调用。
const shallow = { value: 1 };
let deep = shallow;
for (let i = 0; i < 20; i++) {
deep = Object.create(deep);
}
// 访问 deep.value 需要向上查 21 层
// 而 shallow.value 只需 1 次
deep.value; // 结果一样,代价不同
好消息是:现代引擎对短原型链(3~5 层)的查找做了高度优化,实际差异通常可以忽略。真正需要警惕的是在热循环里反复访问深层原型属性。
十四、十六个必须记住的陷阱
Fn.prototype 和 Object.getPrototypeOf(Fn)——前者给实例,后者给函数自己
prototype,不能被 new 调用
Fn.prototype 却忘了补 constructor,导致类型判断出错
for...in 遍历对象却不加 hasOwn 过滤,把原型方法也遍历出来
super() 之前访问 this,抛 ReferenceError
this 丢失(const f = obj.method; f())
instanceof 可以跨 iframe 判断数组类型
Object.prototype 加方法,污染所有对象,破坏 for...in
__proto__,造成原型污染漏洞
class 声明不会提升,在定义前使用会抛 ReferenceError
class 方法可以被 Object.keys 枚举出来——它们不可枚举
super 是词法绑定,解构或重新赋值后失效
Object.create(null) 造的对象调用 toString() 会报错
Object.setPrototypeOf,导致 V8 去优化
三道经典面试题
function A() {}
A.prototype.n = 1;
const a = new A();
A.prototype = { n: 2, m: 3 };
const b = new A();
console.log(a.n, a.m); // 1, undefined
console.log(b.n, b.m); // 2, 3
// 关键:a 的原型在创建时就固定了,后续替换 A.prototype 不影响 a
function create() {
const obj = Object.create(null);
obj.toString = function () { return 'custom'; };
return obj;
}
const o = create();
console.log(o.toString()); // 'custom'
console.log(Object.getPrototypeOf(o)); // null
const obj = {
name: 'obj',
getName() {
return this.name;
},
getNameArrow: () => this.name
};
obj.getName(); // 'obj' —— 方法调用,this 是 obj
obj.getNameArrow(); // undefined —— 箭头函数捕获定义时的 this(模块顶层)
const f = obj.getName;
f(); // undefined(严格模式)—— 调用位置没有接收者
f.call(obj); // 'obj'
十五、原型与继承检查清单
把这篇文章的结论压缩成一份可以随时对照的清单。
- 区分三兄弟:
[[Prototype]]是所有对象都有的内部槽,__proto__是它的历史遗留访问器,prototype只有函数有。 - 读原型用
Object.getPrototypeOf,写原型优先Object.create,避免Object.setPrototypeOf。 - 判断自身属性用
Object.hasOwn,不要依赖hasOwnProperty(可能被覆盖)。 - 数据放实例,方法放原型,这是构造函数模式的黄金法则。
- 原型上不放引用类型,否则所有实例共享同一个数组/对象。
- 理解 new 的四步:创建对象 → 链接原型 → 绑定 this 执行 → 处理返回值。
- 知道
new的返回值规则:返回对象则替换,返回原始值则忽略。 - 知道原型链的终点是
null,Object.prototype是绝大多数对象的倒数第二站。 - 不要修改
Object.prototype,任何“通用方法”都放在独立模块里。 - 理解组合继承的浪费:父类构造函数被调用两次,原型上多一份无用属性。
- 优先用 class,它是寄生组合继承的语法糖,且更简洁、更安全。
- 记住 extends 建立两条链:实例链(
Child.prototype → Parent.prototype)和静态链(Child → Parent)。 - 派生类中
this必须在super()之后使用,这是语言层面的强制要求。 - 理解 super 的语义:它决定“去哪里找方法”,不改变
this的指向。 - 深层继承链改用组合或 Mixin,只在实际是“是一个”关系时用继承。
- 警惕原型污染:深合并时过滤
__proto__、constructor、prototype。 - 字典对象用
Object.create(null)或Map,避免被原型干扰。 - 保持隐藏类稳定:构造函数里一次性初始化所有属性,避免
delete。 - 类型判断优先用
Array.isArray/Object.prototype.toString,instanceof只在判断继承关系时用。 - 能在控制台验证的,一定亲手敲一遍——原型是“想不明白但一敲就懂”的典型。
class EventEmitter {
#listeners = new Map();
on(event, handler) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
this.#listeners.get(event).add(handler);
return this; // 支持链式调用
}
off(event, handler) {
this.#listeners.get(event)?.delete(handler);
return this;
}
emit(event, ...args) {
const set = this.#listeners.get(event);
if (!set) return false;
for (const fn of [...set]) fn.apply(this, args);
return true;
}
}
class Store extends EventEmitter {
#state;
constructor(initial = {}) {
super(); // 必须先调用
this.#state = { ...initial };
}
get state() {
return { ...this.#state }; // 返回副本,防止外部修改
}
setState(patch) {
this.#state = { ...this.#state, ...patch };
this.emit('change', this.state);
return this;
}
static from(obj) {
return new this(obj); // this 指向调用者,子类调用也正确
}
}
// 使用
const store = Store.from({ count: 0 });
store.on('change', (s) => console.log('新状态:', s));
store.setState({ count: 1 });
// 新状态:{ count: 1 }
最后一段话
原型之所以难,不是因为它复杂,而是因为它隐藏得太深。你几乎从不需要直接写 __proto__,却无时无刻不在使用它带来的能力:map、filter、toString、hasOwnProperty,全都来自原型链。
理解原型的真正价值,不在于面试时能背出六种继承方案,而在于:
- 看到一个对象时,你能在脑子里自动画出它的原型链。
- 遇到
undefined is not a function时,你能立刻想到“是原型链断了”。 - 写工具函数时,你知道该放原型还是放实例。
- 做安全审计时,你能一眼看出深合并函数的原型污染风险。
- 读框架源码时,你能看穿那些
Object.create和Object.defineProperty背后的意图。
这五条能力,才是“懂原型”和“知道原型这个词”之间的差距。
JavaScript 没有类,只有对象;没有模板,只有原型。接受了这一点,你就真正开始用 JavaScript 的方式思考了。