习惯了 C++ 或 Java 严谨面向对象体系的开发者,初遇 JavaScript 的原型(Prototype)时往往会感到极其违和。没有 class 内存布局,没有严格的虚函数表,所有的继承全靠几个长得差不多的属性名(__proto__、prototype)绕来绕去。
但如果我们将视角下沉到内存分配 和指针指向,你会发现 JS 的原型机制极其精简。今天,我们将通过三段循序渐进的代码,用 C++/Java 的底层思维,把 JS 的原型链彻底扒光。
阶段一:构造函数与共享字典 (Person 实例)
在 Java 或 C++ 中,类(Class)是图纸,对象是按照图纸在堆内存中复刻出来的实体,方法通常存放在公共的方法区。JS 没有传统意义上的类,它是如何实现方法共享的?
来看第一段代码:
JavaScript
// 1. 在"公共字典"中挂载方法
Person.prototype.say = function() {
console.log('我是大帅哥');
}
// 2. 构造函数(充当 C++/Java 中的 Constructor)
function Person() {
this.name = '强哥'; // 私有属性,放进实例的堆内存
}
// 3. 实例化对象
const p = new Person();
p.say(); // 输出:我是大帅哥
C++/Java 视角的底层翻译:
- 当 JS 引擎解析到
function Person()时,不仅在内存中创建了函数对象,还强制分配了一块独立的哈希表内存 ,并将Person.prototype这个指针指向它。你可以把这块内存理解为 C++ 中属于这个类的 VTable(虚函数表/公共字典) 。 - 当执行
new Person()时,引擎在堆内存分配了一块新空间给p。 - 核心动作:
new关键字会在底层秘密执行一行 C 代码层面的绑定:p.__proto__ = Person.prototype。 - 当调用
p.say()时,引擎先去p自己的内存里找,没找到;于是顺着__proto__这根隐式指针,精准定位到了那块"公共字典",找到了say方法并执行。
总结:prototype 是挂在工厂上的公共地址,__proto__ 是插在产品上的寻址天线。
阶段二:属性的遮蔽效应 (Car 实例)
如果实例自身的内存和公共字典里出现了同名属性,会发生什么?这是面向对象中"重写(Override)"概念在 JS 中的体现。
来看第二段代码
JavaScript
// 公共字典里的属性
Car.prototype.name = 'su7';
function Car(color) {
this.name = 'su6'; // 实例肚子里的私有属性
this.color = color; // 接收参数
}
const car = new Car('blue');
console.log(car.name); // 输出是什么?
C++/Java 视角的底层翻译:
在 C++ 中,子类的成员变量会隐藏父类的同名成员变量。JS 的逻辑更加简单粗暴:单向链表的短路机制。
Car.prototype字典里存着name: 'su7'。new Car('blue')执行后,car实例自己的堆内存里存着name: 'su6'和color: 'blue'。- 当你访问
car.name时,引擎开始顺藤摸瓜。它首先访问car自身的内存空间。 - 命中! 引擎直接找到了
'su6',立即返回并终止查找。它绝对不会 再顺着__proto__指针去公共字典里看一眼。
这就叫遮蔽效应(Shadowing) 。想要调用公共字典里的属性,前提是你自己肚子里确实没有。
阶段三:暴力拼接指针,实现继承链 (Grand -> Father -> Child)
前面两个阶段都是单层查找。既然 __proto__ 是一根指针,那我们能不能把多个字典用指针首尾相连,拼成一条长长的单向链表?
这就是 JS 早期实现"继承"的终极杀招。来看这段层层递进的代码:
JavaScript
Grand.prototype.house = function() {
console.log('汤臣一品');
}
function Grand() {
this.name = 1000000000;
}
// ==================== 继承核心动作 ====================
// 将 Father 的公共字典,强行替换为 Grand 的一个实例!
Father.prototype = new Grand();
function Father() {
this.name = '张';
}
// 同理,将 Child 的公共字典,强行替换为 Father 的一个实例!
Child.prototype = new Father();
function Child() {
this.age = 18;
}
const p = new Child();
p.house(); // 输出:汤臣一品
C++/Java 视角的底层翻译(指针重定向):
这段代码如果不看内存,会觉得极其不可理喻。为什么要把 prototype 赋值为一个 new 出来的实例?
我们单步拆解这个"狸猫换太子"的指针操作:
new Grand()在堆内存中造了一个对象(我们叫它objG)。它肚子里有name: 1000000000,且objG.__proto__指向Grand.prototype(这里面有house方法)。Father.prototype = objG;引擎直接把Father原本自带的空字典当垃圾扔了,将指针强行接到了objG上。- 随后,
new Father()造出了objF。注意,此时objF.__proto__指向的就是objG了! Child.prototype = objF;再次重定向指针。- 最终
new Child()造出了p。
寻址链条(单向链表遍历):
当你调用 p.house() 时,V8 引擎开始在链表上狂奔:
p 自身(无) <math xmlns="http://www.w3.org/1998/Math/MathML"> → \rightarrow </math>→ 顺着指针找到 objF(无) <math xmlns="http://www.w3.org/1998/Math/MathML"> → \rightarrow </math>→ 顺着指针找到 objG(无) <math xmlns="http://www.w3.org/1998/Math/MathML"> → \rightarrow </math>→ 顺着指针找到 Grand.prototype(找到了 house 函数!)。
在 C++ 中,继承是通过编译器在编译阶段构建复杂的对象内存模型和虚表来实现的。而在 JS 中,继承纯粹是在运行时(Runtime)暴力修改对象的指针走向,将一块块独立的内存生生串成了一条名为"原型链"的单向链表。
进阶优化:修补被斩断的钢印
上面的 Grand -> Father -> Child 代码在逻辑上打通了,但在工程规范上有一个致命的瑕疵,这也是很多高级面试会追问的细节。
在 JS 中,每个原本由引擎自动生成的 prototype 字典里,都天生自带一个 constructor 属性,这个属性反向指回 构造函数本身(比如 Grand.prototype.constructor === Grand)。这就像是出厂产品上的防伪钢印,用来查明一个对象是哪个工厂造的。
但是,当你执行 Father.prototype = new Grand(); 时,你用一个全新的对象整体覆盖 了原来的字典,那个自带的 constructor 钢印被你弄丢了!
专业级代码的补救措施:
每次在进行这种"狸猫换太子"的指针重定向后,必须手动把钢印补回去:
JavaScript
// 1. 强行拼接链表
Father.prototype = new Grand();
// 2. 补上丢失的钢印,保证 p.constructor 的指向正确
Father.prototype.constructor = Father;
Child.prototype = new Father();
Child.prototype.constructor = Child;
理解了这三个阶段,你就看透了 JavaScript 面向对象的核心骨架:没有森严的类继承体系,只有靠着 prototype 和 __proto__ 临时拼接、随时可变的指针链条。