原型链语法糖本质
# 06.原型链与类语法糖本质
📍 上接第 05 篇《this 四规则与函数组合》。函数的行为已了然。本文回到 JS 最核心的支柱——对象系统和原型链。
{}为什么能调用.toString()?new干了哪四件事?class 在 V8 里被脱糖成了什么?对象属性到底被藏在了哪里?
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. 属性查找算法
- 4. new底层步骤
- 5. 五种继承演进
- 6. class脱糖
- 7. super原理
- [7.1 [HomeOb
- 7.2 construc
- 7.3 多重继承下 su
- 8. 属性描述符详解
- 9. Mixin与安全
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 一个 JSON
先看一段在生产环境真实出现过的代码——一个后台管理系统的"用户配置同步"功能:
// config-sync.js —— 用户配置同步器(故障版本)
async function syncUserConfig(userInput) {
// 用户输入是一段 JSON——前端表单里"个性化设置"编辑器的产物
let config = { theme: 'light', lang: 'zh' };
// 合并用户自定义配置——允许用户覆盖默认值
try {
const userConfig = JSON.parse(userInput); // ← 用户输入的 JSON
// 浅合并:用户配置覆盖默认配置
for (const key in userConfig) {
config[key] = userConfig[key]; // ← 直接赋值——没有过滤!
}
} catch (e) {
console.error('Invalid JSON');
return;
}
// 把合并后的配置应用到页面
applyConfig(config);
}
function applyConfig(config) {
// 遍历所有配置项,应用到 DOM 和 localStorage
for (const key in config) {
if (config.hasOwnProperty(key)) { // ← 用了 hasOwnProperty
// ... 应用配置 ...
}
// ⚠️ 但如果 config.hasOwnProperty 被污染了呢?
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
攻击载荷——一个恶意用户输入:
{
"theme": "dark",
"__proto__": {
"hasOwnProperty": "polluted"
}
}
2
3
4
5
6
现场:管理员保存这个配置后,整站所有页面变空白。控制台报错:Uncaught TypeError: config.hasOwnProperty is not a function。
根因:for (const key in userConfig) 遍历到了 __proto__ 这个属性(__proto__ 是 Object.prototype 上的一个可枚举的历史 getter/setter)。当执行 config['__proto__'] = userConfig['__proto__'] 时,它没有在 config 上创建新属性——而是把 Object.prototype.hasOwnProperty 替换成了字符串 "polluted"。此后,所有对象的 .hasOwnProperty() 都变成了 "polluted"() → TypeError。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:是不是
for...in遍历了不应该遍历的属性?——是。for...in会遍历对象自身的和原型链上的所有可枚举属性。__proto__虽然是历史遗留的 getter,但它可枚举、且存在于Object.prototype上——所以for...in会遍历到它。 - 假设 2:为什么
config[key] = userConfig[key]没有在 config 上创建一个新属性?——因为__proto__不是普通的属性。它是Object.prototype上的一个 accessor property (getter/setter)。当你通过obj.__proto__ = value赋值时,它的 setter 不是去创建一个名为__proto__的自身属性(own property)——而是去修改obj的内部[[Prototype]]槽。而这个赋值最终落到了Object.prototype上——因为config的原型是Object.prototype(普通字面量创的)。 - 假设 3:到底谁被污染了?——
config的[[Prototype]]是Object.prototype。config.__proto__ = { hasOwnProperty: "polluted" }→ 把config的原型从Object.prototype替换成了攻击输入的这个对象(从而让config自己失去 hasOwnProperty)——等等,更精确地说,这个赋值是修改config.[[Prototype]],把它指向了{ hasOwnProperty: "polluted" }。然后for...in在遍历Object.assign时又把__proto__上的值扩散了。 - 假设 4:那为什么"所有对象"都被污染了?——实际上这个特定攻击只污染了用
Object.prototype作为原型链顶端的对象。但如果你不小心把这个污染扩散到了Object.prototype自身(通过其他路径),那么所有{}都会受影响。
# 1.3 我们要回答什么
这段代码里至少藏着 7 个原理点:
① prototype / __proto__ / [[Prototype]] 三角到底什么关系?为什么有三个名字? → 第 2 章
② 属性查找时 V8 怎么沿原型链找?为什么 1 万次 obj.toString 不会每次都遍历? → 第 3 章
③ new 到底做了什么?手写一个不到 15 行的 myNew——它必须处理什么边界条件? → 第 4 章
④ 五种继承方式:原型链→盗用→组合→寄生组合→class——每一步修了上一步的什么bug? → 第 5 章
⑤ class 脱糖后是什么?Babel 的 _classCallCheck 是怎么实现"不准直接调用"的? → 第 6 章
⑥ super.method() 怎么知道"父类"是谁?[[HomeObject]] 是个什么东西? → 第 7 章
⑦ configurable/writable/freeze/seal 怎么联动?原型污染攻击怎么防? → 第 8~9 章
2
3
4
5
6
7
本篇路线:
架构总图(第 2 章)
↓
属性查找(第 3 章)──→ 解开"obj.toString 到底从哪来、V8 怎么加速它"
↓
new 四步骤(第 4 章)──→ 解开"构造函数到实例的精确映射"
↓
五种继承(第 5 章)──→ 解开"每一步在解决上一步的什么问题"
↓
class 脱糖(第 6 章)──→ 解开"class 在 V8 里的真实面目"
↓
super 本质(第 7 章)──→ 解开"[[HomeObject]] 如何锁定父类"
↓
属性描述符(第 8 章)──→ 解开"对象的元数据层"
↓
Mixin 与安全(第 9 章)──→ 解开"CVE-2018-3721 是怎么发生的"
↓
综合案例(第 10 章)──→ 案例彻底剖开 + 哲学四条 + 速查表
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
📌 本篇定位:这是 JS 对象系统的地基篇。第 04 篇讲了作用域和闭包(变量在哪里),第 05 篇讲了 this 和函数(调用者是谁)。本篇回答:对象是什么、它身上的属性和方法从哪来、prototype 和 class 之间隔了什么。读完本篇后,你对
{}不再有"它从哪里来、要到哪里去"的模糊感。
# 2. 架构全景概览
# 2.1 proto
疑惑:同一件事——"对象的原型"——为什么有三个名字?它们是不是同一个东西的不同叫法?
论证——它们是三个不同层次的概念:
┌────────────────────────────────────────────────────────────────────┐
│ 原型链三个名字的层次关系 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ [[Prototype]](ECMA-262 规范层) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ECMA-262 规定的每个 JS 对象的内部槽(Internal Slot)。 │ │
│ │ 每个对象都有一个 [[Prototype]] 指向另一个对象或 null。 │ │
│ │ JS 代码不能直接读/写这个槽——只能通过辅助 API。 │ │
│ │ 规范访问方式: │ │
│ │ Object.getPrototypeOf(obj) → 读 │ │
│ │ Object.setPrototypeOf(obj, proto) → 写 │ │
│ │ V8 中:存储为对象的第一个字段(Map 偏移量中的 prototype 指针) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↕ 实现 │
│ __proto__(历史遗留 getter/setter) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 定义在 Object.prototype 上的 accessor property (get/set)。 │ │
│ │ 它的 getter 内部调用:Object.getPrototypeOf(this) │ │
│ │ 它的 setter 内部调用:Object.setPrototypeOf(this, value) │ │
│ │ 规范标记为 [[Legacy]]——不推荐在生产代码中使用 │ │
│ │ ⚠️ 可以被覆盖或删除(因为它是普通属性,在 Object.prototype 上) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↕ 模板 │
│ prototype(构造函数属性) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 只有函数对象(包括 class)有这个属性(箭头函数没有!) │ │
│ │ 函数声明时自动创建:Fn.prototype = { constructor: Fn } │ │
│ │ 它是一个**普通的对象**——只是被 new 操作符用来设置新对象的原型 │ │
│ │ 在 new Fn() 时:newObj.[[Prototype]] = Fn.prototype │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
三个名字的关系(以 function Dog() {} 为例):
Dog 函数本身:
Dog ──prototype──→ Dog.prototype
├ constructor ──→ Dog
└ [[Prototype]] ──→ Object.prototype(因为 Dog.prototype 是字面量 {})
Dog 实例:
const d = new Dog()
d ──[[Prototype]]──→ Dog.prototype
d.__proto__ === Dog.prototype ← 规范上等价(但不推荐用 __proto__)
2
3
4
5
6
7
8
9
V8 内部——[[Prototype]] 的存储位置:
在 V8 的
HeapObject实现中,[[Prototype]]不是独立的槽——它存储在对象的 Map(隐藏类) 的prototype字段中。所有共享同一个 Map 的对象,它们的原型一定相同(因为原型是 Map 的字段之一)。这就是为什么"改一个对象的原型"代价很高——V8 必须给它分配一个新的 Map。
# 2.2 为什么 JS 选
疑惑:几乎所有主流语言(Java/C++/C#/Python)都用 class 做继承。为什么 JS 选择了一个"原型链"——这个东西在 1995 年几乎是"另一种道路"?
论证——Brendan Eich 在 2001 年的采访中解释了设计决策:
时间紧迫——JS 在 10 天内设计完成(1995 年 5 月)。实现一个完整的 class 系统(包括静态类型检查、多重继承、虚函数表等)在当时不可能做到。原型链是最简化的"有继承能力的对象系统"——只需要两个核心概念:对象和对象之间的单向指针。
"足够好"——JS 不是要替代 Java——Netscape 的市场策略是"JS 是 Java 的脚本小助手"。不需要一个完整的 OOP 系统。原型链足够让开发者"把方法挂到一个共享对象上",节省内存。
Self 语言的影响——Brendan Eich 的灵感来源是 Self(Sun Microsystem 的 Smalltalk 变体)——它是第一种用"原型"替代"类"的主流语言。Self 证明了没有 class 也能实现继承、多态和代码复用——原型链就是 Self 的简化版。
动态友好的设计——原型链天然支持运行时修改类的行为:
// 在 Java 中给所有 String 加一个方法 → 不可能(除非改 JDK 源码)
// 在 JS 中 → 改原型即可
Array.prototype.last = function() { return this[this.length - 1]; };
[1, 2, 3].last(); // → 3 ← 所有数组立即拥有了 .last()!
2
3
4
这种"运行时扩展"在 Class 系统中需要"猴子补丁"(monkeypatching),在原型链中是天生的。但这也是原型污染攻击的根基——你能加好方法,攻击者也能加坏方法。
结论:JS 选择原型链不是"技术选型的偏好",而是**"用一个最简单的概念实现'有继承能力的对象系统'"的务实决策**。25 年后,ES6 的 class 语法糖证明了:原型链的底层能力完全足够支撑类式 OOP——它只是差了一层语法糖。
# 3. 属性查找算法
# 3.1 [[Get]]
ECMA-262 §10.1.8 规定了 obj.prop 的查找流程:
┌──────────────────────────────────────────────────────┐
│ [[Get]](P, Receiver) —— 属性 P 的完整查找流程 │
├──────────────────────────────────────────────────────┤
│ │
│ ① 调用 [[GetOwnProperty]](P) │
│ → 在 obj 自身的属性中查找 P │
│ → 找到 → 把属性描述符转成值 → 返回 │
│ → 没找到 → 进入步骤 ② │
│ │
│ ② 获取 parent = obj.[[Prototype]] │
│ → parent === null → 返回 undefined(链顶端) │
│ → parent 是普通对象 → 递归:parent.[[Get]](P, Receiver)│
│ → parent 是 Proxy → 调用 proxy 的 get trap │
│ │
│ ③ 如果找到的属性是 accessor property (getter/setter): │
│ → 调用 getter.call(Receiver) │
│ → 注意:this 是 Receiver(最初发起查找的那个对象!)│
│ │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
一个容易被忽略的细节——Receiver 的作用:
const parent = {
get name() {
return this._name; // ← this 是谁?
}
};
const child = Object.create(parent);
child._name = 'child';
console.log(child.name); // → 'child' ← this 是 child(Receiver),不是 parent!
// 因为 [[Get]] 的第 3 步传了 Receiver = child(发起查找的那个对象)
// getter 里的 this 绑定到 Receiver
2
3
4
5
6
7
8
9
10
11
# 3.2 V8 隐藏类
疑惑:原型链查找听起来是 O(链长) 的——每次 obj.toString 都要沿着 obj → prototype → Object.prototype 找三次。V8 怎么能让 10000 次 obj.toString 都在 ~1µs 内完成?
论证——V8 的 IC 不"每次遍历",而是"第一次遍历后记住路径":
┌──────────────────────────────────────────────────────────┐
│ V8 内联缓存(IC)在原型链查找中的应用 │
├──────────────────────────────────────────────────────────┤
│ │
│ 第一次执行 obj.toString: │
│ ┌────────────────────────────────────────────────────┐ │
│ │ ① 读 obj 的 Map(隐藏类) → MapA │ │
│ │ ② 在 MapA 的 descriptor array 中找 'toString' │ │
│ │ → 没找到 │ │
│ │ ③ 读 MapA.prototype → Object.prototype 的 Map │ │
│ │ ④ 在 Object.prototype 的 descriptor array 中找到 │ │
│ │ 'toString' 在 offset 28 │ │
│ │ ⑤ 记录到 IC:{ MapA, "toString", path: [offset 28], │ │
│ │ prototype_chain: 1 hop } ← 记下来! │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ 第二次及以后(IC 命中): │
│ ┌────────────────────────────────────────────────────┐ │
│ │ ① 读 obj 的 Map → MapA ✓(和第一次一样) │ │
│ │ ② IC 命中了!→ 直接读 [obj + prototype_offset + 28] │ │
│ │ 不遍历、不查 descriptor array、不比较属性名 │ │
│ │ → ~1-2 CPU 指令(一次指针偏移 + 一次内存读) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ TurboFan JIT 更进一步——直接内联为: │
│ mov rax, [obj + 8] ; 读__proto__ │
│ mov rax, [rax + 28] ; 读 toString 在原型上的偏移 │
│ → 2 条 mov 指令 = ~1ns(CPU cache 命中时) │
│ │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
IC 失效的条件——什么时候"路径"作废:
const obj = { x: 1 };
obj.toString(); // 第一次:IC 记住了原型查找路径
// 场景 1:改变了 obj 的原型 → IC 失效!
Object.setPrototypeOf(obj, null);
obj.toString(); // → TypeError!IC 存的路径错了(原型变成 null 了)
// 场景 2:obj 的类型变了(隐藏类不同)→ IC 失配
// V8 会记录多个隐藏类的 IC 路径,达到上限(通常 4 个)后降级为常规查找
2
3
4
5
6
7
8
9
# 3.3 hasOwnPr
| 操作 | 查找范围 | 安全风险 | ES 版本 |
|---|---|---|---|
obj.hasOwnProperty('x') | 仅自身属性 | ⚠️ 可以被覆盖(obj.hasOwnProperty = 'xxx') | ES3 |
'x' in obj | 自身 + 整条原型链 | ✅ in 是操作符,不能被覆盖 | ES3 |
Object.hasOwn(obj, 'x') | 仅自身属性 | ✅ 静态方法,不受原型链影响 | ES2022 |
// 危险——hasOwnProperty 被覆盖后
const obj = { hasOwnProperty: 'nope', x: 1 };
obj.hasOwnProperty('x'); // → TypeError: not a function
// 安全的替代
Object.prototype.hasOwnProperty.call(obj, 'x'); // → true(老方案)
Object.hasOwn(obj, 'x'); // → true(ES2022 最佳实践)
2
3
4
5
6
7
# 4. new底层步骤
# 4.1 四步拆解 + 手
疑惑:new Fn() 到底做了什么?ECMA-262 规定了哪四步?
论证——ECMA-262 §13.2.2 [[Construct]] 的四步展开:
function myNew(Constructor, ...args) {
// 步骤 ①:创建一个新的空对象
const obj = {}; // 规范:OrdinaryObjectCreate
// 步骤 ②:设置这个对象的 [[Prototype]] = Constructor.prototype
Object.setPrototypeOf(obj, Constructor.prototype);
// 等价于:obj.__proto__ = Constructor.prototype
// 步骤 ③:执行构造函数,this = obj
const result = Constructor.apply(obj, args);
// 步骤 ④:如果构造函数返回了对象(不是原始类型)→ 返回那个对象
// 否则 → 返回步骤 ① 创建的对象
return result instanceof Object ? result : obj;
}
// 验证:
function Dog(name) { this.name = name; }
const d = myNew(Dog, '旺财');
console.log(d.name); // → '旺财'
console.log(d instanceof Dog); // → true
console.log(d.__proto__ === Dog.prototype); // → true
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 4.2 构造函数返回对象
// 返回对象 → new 返回那个对象(不是 this!)
function Foo() {
this.name = 'foo';
return { override: true };
}
const f = new Foo();
console.log(f.name); // → undefined ← 不是 'foo'!
console.log(f.override); // → true ← 返回的是 return 的那个对象
// 返回基本类型 → new 忽略它,返回 this
function Bar() {
this.name = 'bar';
return 42; // ← 基本类型 → 被忽略
}
const b = new Bar();
console.log(b.name); // → 'bar' ← this 照常返回
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 4.3 new.targ
// new.target:当前 [[Construct]] 调用中的构造函数
function Person(name) {
if (!new.target) {
// 如果是普通调用(Person() 而不是 new Person())→ new.target = undefined
throw new Error('Person must be called with new');
}
this.name = name;
}
// instanceof:检查 Constructor.prototype 是否在 obj 的原型链上
function myInstanceof(obj, Constructor) {
let proto = Object.getPrototypeOf(obj);
while (proto !== null) {
if (proto === Constructor.prototype) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 5. 五种继承演进
# 5.1 原型链继承 →
┌─────────────────────────────────────────────────────────────────┐
│ 五种继承方式的精确进化链 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ① 原型链继承(最早) │
│ 做法:Child.prototype = new Parent() │
│ 优点:父类方法被所有子实例共享(节省内存) │
│ 问题:父类的引用类型属性也被所有子实例共享 ← 改一个全改! │
│ │
│ ② 盗用构造函数继承 │
│ 做法:在 Child 内部 Parent.call(this) │
│ 优点:每个实例有独立的属性 │
│ 问题:无法继承父类原型上的方法(eat 这些在 Parent.prototype 上) │
│ │
│ ③ 组合继承(原型链 + 盗用构造) │
│ 做法:① + ② 组合 │
│ 优点:方法继承 + 属性独立 │
│ 问题:Parent 被调用了两次(new Parent() + Parent.call(this)) │
│ │
│ ④ 寄生组合继承(最优——ES6 class 的底层蓝图) │
│ 做法:Child.prototype = Object.create(Parent.prototype) │
│ + Parent.call(this) │
│ 优点:方法继承 + 属性独立 + Parent 只被调用一次 ✅ │
│ 本质:用 Object.create 代替 new Parent()——不调用父构造器 │
│ │
│ ⑤ ES6 class extends(语法糖——底层是 ④) │
│ 做了额外的优化:方法默认不可枚举、强制 super() 必须先调 │
│ │
└─────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
代码对照:
// ① 原型链继承——引用共享 bug
function Parent() { this.items = []; }
function Child() {}
Child.prototype = new Parent();
const c1 = new Child(); c1.items.push('a');
const c2 = new Child(); console.log(c2.items); // → ['a'] ← 共享了!
// ② 盗用构造——方法不继承
function Child() { Parent.call(this); }
// c1.eat() ← 不存在!eat 在 Parent.prototype 上
// ④ 寄生组合——完美方案
function inherit(Child, Parent) {
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
}
function Child() { Parent.call(this); }
inherit(Child, Parent);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 5.2 为什么寄生组合是
寄生组合解决了所有前置版本的问题,而没有引入新的概念——它的两个核心操作(Object.create + Parent.call(this))都是 ES5 的原生能力。ES6 class 脱糖后正是调用这两个操作完成继承。
# 6. class脱糖
# 6.1 _classCa
// 源码
class Person {
constructor(name) { this.name = name; }
greet() { return `Hi ${this.name}`; }
}
// Babel 脱糖产物(精简后逐行注释):
var Person = /*#__PURE__*/(function () {
// ① 构造函数本体——和 function Person(name) {} 没区别
function Person(name) {
_classCallCheck(this, Person); // ← 防"忘记 new"
this.name = name;
}
// ② 挂原型方法——把 greet 挂到 Person.prototype 上
_createClass(Person, [{
key: "greet",
value: function greet() { return "Hi " + this.name; }
}]);
return Person;
})();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
三个辅助函数的逐行拆解:
// _classCallCheck —— 防"忘记 new"
function _classCallCheck(instance, Constructor) {
// instanceof 检查:instance 的原型链上有 Constructor.prototype 吗?
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
// Person() → 进入函数 → instance = undefined → !(undefined instanceof Person) → throw
// new Person() → instance = {}(新创建的对象)→ 对象的原型链上有 Person.prototype → 通过
// _createClass —— 把方法数组挂到原型上(支持不可枚举!)
function _createClass(Constructor, protoProps) {
for (var i = 0; i < protoProps.length; i++) {
Object.defineProperty(Constructor.prototype, protoProps[i].key, {
value: protoProps[i].value,
enumerable: false, // ← class 方法默认不可枚举(区别于 ES5 原型方法!)
writable: true,
configurable: true
});
}
}
// _inherits —— 寄生组合继承
function _inherits(Child, Parent) {
Child.prototype = Object.create(Parent.prototype); // ← 只继承原型方法
Child.prototype.constructor = Child;
Object.setPrototypeOf(Child, Parent); // ← 让子类能访问父类的静态方法
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 6.2 为什么 clas
new Foo(); // → 正常!function 整体提升
function Foo() {}
new Bar(); // → ReferenceError: Cannot access 'Bar' before initialization
class Bar {}
2
3
4
5
class 声明的行为和 let/const 完全一致——创建阶段绑定进入 TDZ(第 04 篇 §5.1)。为什么?因为 class 的构造函数体中可能访问到自身或父类的静态属性——如果允许在声明前使用,就会读到"还在 TDZ 中"的变量→更难排查的 bug。
# 6.3 class 字段
class Base {
field = 'base'; // ← class field(public instance field)
constructor() {
console.log('Base constructor');
console.log(this.field); // → 'base'(field 在 super 后、constructor 代码前被初始化)
}
}
class Child extends Base {
field = 'child';
constructor() {
super(); // ← 1. 调用父构造器(Base.constructor 执行)
console.log(this.field); // → 'child'(子类 field 在 super 后初始化)
}
}
new Child();
// 输出顺序:
// Base constructor
// base ← Base.constructor 里的 this.field
// child ← Child.constructor 里的 this.field
//
// 字段初始化顺序:
// super() → 父类 field 初始化 → 子类 field 初始化 → constructor 剩余代码
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 7. super原理
# 7.1 [[HomeOb
疑惑:super.method() 怎么知道"父类"是谁?类内部没有显式存"父类"的变量啊。
论证——每个方法(不是每个函数——是"类方法"这种简写语法定义的方法)内部隐藏了一个 [[HomeObject]] 槽:
class Dog extends Animal {
speak() {
super.eat(); // ← super 怎么找"父类"?
}
}
// 关键:Dog.prototype.speak 在创建时,[[HomeObject]] 被设为 Dog.prototype
// speak.[[HomeObject]] = Dog.prototype
// super.eat() 的查找路径:
// ① 从 speak 中拿到 [[HomeObject]] = Dog.prototype
// ② 取 Dog.prototype 的原型链上一级 → Object.getPrototypeOf(Dog.prototype)
// = Animal.prototype(因为在 class extends 中设置了 Dog.prototype.__proto__ = Animal.prototype)
// ③ 在 Animal.prototype 上找 'eat' → 找到!
2
3
4
5
6
7
8
9
10
11
12
13
14
为什么 super 不能在普通函数中使用:
const obj = {
method() {
super.toString(); // ✅ 可以——method 的 [[HomeObject]] = obj
}
};
function foo() {
super.toString(); // ❌ SyntaxError——普通函数没有 [[HomeObject]]!
}
2
3
4
5
6
7
8
9
# 7.2 construc
class Child extends Parent {
constructor() {
super(); // ← super() = 调用父类构造器(必须是在 this 之前第一行!)
// 等价于:Parent.call(this)——但这步由引擎保证,不能自己写
}
method() {
super.method(); // ← super.method() = 沿 [[HomeObject]] 的原型链查找
// 等价于:Parent.prototype.method.call(this)
}
}
2
3
4
5
6
7
8
9
10
11
# 7.3 多重继承下 su
JS 的原型链是单链——每个对象的 [[Prototype]] 只指向一个对象。这意味着 super 的查找不会"分叉"去找多个父类——这是 JS 不原生支持多重继承的根本原因。但 Mixin 模式(第 9 章)通过"把多个父类的方法拷贝到一个原型上",模拟了多重继承的效果。
# 8. 属性描述符详解
# 8.1 数据描述符 vs
每个对象属性的底层是一个 Property Descriptor(属性描述符)——一个包含六个位的元数据对象。不是所有位都同时存在——数据描述符和访问器描述符互斥:
┌──────────────────────────────────────────────────────┐
│ 属性描述符的两种形态(互斥) │
├──────────────────────────────────────────────────────┤
│ │
│ 数据描述符(data descriptor)——属性存的是"值": │
│ ┌──────────────────────────────────────────────┐ │
│ │ value → 属性的当前值 │ │
│ │ writable → 是否可以修改 value │ │
│ │ enumerable → 是否出现在 for...in / keys 中 │ │
│ │ configurable → 是否可删除 / 可修改其他描述符位 │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ 访问器描述符(accessor descriptor)——属性存的是"函数": │
│ ┌──────────────────────────────────────────────┐ │
│ │ get → 取值函数(读取属性时调用) │ │
│ │ set → 设值函数(写入属性时调用) │ │
│ │ enumerable → 同上 │ │
│ │ configurable → 同上 │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ⚠️ 互斥规则:value 和 get 不能同时存在 │
│ writable 和 get/set 不能同时存在 │
│ │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const obj = {};
Object.defineProperty(obj, 'x', {
value: 42,
writable: false,
enumerable: true,
configurable: false
});
obj.x = 100; // 静默失败(严格模式下抛 TypeError)
delete obj.x; // 静默失败——configurable: false 不可逆!
// ⚠️ configurable: false 一旦设置,就永远不能再改成 true!
// 也不能把属性从 data descriptor 改成 accessor descriptor
2
3
4
5
6
7
8
9
10
11
12
13
# 8.2 freeze
| 操作 | 等价于对每个属性设置 | 可加新属性 | 可删属性 | 可改已有属性值 | 可逆? |
|---|---|---|---|---|---|
preventExtensions | — | ❌ | ✅ | ✅ | ❌ |
seal | configurable: false | ❌ | ❌ | ✅(如果 writable: true) | ❌ |
freeze | configurable: false + writable: false | ❌ | ❌ | ❌ | ❌ |
关键细节——freeze 是浅冻结:
const obj = { a: { b: 1 } };
Object.freeze(obj);
obj.a.b = 2; // ✅ 可以改!freeze 不冻结嵌套对象!
obj.a; // → { b: 2 }
2
3
4
# 8.3 getter
// 场景 1:数据校验——拦截不合法赋值
const user = {
_age: 0,
get age() { return this._age; },
set age(val) { if (val < 0) throw Error('age >= 0'); this._age = val; }
};
// 场景 2:惰性计算——第一次访问时算,之后用缓存
const stats = {
_data: [1, 2, 3, 4, 5],
get sum() {
const val = this._data.reduce((a, b) => a + b, 0);
Object.defineProperty(this, 'sum', { value: val, writable: true, configurable: true });
return val; // ← 以后 sum 变成普通数据属性——不再重复计算!
}
};
// 场景 3:兼容旧 API——内部改成新实现,外部接口不变
const legacyAPI = {
get fullName() { return `${this.firstName} ${this.lastName}`; }
// 老代码还在读 fullName,但内部已经是合成属性
};
// 场景 4+5:响应式拦截——Vue 2.x 的 defineProperty 响应式(见 §10.2)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 9. Mixin与安全
# 9.1 对象混入 vs
// 对象混入:浅拷贝属性——简单但容易覆盖已有属性
const mixin = (target, ...sources) => {
for (const src of sources) {
for (const key of Reflect.ownKeys(src)) {
// 跳过原型污染风险键:__proto__ / constructor / prototype
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(src, key));
}
}
return target;
};
// 类混入:利用 class extends 的链式表达能力
const Timestamped = Base => class extends Base {
createdAt = Date.now();
};
const Serialized = Base => class extends Base {
serialize() { return JSON.stringify(this); }
};
class Model extends Timestamped(Serialized(Object)) {}
const m = new Model();
console.log(m.createdAt); // → 时间戳(来自 Timestamped)
console.log(m.serialize()); // → JSON 字符串(来自 Serialized)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 9.2 原型污染攻击
2018 年 lodash 的 defaultsDeep 函数被爆出一个原型污染漏洞(CVE-2018-3721)。攻击源头是——当用户可控的数据被用来做 _.defaultsDeep 合并时,__proto__ 这个键被当作"普通属性"而非"原型链访问器"处理:
// 攻击载荷
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// 如果合并函数没过滤 __proto__:
// _.defaultsDeep({}, payload)
// → Object.prototype.isAdmin = true ← 污染了全局原型!
// 之后:
const user = {}; // 普通用户对象
console.log(user.isAdmin); // → true ← 原型被污染!
2
3
4
5
6
7
8
9
10
防护措施:
// ① 用 Object.create(null) 创建无原型的"纯字典"
const safeObj = Object.create(null);
// safeObj.__proto__ = xxx → 在 safeObj 上创建一个叫做 "__proto__" 的普通属性
// 不会影响原型链——因为 safeObj 没有原型链!
// ② 过滤危险键
const BLOCKED_KEYS = ['__proto__', 'constructor', 'prototype'];
function safeAssign(target, source) {
for (const key of Object.keys(source)) {
if (BLOCKED_KEYS.includes(key)) {
console.warn(`Blocked prototype pollution attempt: ${key}`);
continue;
}
target[key] = source[key];
}
}
// ③ 冻结 Object.prototype(防御终极手段——但可能影响第三方库)
Object.freeze(Object.prototype);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 9.3 Object.c
用 Object.create(null) | 用普通 {} |
|---|---|
| ✅ 纯字典(只做 KV 存储)——如缓存、配置、路由表 | ✅ 需要 toString/hasOwnProperty 等原型方法 |
✅ 防御原型污染——__proto__ 变成普通属性 | ✅ 需要放到原型链上被其他对象继承 |
| ✅ 不受第三方代码污染影响 | ✅ 需要配合框架的响应式系统(Vue 2 通过原型链追踪变更) |
// ❌ 不应该用 Object.create(null) 的地方
const vm = Object.create(null);
// Vue 不能把 vm 做成响应式——因为 Vue 2 依赖 Object.defineProperty 和原型链
// 第 10.2 节会展开
2
3
4
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的原型污染攻击——七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① prototype / __proto__ / [[Prototype]] 的关系 | 第 2.1 节:[[Prototype]] 是规范内部槽(读用 getPrototypeOf);proto 是 Object.prototype 上的 legacy accessor;prototype 是函数的属性,作为 new 的原型模板。三个层次 |
| ② V8 怎么沿原型链找属性 | 第 3.2 节:第一次查找遍历原型链→IC 记录"Map + 原型跳数 + 属性偏移"→后续直接两条 mov 指令完成读取 |
| ③ new 的四步是什么 | 第 4.1 节:创建空对象→设置 [[Prototype]] → 执行构造器→返回对象/this |
| ④ 五种继承方式每一步解决了什么 | 第 5 章:原型链(共享引用bug)→盗用构造(方法不继承)→组合(Parent 调两次)→寄生组合(一次+方法继承)→class(语法糖+不可枚举) |
| ⑤ class 脱糖后是什么 | 第 6.1 节:_classCallCheck防忘记 new + _createClass 挂原型方法 + _inherits 寄生组合继承 |
| ⑥ [[HomeObject]] 怎么让 super 找到父类 | 第 7.1 节:每个类方法创建时记录 [[HomeObject]]=该方法所在的原型对象→super 沿这个对象的原型链查找 |
| ⑦ configurable/writable/freeze 怎么联动 | 第 8 章:configurable: false 不可逆→seal = 所有属性设 configurable: false + 禁加属性→freeze = seal + 所有数据属性 writable: false |
修复方案(按代价从小到大):
方案 A:for...in 换 Object.keys + 过滤危险键
// ❌ 危险——for...in 遍历原型链
for (const key in userConfig) { config[key] = userConfig[key]; }
// ✅ 安全——只遍历自身属性 + 过滤 __proto__
for (const key of Object.keys(userConfig)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
config[key] = userConfig[key];
}
2
3
4
5
6
7
8
方案 B:用 Object.create(null) 创建目标对象(防御终极方案)
// config 没有原型链——__proto__ 此时只是一个普通字符串属性
const config = Object.create(null);
// config.__proto__ = xxx → 在 config 上创建名为 "__proto__" 的普通数据属性
// 不影响任何原型链!
2
3
4
# 10.2 实现 Vue-s
// ── defineProperty 版(Vue 2.x 风格)──
let activeEffect = null; // 当前正在执行的依赖收集函数
const targetMap = new WeakMap();
function reactive(obj) {
for (const key of Object.keys(obj)) {
defineReactive(obj, key, obj[key]);
}
return obj;
}
function defineReactive(obj, key, val) {
// 递归处理嵌套对象
if (typeof val === 'object' && val !== null) reactive(val);
const dep = new Set(); // 依赖集合(订阅这个 key 的所有 effect)
Object.defineProperty(obj, key, {
get() {
if (activeEffect) dep.add(activeEffect); // 收集依赖
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
if (typeof newVal === 'object') reactive(newVal); // 新值是对象→递归
dep.forEach(effect => effect()); // 通知更新
},
enumerable: true,
configurable: true
});
}
// 缺陷:
// 1. 无法检测新增属性(obj.newKey = 'val' 不会触发 get/set)
// 2. 无法检测删除属性(delete obj.key 不会触发 set)
// 3. 数组索引赋值(arr[0] = x)和 .length 修改无法检测
// 4. 递归遍历整个对象——大对象初始化慢
// ── Proxy 版(Vue 3.x 风格——在 07 篇全面展开)──
const reactiveMap = new WeakMap(); // 缓存:同一个对象只创建一次 Proxy
function reactive(obj) {
if (reactiveMap.has(obj)) return reactiveMap.get(obj);
const proxy = new Proxy(obj, {
get(target, key, receiver) {
track(target, key); // ← 可拦截动态新增属性!
const res = Reflect.get(target, key, receiver);
return typeof res === 'object' && res !== null ? reactive(res) : res; // 懒递归
},
set(target, key, value, receiver) {
const old = target[key];
const result = Reflect.set(target, key, value, receiver);
if (old !== value) trigger(target, key); // ← 可拦截新增和修改
return result;
},
deleteProperty(target, key) {
const had = key in target;
const result = Reflect.deleteProperty(target, key);
if (had) trigger(target, key); // ← 可拦截删除!
return result;
}
});
reactiveMap.set(obj, proxy);
return proxy;
}
function track(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
if (activeEffect) dep.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) dep.forEach(effect => effect());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# 10.3 设计哲学回扣
哲学一·「委托优于复制——原型链是单一共享、链式查找的哲学」
JS 没有选择"每个实例拷贝一份方法"(浪费内存),而是让所有实例共享同一份原型上的方法,通过原型链查找。这是对"大部分方法在所有实例上都是一样的"这一观察的极致利用——用一条单向链表省掉了 n 份拷贝。但这种"委托"机制的代价是:属性访问的性能依赖于链的长度(除非 V8 的 IC 记住了路径)。这也是为什么 ES6 class 选择在原型上定义方法而非在 constructor 中定义——前者是共享的,后者是拷贝的。
哲学二·「class 不是新类型——是原型链的语法封装,JS 的对象模型从 1995 年至今没有变过」
ES6 的 class 没有引入新的对象模型。它只是对寄生组合继承 + 原型链的语法封装。class A {} 在 V8 内部的表示和 function A() {} 没有任何本质区别——两者都被编译为同一个 JSFunction C++ 对象。JS 的 class 是"糖"不是"新基础设施"——这既是它的简洁所在(不用学两套对象模型),也是"为什么 JS 的 class 和 Java 的 class 不一样"的根源(没有静态类型系统、没有接口、没有重载)。
哲学三·「属性描述符是 JS 的'反射元数据'层——JS 在运行时给你 C++ 编译器级别的控制力」
configurable / writable / enumerable / get / set——这套元数据体系让 JS 的对象模型具有传统 OOP 语言少见的"运行时自省"能力。你可以冻结任何对象(freeze)、拦截任何属性的读写(getter/setter)、控制属性的可见性(enumerable)——这种元数据层是 Vue 响应式系统(defineProperty 版)的基石。Proxy 在 ES6 中把这个能力扩展到了 13 种操作(属性读写之外还包括 delete、in、Object.keys、构造函数调用等)——这是 JS 从"属性级别的拦截"到"对象级别的拦截"的一次跃迁。
哲学四·「原型污染是'动态扩展'的硬币反面——你能加好方法,攻击者也能加坏方法」
原型链的"运行时扩展"能力(如 Array.prototype.last = fn)是 JS 灵活性的象征。但它也是原型污染攻击的入口——因为"任何人"都可以往 Object.prototype 上加属性。这引出了 JS 安全的一个底层矛盾:"默认开放" vs "默认安全"。ES2022 引入 Object.hasOwn(静态版 hasOwnProperty)就是为了让开发者可以安全地检查自身属性而不受原型污染影响。Object.create(null) 提供了一个"关闭原型链"的逃生舱——用"放弃继承"换"绝对安全"。
# 10.4 速查表
三角模型速查:
| 名字 | 谁有这个属性 | 可读/可写 | 规范地位 | 用途 |
|---|---|---|---|---|
[[Prototype]] | 每个对象 | 规范内部(JS 不可直接访问) | ECMA-262 Internal Slot | 构成原型链 |
__proto__ | Object.prototype 上的 accessor | ✅ 可读可写 | [[Legacy]](不推荐) | 历史遗留 |
prototype | 只有函数(包括 class) | ✅ 可读写 | ECMA-262 §20.2.4 | new 的原型模板 |
继承五法演进速查:
| 方式 | 属性独立 | 方法复用 | 父类构造器调用次数 | 缺陷 |
|---|---|---|---|---|
| 原型链继承 | ❌(共享引用) | ✅ | 1 次 | 引用类型被所有实例共享 |
| 盗用构造函数 | ✅ | ❌ | 1 次 | 方法不能复用 |
| 组合继承 | ✅ | ✅ | 2 次 | 父类被重复调用 |
| 寄生组合继承 | ✅ | ✅ | 1 次 | 无(ES6 class extends 的蓝图) |
| ES6 class extends | ✅ | ✅ + 不可枚举 | 1 次(super()保证) | 无 |
三级冻结速查:
| 方法 | 禁止添加新属性 | 禁止删除属性 | 禁止修改已有属性值 | 可逆? |
|---|---|---|---|---|
preventExtensions | ✅ | ❌ | ❌ | ❌ |
seal | ✅ | ✅ | ❌(writable: true 可改) | ❌ |
freeze | ✅ | ✅ | ✅(数据属性全设 writable: false) | ❌ |
class 脱糖对照速查:
| class 语法 | 脱糖等价 | 防护了什么 |
|---|---|---|
class Foo {} | function Foo() { _classCallCheck(this, Foo); } | 防止直接调用(Foo() 报错) |
method() {} | Object.defineProperty(Foo.prototype, 'method', { value: fn, enumerable: false }) | 方法不可枚举 |
class Child extends Parent {} | _inherits(Child, Parent) = Object.create + Object.setPrototypeOf | 寄生组合继承 + 静态方法继承 |
constructor() { super(); } | Parent.call(this) + 强制先 super | 确保 this 初始化链完整 |
下一步:属性描述符有局限——检测不到新增和删除的属性。ES6 给了我们一个更强大的工具——Proxy 能拦截 13 种操作。进入 07.Proxy 元编程与迭代协议。