——小Dora 的 JavaScript 修炼日记 · Day 5
“你以为你在访问对象属性,其实你是在走一条原型修炼者的查找之路。”
——一个被__proto__绊倒三次的前端人
前几日你学了作用域、上下文、闭包……
今天要解封的,是 JavaScript 的江湖绝学:
🌀 原型 & 原型链
这不是江湖新词,它藏在你每天敲的代码里:
- 你以为
obj.toString()是obj自己的? - 你以为
instanceof是个魔法? - 你以为
extends是 ES6 的福音?其实它背后也是 prototype。
本期关键词:
__proto__、prototype、原型链、属性查找、继承、instanceof、V8 Hidden Class
🧠 一、原型系统初体验:你以为你 new 出的是空对象?
function Person(name) {
this.name = name;
}
const p = new Person("小Dora");
执行完上面代码后,实际上发生了以下仪式感十足的流程:
- 创建一个空对象
p - 把
p.__proto__指向Person.prototype - 执行
Person.call(p, "小Dora")把 name 挂到p上
👉 注意!__proto__ 是每个实例对象身上的隐式原型属性,指向构造函数的 prototype。
🔍 二、搞清楚三个「Prototype」的概念
1、先搞清楚三个核心对象及属性所在位置
| 对象/函数 | 有哪些关键属性 | 含义 |
|---|---|---|
| 函数对象 Foo | .prototype, .__proto__ | .prototype 是函数特有,用于创建实例的原型;__proto__ 指向 Function.prototype(函数的原型) |
| 实例对象 obj | .__proto__ | 指向创建它的函数的 .prototype,用于属性查找的“原型链” |
| 函数的 prototype 对象 | .constructor, .__proto__ | .constructor 指向函数本身;__proto__ 指向 Object.prototype |
2、关系图(箭头指向是“指向”)
实例对象 obj
│
└── __proto__ ──▶ Foo.prototype 对象
│
├── constructor ──▶ Foo 函数对象
│
└── __proto__ ──▶ Object.prototype
Foo 函数对象
│
└── __proto__ ──▶ Function.prototype
3、它们之间的核心逻辑
| 名称 | 角色 | 作用和指向 |
|---|---|---|
obj.__proto__ | 实例的隐式原型链指针 | 指向 Foo.prototype,查找属性时用 |
Foo.prototype | 构造函数的显式原型对象 | 用作实例对象的原型,保存共享属性和方法 |
Foo.prototype.constructor | 连接回构造函数的引用 | 指向 Foo,标识该原型对象属于哪个构造函数 |
Foo.__proto__ | 函数的隐式原型链指针 | 指向 Function.prototype,因为函数也是对象 |
4、巧记口诀
- 「实例的
__proto__指向构造函数的prototype」 - 「构造函数的
prototype.constructor指回构造函数」 - 「构造函数(函数)本身的
__proto__指向Function.prototype」
一句话记忆:
实例指向原型,原型指回函数,函数指向函数原型。
5、补充:为什么函数有 prototype 而普通对象没有?
prototype是函数创建实例时的“模板”,普通对象没法new,自然没有prototype- 所以访问普通对象的
prototype是undefined
🧭 三、原型链是怎么查找属性的?
你执行 obj.xxx 时,JS 引擎会做:
- 先在
obj自身找属性 - 找不到就看
obj.__proto__ - 再往
obj.__proto__.__proto__查找 - 最后到达
Object.prototype,否则返回undefined
const obj = {};
console.log(obj.toString); // 来自 Object.prototype
🔁 属性查找路径图示:
obj → obj.__proto__ → Object.prototype → null
⚠️ 注意:原型链是对象间的链,而不是函数调用栈!
🏗️ 四、V8 中的原型链真的还是“链”吗?其实是 Hidden Class + Inline Cache 的高速结构!
💥 表面看是链,其实 V8 早已偷偷换上“喷气引擎”
你以为你访问 obj.xxx 是一层一层爬原型链,其实 V8 早就不那样做了。
V8 做了两件事:
- 将对象抽象为 Hidden Class(隐藏类) —— 类似于 Java/C++ 的结构定义
- 为对象访问构建 Inline Cache(内联缓存) —— 快速记忆属性查找路径
🔍 什么是 Hidden Class?
V8 为每个对象动态生成隐藏类(内部结构类似状态机):
function Foo() {
this.x = 1;
}
const a = new Foo(); // 分配 HiddenClass_H0
a.y = 2; // 变为 HiddenClass_H1(状态转移)
类图结构(类似状态跳转):
HiddenClass_H0: { x }
|
添加 y
↓
HiddenClass_H1: { x, y }
🧠 每次对象结构改变(新增属性,顺序不同)都会触发 Hidden Class 转移。这就是为什么 动态添加属性会影响性能!
🧪 举个经典优化与反优化例子:
function Point(x, y) {
this.x = x;
this.y = y;
}
const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
上面代码中,p1 和 p2 拥有相同的 Hidden Class,内存布局一致,V8 可以共享优化。
🧨 但如果你动态加属性:
p1.z = 5;
此时 p1 的 Hidden Class 发生变异,优化中断,p1 和 p2 不再共享结构。
⚙️ Hidden Class 的存在意义?
V8 是为性能设计的,它要:
- 避免 JS 的动态特性带来的频繁查找
- 让对象像 C++ 一样高效定位属性偏移量(offset)
V8 为每个 Hidden Class 分配属性偏移表(Property Descriptor),这样当你访问 p1.x 时,V8 不需要在原型链上一层一层查找,而是:
“p1 是 HiddenClass_H1 类型,x 在 offset 0 上,走你!”
🧠 再讲讲 Inline Cache(IC):属性访问怎么越来越快?
当 JS 引擎第一次遇到 obj.prop 时,它会去原型链查找,并把查找路径缓存下来。
下次遇到同样的对象结构,就可以直接命中缓存,不必重复找。
obj.a; // 第一次找,全链查找
obj.a; // 第二次找,直接命中 Inline Cache,性能飞起 🚀
这就是为什么 结构稳定的对象能让 V8 优化到极致!
⚠️ 面试 / 项目优化建议
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 构造函数里动态加属性 | 在构造函数内统一定义 | 避免 Hidden Class 转换 |
| 对象属性顺序 | 保持一致 | 保证对象结构一致性 |
| 原型继承 | 使用 Object.create | 避免不必要的继承层干扰 Hidden Class 构建 |
| 批量对象创建 | 不要乱改结构(比如后期加字段) | 防止 V8 回退到 dictionary 模式(超慢) |
🧠 原型链与 Hidden Class 联动小结
| 机制 | 本质 | V8 优化方式 |
|---|---|---|
| 原型链查找 | 多层对象间 __proto__ 链 | 缓存路径 + 属性偏移量优化 |
| 对象属性结构 | 动态 | Hidden Class + 状态跳转图 |
| 属性访问优化 | 原始为线性搜索 | Inline Cache 内联缓存 |
| 不规范结构对象 | 动态新增属性、结构不一致 | 回退为 dictionary 模式,极慢 ⚠️ |
🧪 补充 Debug 工具推荐(深入分析 Hidden Class):
在 Chrome DevTools 中:
%HaveSameMap(obj1, obj2) // 判断是否拥有相同 Hidden Class
或者使用 V8 Inspector Protocol + --trace_maps 参数启动 Node。
✅ 血与泪教训总结
- 动态添加属性 ≠ 万能扩展,而是打断优化之刃
- 属性查找 ≠ 原型链爬树,而是 V8 提前铺路的内联查找高速公路
- 如果你能保证构造函数结构稳定、使用标准继承方式,你的代码就能吃到 V8 优化的大餐 🍖
🧬 五、手写继承经典场景:组合继承 + 原型链继承
🔧 原型链继承
function Animal() {}
Animal.prototype.eat = function () {
console.log("吃饭");
};
function Dog() {}
Dog.prototype = new Animal();
const dog = new Dog();
dog.eat(); // 吃饭
🧨 问题:所有实例共享同一个父类实例!
🧪 组合继承(最常见)
function Animal(name) {
this.name = name;
}
Animal.prototype.sayHi = function () {
console.log("Hi", this.name);
};
function Dog(name) {
Animal.call(this, name); // 继承属性
}
Dog.prototype = Object.create(Animal.prototype); // 继承方法
Dog.prototype.constructor = Dog;
const d = new Dog("小哈");
d.sayHi(); // Hi 小哈
🔍 六、instanceof 判断原理:沿着 proto 走
function Foo() {}
const f = new Foo();
console.log(f instanceof Foo); // true
👉 实际上做了这件事:
function myInstanceof(obj, Constructor) {
let proto = Object.getPrototypeOf(obj);
const prototype = Constructor.prototype;
while (proto) {
if (proto === prototype) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}
🧠 所以判断的是:某对象的原型链上是否存在某构造函数的 prototype
🧠 七、结合上下文与词法环境的理解
虽然原型链不属于执行上下文的范畴,但它会在 V8 引擎进行属性查找时和作用域链一起生效:
| 类型 | 查找路径 | 存储位置 |
|---|---|---|
| 变量查找 | 词法环境链(作用域链) | 执行上下文 |
| 对象属性查找 | 原型链(proto 链) | 对象 + Hidden Class |
💥 闭包保存词法环境,而原型链保存的是构造函数继承路径,两者共同构成 JS 的“运行时灵魂双剑”。
📦 八、class 是糖?不,是精装 prototype
class Animal {
say() {
console.log("hi");
}
}
const a = new Animal();
等价于:
function Animal() {}
Animal.prototype.say = function () {
console.log("hi");
};
📌 class 实际创建的是构造函数,其方法挂在 Class.prototype 上。
✅ 结构仍是原型链:
a.__proto__ === Animal.prototype
Animal.prototype.constructor === Anima
这是一个非常关键且有深度的问题,尤其涉及 ES6 class 继承背后的原型链机制和 super 的工作原理。下面我详细解析 super 的继承机制,帮你理解它到底是怎么“跑”的。
九、ES6 super 的继承机制解析
1. super 在类中的含义
-
super关键字主要有两种用途:- 在子类构造函数中调用父类构造函数:
super(...)用来调用父类的构造器,负责初始化父类部分的属性。 - 在子类方法中调用父类同名方法:
super.method()调用父类原型上的对应方法。
- 在子类构造函数中调用父类构造函数:
2. ES6 class 继承本质
- ES6
class的继承本质是通过原型链和内部机制实现的,继承了原型链与构造函数链的双重机制。 - 子类的
prototype.__proto__指向父类的prototype,保证实例方法继承。 - 子类的构造函数本身(即函数对象)的
__proto__指向父类构造函数,保证静态属性继承。
3. 详细示意
class Parent {
constructor(name) {
this.name = name;
}
sayHi() {
console.log('Hi, I am ' + this.name);
}
static staticMethod() {
console.log('Parent static method');
}
}
class Child extends Parent {
constructor(name, age) {
super(name); // 调用父类构造函数
this.age = age;
}
sayHi() {
super.sayHi(); // 调用父类方法
console.log('And I am ' + this.age + ' years old');
}
static staticMethod() {
super.staticMethod(); // 调用父类静态方法
console.log('Child static method');
}
}
原型链结构示意:
Child.prototype.__proto__ === Parent.prototype
Child.__proto__ === Parent
- 实例方法继承: 子类原型链链上指向父类原型,实例访问方法时会顺着链找到父类方法。
- 静态方法继承: 子类构造函数对象链上指向父类构造函数对象,所以静态方法也被继承。
4. super 的调用底层机制
super实际上是通过访问**父类原型对象([[HomeObject]].[[GetPrototypeOf]])**来实现的。- 例如,
super.sayHi()实际调用的是Parent.prototype.sayHi.call(this)。 - 在构造函数中,
super()是调用父类构造函数,确保父类实例属性被正确初始化。
5. super 和执行上下文
super调用绑定的是当前实例对象的this,即调用时上下文不会变。- 这保证了即使是调用父类方法,
this依然是子类实例。
6. 总结
| 关系 | 描述 |
|---|---|
Child.prototype.__proto__ | 指向 Parent.prototype,实现实例方法继承 |
Child.__proto__ | 指向 Parent 构造函数,继承静态方法 |
super() | 在子类构造函数中调用父类构造函数,初始化父类属性 |
super.method() | 调用父类原型上的同名方法,this 绑定当前子类实例 |
📋 原型系统专属 Checklist 自检清单(进阶)
- 我能区分 prototype / proto / constructor?
- 我能准确解释对象属性的查找路径?
- 我能手写
instanceof实现? - 我了解原型链查找过程和终点?
- 我理解组合继承 / 原型继承的实现细节?
- 我知道 V8 会生成 Hidden Class 优化对象访问?
- 我知道原型链 ≠ 作用域链,两者分工明确?
- 我能 debug 原型链中的属性覆盖和查找?
- 我能用图画出多个构造函数继承的链条?
- 我理解 ES6 class 背后仍是 prototype 的语法糖?
✅ Day 5 总结打卡
| 概念 | 说明 |
|---|---|
__proto__ | 每个对象的隐式原型,指向其构造函数的 prototype |
prototype | 每个函数对象自带,用于构造实例的原型链 |
| 原型链 | 属性查找的路径,由对象逐层连接其 __proto__ 构成 |
| Hidden Class | V8 优化用的“隐藏类型系统”,加快属性访问 |
| 继承实现 | JS 使用原型链 + 构造函数组合式继承 |
instanceof | 检查构造函数 prototype 是否在对象原型链上 |
🎯 你不是学会了原型,而是:
在千层原型链、闭包作用域、上下文栈、V8 执行模型的交错中
能准确找到变量和属性的来源
能合理设计继承链避免性能陷阱
能解释 JS 为什么是世界上最“灵活”的语言之一!