原型与继承

张玥 2026年9月20日 阅读时间 40分钟
JavaScript 原型链 继承 class 面向对象 底层原理
前端开发技巧之原型与继承

如果你问一个前端工程师“JavaScript 里最难讲清楚的是什么”,答案大概率是原型与继承。它不像闭包那样有个直观的名字,也不像事件循环那样能画成一张流程图——它藏在每一个对象背后,你写 arr.map() 的时候它在工作,你写 class A extends B 的时候它在工作,你打印一个对象看到一串“看不见的属性”时它也在工作。原型是 JavaScript 唯一的继承机制,也是理解这门语言对象模型的唯一入口。不夸张地说,把原型吃透,你对 JS 的认知会从“会写”跃迁到“知道为什么”。这篇长文会从最基本的 [[Prototype]] 讲起,一路走到 class 的语法糖真相、六种继承方案的演进史、原型污染的安全风险,以及 V8 引擎在背后做的性能优化。建议留出完整的时间,边读边在控制台里敲一遍——这篇内容值得你花 40 分钟。

一、为什么 JavaScript 选择了原型

要理解原型,先要理解 JavaScript 的设计出发点。1995 年,Brendan Eich 受命为 Netscape 浏览器设计一门“看起来像 Java 的脚本语言”,但他只有十天时间。他没有照搬 Java 的类继承体系,而是借鉴了 Self 语言——一门基于原型的面向对象语言。

这带来一个根本性的差异:

类继承 先定义“类”这个模板,再由类实例化出对象。类是蓝图,对象是产品。Java、C++、Python 都是这一类
原型继承 没有类,只有对象。新对象直接“挂”在另一个已有对象下面,继承它的能力。对象是原件,新对象是复印件

所以在 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 自己的爸爸”。
function Foo() {}

// 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 的时候,引擎执行的是这样一个算法:

1
查自身
own property
先用 Object.hasOwn(obj, 'foo') 检查对象自身是否有这个属性。有就直接返回,查找结束,不会继续往上找。
2
查原型
[[Prototype]]
沿着 [[Prototype]] 走到原型对象,重复第 1 步。这个原型对象自己也有 [[Prototype]],于是形成一条链。
3
循环上溯
直到 null
一直向上找,直到某个对象的 [[Prototype]] 是 null。这条链的长度理论上没有限制,但实践中不会超过几层。
4
返回
undefined 或 getter
找到就返回属性值;如果命中的是 getter,则调用 getter;如果一路到 null 都没找到,返回 undefined。

亲手把这条链“点亮”

下面是一个真实的原型链。点击“逐级查找”,观察 p.greet() 是怎么找到方法的。

function Person(name) {
  this.name = name; // 自身属性
}
Person.prototype.greet = function () {
  return '你好,' + this.name;
};

const p = new Person('张玥');
p.greet(); // 这个调用经过了哪些对象?
p own: name = '张玥'
▼ [[Prototype]]
Person.prototype own: greet, constructor
▼ [[Prototype]]
Object.prototype own: hasOwnProperty, toString…
▼ [[Prototype]]
null 链的终点
// 点击上方按钮开始查找

遮蔽(Shadowing):自身属性永远优先

如果实例上写了一个跟原型同名的属性,原型上的那个就被“遮住”了:

Person.prototype.name = '默认名字';

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 parent = { greet() { return 'hi'; } };
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

当你声明一个普通函数时,引擎会悄悄做两件事:

  1. 给这个函数对象加一个 prototype 属性,值是一个新对象。
  2. 这个新对象上有一个 constructor 属性,指回函数本身。
function Person() {}

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('张玥') 的完整过程。

1
创建对象
空对象
在内存中创建一个全新的空对象 {}。此时它还没有任何属性,[[Prototype]] 是 Object.prototype。
2
链接原型
[[Prototype]]
把这个新对象的 [[Prototype]] 指向构造函数的 prototype 属性,也就是 Person.prototype。这一步是继承的关键。
3
绑定 this 执行
call
以新对象作为 this,执行构造函数体。构造函数里写的 this.name = name 因此落在了新对象自己身上。
4
处理返回值
对象优先
如果构造函数返回的是一个对象,就返回它;否则忽略返回值,返回第 1 步创建的那个新对象。返回原始值会被忽略。

手写一个 myNew

理解了这四步,你自己就能实现 new:

function myNew(Ctor, ...args) {
  // 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。理解链的尽头,才能理解为什么有些属性“人人都有”。

const arr = [1, 2, 3];

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

一张完整的链地图

{} 普通对象
obj → Object.prototype → null
[] 数组
arr → Array.prototype → Object.prototype → null
function 函数
fn → Function.prototype → Object.prototype → null
new Foo() 实例
f → Foo.prototype → Object.prototype → null
Object.create(null)
纯净对象 → null,没有任何继承的方法
'abc' 字符串字面量
临时包装 → 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 的三种用法

// 1. 指定原型创建(最纯粹的继承)
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

属性描述符:比原型更底层的一层

说到属性,就不能不提描述符。每个属性其实不是一个值,而是一组特性:

const o = { a: 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
value 属性的值(数据属性)
writable 能否被赋值修改
enumerable 能否被 for...in / Object.keys 枚举
configurable 能否被删除或重新定义
get / set 访问器属性,与 value 互斥

enumerable 这个特性特别重要:for...in 会遍历原型链上所有可枚举属性,而 Object.keys() 只看自身可枚举属性。这也是为什么内置原型上的方法不会被 Object.keys() 列出来——它们被定义为不可枚举。

六、instanceof、isPrototypeOf 与类型判断

instanceof 的语义其实非常简单:右边构造函数的 prototype,是否出现在左边对象的原型链上?

function Animal() {}
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

function myInstanceOf(obj, Ctor) {
  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 的四个坑

坑 1 跨 iframe / realm 失效:两个窗口各有自己的 Array.prototype,iframeArr instanceof Array 为 false。用 Array.isArray() 代替。
坑 2 原始值一律返回 false:'abc' instanceof String 是 false,因为原始值不是对象。
坑 3 原型被替换后结果突变:Fn.prototype = {} 之后,旧实例的 instanceof 立刻变成 false。
坑 4 可以被 Symbol.hasInstance 自定义,也可能被恶意代码劫持。
// Symbol.hasInstance:自定义 instanceof 行为
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 不可靠

function Foo() {}
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。

方案一:原型链继承

function Parent() {
  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'] —— 被污染了!
优点
实现简单,父类原型方法能被子类实例共享。
致命缺陷
引用类型属性被所有实例共享;创建子类实例时无法向父类构造函数传参。

方案二:构造函数继承(借用构造函数)

function Parent(name) {
  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 之前最流行的写法,也是无数教程里的标准答案:

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;

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'] 永远被实例属性遮蔽

方案四、五:原型式继承与寄生式继承

// 原型式继承:Object.create 的思想源头
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 就是函数

class Person {
  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 与构造函数的九个差异

1 class 不会被提升(存在暂时性死区),函数声明会提升
2 class 内部代码自动处于严格模式
3 class 必须用 new 调用,直接调用会抛 TypeError
4 class 的原型方法默认不可枚举,构造函数里挂的方法可枚举
5 class 内部所有代码都是严格模式,包括方法体
6 class 的 constructor 属性本身不可写、不可枚举、不可配置
7 class 声明会创建块级作用域绑定,函数声明在块内行为不统一
8 class 支持 extends、super、static、私有字段等语法
9 class 的 name 属性不可写(但可配置)

extends 建立了两条链

这是理解 class 继承最关键的一点。class Child extends Parent 实际上同时做了两件事:

实例链(原型链)
Child.prototype 的 [[Prototype]] 指向 Parent.prototype。
效果:new Child() 出来的实例能访问父类原型方法。
构造器链(静态链)
Child 本身的 [[Prototype]] 指向 Parent。
效果:子类能继承父类的 静态方法。
class 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() 调用父类构造函数

class Animal {
  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。

class Dog extends Animal {
  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 调用它”。

class A {
  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

const base = {
  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:挂在类本身,而非实例上

class MathUtil {
  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 来模拟私有。现在有了真正的私有字段:

class Counter {
  #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 与字段初始化顺序

class Temperature {
  #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() 返回之后、构造函数体执行之前完成。

class Base {
  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 调用

function Flexible() {
  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

用函数式工厂替代继承

const createFlyer = (state) => ({
  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(); // '唐老鸭 在游'

什么时候该用继承,什么时候不该

适合继承
关系确实是 “是一个”(is-a) 且层级浅(≤2 层):
class Circle extends Shape
class ApiError extends Error
不适合继承
关系是 “有一个”(has-a) 或“能做某事”:
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
Map 的键完全独立于原型链,是最彻底的解决方案。
④ 冻结原型
Object.freeze(Object.prototype) 可以一劳永逸,但可能影响第三方库。
⑤ 判断用 hasOwn
不要写 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 替代对象字典

const dict = new 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; // 删除属性,更糟
不同实例的“形状”各不相同,内联缓存退化。

四条实用的性能建议

建议 1 在构造函数里一次性初始化所有属性,即使是 null 也先占位,避免后续动态添加
建议 2 避免在生产代码中 delete obj.prop,它会强制改变隐藏类;把值设为 null 更友好
建议 3 方法放原型上共享,数据放实例上——这既省内存,也让所有实例共享同一个隐藏类
建议 4 Object.setPrototypeOf 会让引擎丢弃所有相关优化,创建时就确定原型

原型链长度的影响

属性查找需要沿着原型链逐层向上。链越长,查找越慢。实践中:

  • 继承层级控制在 2 到 3 层以内。
  • 访问频繁的热点属性,考虑直接放在实例上。
  • 避免在原型链上放置 getter——每次访问都会触发函数调用。
// 用 benchmark 感受一下差异(伪代码)
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 层)的查找做了高度优化,实际差异通常可以忽略。真正需要警惕的是在热循环里反复访问深层原型属性。

十四、十六个必须记住的陷阱

陷阱 1 混淆 Fn.prototype 和 Object.getPrototypeOf(Fn)——前者给实例,后者给函数自己
陷阱 2 用箭头函数当构造函数:没有 prototype,不能被 new 调用
陷阱 3 替换了 Fn.prototype 却忘了补 constructor,导致类型判断出错
陷阱 4 在构造函数里定义方法,每个实例一份副本,内存翻倍
陷阱 5 用 for...in 遍历对象却不加 hasOwn 过滤,把原型方法也遍历出来
陷阱 6 原型上放数组或对象等引用类型属性,被所有实例共享
陷阱 7 在派生类构造函数里,super() 之前访问 this,抛 ReferenceError
陷阱 8 把方法从对象上摘下来单独调用,this 丢失(const f = obj.method; f())
陷阱 9 误以为 instanceof 可以跨 iframe 判断数组类型
陷阱 10 给 Object.prototype 加方法,污染所有对象,破坏 for...in
陷阱 11 深合并用户输入时未过滤 __proto__,造成原型污染漏洞
陷阱 12 class 声明不会提升,在定义前使用会抛 ReferenceError
陷阱 13 认为 class 方法可以被 Object.keys 枚举出来——它们不可枚举
陷阱 14 super 是词法绑定,解构或重新赋值后失效
陷阱 15 用 Object.create(null) 造的对象调用 toString() 会报错
陷阱 16 在热路径上用 Object.setPrototypeOf,导致 V8 去优化

三道经典面试题

// 题 1:输出什么?
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
// 题 2:实现一个能通过下列断言的函数
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
// 题 3:这段代码的 this 是什么?
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 继承模板

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 的方式思考了。