目录
- 引言:为什么原型是前端工程师绕不过去的一课
- 一、先建立统一认知:对象原型到底是什么
- 二、
prototype和[[Prototype]]不是一回事 - 三、从
new和内存视角理解实例、构造函数与原型 - 四、函数原型上的高频知识点:共享属性与
constructor - 五、重写原型对象时,为什么最容易踩坑
- 六、创建对象的推荐姿势:实例数据放
this,共享方法放prototype - 实战建议
- 总结:关键结论与团队落地建议
引言:为什么原型是前端工程师绕不过去的一课
很多团队在日常开发里已经很少手写“构造函数 + 原型”这套模式了,更多时候我们写的是 class、对象字面量、组合式函数,甚至直接用框架帮我们屏蔽底层细节。于是原型这件事,常常只在面试里出现,看起来像“八股”,但一旦线上排查问题,它又会突然变得非常真实:
- 为什么两个实例的方法地址相同?
- 为什么给对象赋值后没有覆盖到原型上的值?
- 为什么重写
prototype之后,constructor看起来“不对了”? - 为什么控制台里
__proto__看起来什么都有,但代码里又不建议用它? - 为什么
class最终仍然离不开原型链?
如果对这些问题没有统一心智模型,工程上就会出现两类常见问题:一类是“会用但讲不清”,另一类是“改得动但不敢改”。而原型真正的价值,不在于背定义,而在于帮助我们理解 JavaScript 的对象系统、继承机制、方法共享、内存结构,以及很多框架设计背后的语言基础。
这篇文章的目标很明确:不是把概念堆给你,而是把“对象、函数、构造函数、实例、原型、构造器”这几者之间的关系,一次性串起来。读完之后,你至少应该能建立起一个稳定的判断标准:什么应该挂在实例上,什么应该挂在原型上,什么时候可以重写原型,重写后又要补什么。
一、先建立统一认知:对象原型到底是什么
在 JavaScript 中,几乎每个对象都带着一个隐藏的内部链接,这个内部链接在规范里叫 [[Prototype]]。它会指向另一个对象,而这个“被指向的对象”,就是当前对象的原型对象。
你可以把它理解成:当前对象在找不到某个属性时,下一站该去哪里找。
1. 原型最核心的作用:兜底查找
当我们访问一个对象属性时,会触发内部的 [[Get]] 过程;当我们给对象设置属性时,会触发 [[Set]] 过程。
| 操作 | 触发时机 | 原型参与方式 |
|---|---|---|
[[Get]] | 读取属性时 | 先查对象自身,找不到再沿原型向上查 |
[[Set]] | 设置属性时 | 优先看当前对象及属性描述符,再决定是否在当前对象创建新属性 |
下面这个例子最能说明问题:
function A() {}
A.prototype.x = 10
const obj = new A()
console.log(obj.x) // 10,obj 自身没有 x,沿原型找到 A.prototype.x
obj.x = 20
console.log(obj.x) // 20,此时 obj 自身已经有了 x
这里发生了两件事:
- 第一次读
obj.x,对象自身没有,沿着原型找到A.prototype.x - 第二次写
obj.x = 20,是在实例自身新增了一个同名属性,而不是改掉原型上的x
这也是很多人第一次理解“共享”和“遮蔽(shadowing)”的关键入口。
2. 对象字面量创建出来的对象,也有原型
很多人以为只有通过构造函数创建出来的对象才有原型,这其实不对。只要是普通对象,通常都有 [[Prototype]]。
const obj = { name: 'XiaoWu' }
const foo = {}
console.log(obj.__proto__)
console.log(foo.__proto__)
图:隐式原型在浏览器与终端中的表现
控制台里你看到的结果,和真实运行时的内部结构并不完全等价。浏览器控制台为了方便调试,会把一些继承来的内容也展开给你看;Node 的输出则更接近“对象本身 + 原型关系”的表现。
从理解层面,可以先把它抽象成下面这样:
const obj = { name: 'XiaoWu', __proto__: {} }
const foo = { __proto__: {} }
当然,真正的 [[Prototype]] 不是你字面量里真的写出来的这个字段,而是引擎内部维护的链接关系。
3. __proto__、[[Prototype]]、Object.getPrototypeOf 到底什么关系?
这是高频混淆点,必须一次说清:
[[Prototype]]:规范层面的内部槽,真实存在,但你不能直接写代码访问这个名字__proto__:历史遗留的访问器属性,调试方便,但不推荐作为正式代码依赖Object.getPrototypeOf(obj):标准 API,推荐在正式代码里使用
const obj = { name: '小吴' }
console.log(Object.getPrototypeOf(obj))
调试场景里,obj.__proto__ 确实更顺手;工程代码里,优先使用 Object.getPrototypeOf(obj)。原因很简单:
- 语义标准、跨环境更稳定
- 可维护性更高
- 降低“我在操作语言底层 hack 口子”的心智负担
顺手补一句:今天的引擎几乎都支持 __proto__,但“能用”不等于“应该作为主路径使用”。
本章小结
- 每个对象的核心原型关系,体现在内部的
[[Prototype]] - 读取属性找不到时,会沿原型继续查找
- 给实例赋值,不等于改原型;很多时候只是“在实例自身新增同名属性”
__proto__更适合调试,正式代码优先Object.getPrototypeOf- 理解原型,本质是在理解 JavaScript 如何做“属性查找”和“能力复用”
二、prototype 和 [[Prototype]] 不是一回事
聊原型最容易踩的第一个坑,就是把 prototype 和 [[Prototype]] 混为一谈。它们名字很像,但角色完全不同。
1. prototype 是函数身上的属性,不是所有对象都有
先看例子:
function foo() {}
const obj = {}
console.log(foo.prototype) // 普通函数默认有 prototype
console.log(obj.prototype) // undefined,普通对象没有 prototype
这里有一个非常重要的判断标准:
prototype是函数对象上的一个属性,主要给“作为构造函数使用”时服务[[Prototype]]是对象内部的原型链接,普通对象、函数对象都可能有
也就是说:
- 函数是对象,所以函数也有
[[Prototype]] - 但普通对象不是函数,所以普通对象没有
prototype
2. 这两个概念各自负责什么?
可以直接用一句最工程化的话来理解:
prototype:定义将来由这个构造函数创建出来的实例,应该共享什么[[Prototype]]:当前这个对象,实际沿哪条链路去查找属性
它们的职责并不重复:
-
归属不同
prototype属于函数;[[Prototype]]属于对象 -
作用不同
prototype用来定义共享能力;[[Prototype]]用来参与查找路径 -
时机不同
prototype通常在定义阶段配置;[[Prototype]]通常在对象创建时被确定
3. 纠正一个特别容易出现的误区
很多人在刚学到这里时,会误以为:
“函数自己的隐式原型会指向它自己的显式原型”
这是错误的。
准确关系应该是:
foo.prototype:给将来new foo()出来的实例用Object.getPrototypeOf(foo):函数对象foo自己的原型,通常是Function.prototype
也就是说:
function foo() {}
console.log(Object.getPrototypeOf(foo) === Function.prototype) // true
而实例和构造函数之间的正确关系,是下一节的重点:
const f1 = new foo()
console.log(Object.getPrototypeOf(f1) === foo.prototype) // true
4. new 到底做了什么?
理解原型,绕不开 new。把它拆开看,会清晰很多。
new Foo() 大致会做下面几步:
- 创建一个全新的空对象
- 把这个对象的
[[Prototype]]指向Foo.prototype - 用这个新对象作为
this执行构造函数 - 如果构造函数没有显式返回对象,就返回这个新对象
所以,实例为什么能访问构造函数原型上的方法?答案就在第 2 步。
function Foo() {}
const f1 = new Foo()
const f2 = new Foo()
console.log(Object.getPrototypeOf(f1) === Foo.prototype) // true
console.log(Object.getPrototypeOf(f2) === Foo.prototype) // true
这就是为什么不同实例可以“共享一套方法定义”,却又拥有各自不同的数据。
本章小结
prototype和[[Prototype]]名字相似,但职责完全不同- 普通对象没有
prototype,函数通常有 - 实例的
[[Prototype]]会在new时指向构造函数的prototype - 函数对象自己的原型通常是
Function.prototype,不是它自己的prototype - 只要把“定义共享能力”和“参与属性查找”分开理解,很多混乱都会消失
三、从 new 和内存视角理解实例、构造函数与原型
如果只停留在语法层,原型会越学越抽象。真正把它看懂,最有效的方式是换成“引用关系”和“内存指向”的视角。
1. Person、实例对象和原型对象之间是什么关系?
先看一个最简单的例子:
function Person() {}
console.log(Person.prototype)
很多人看到这里会困惑:Person 是函数,Person.prototype 是对象,那实例和它们之间是怎么连起来的?
关键结论只有一个:
同一个构造函数创建出来的实例,默认会共享同一个原型对象。
这也是后面方法复用的基础。
图:从控制台结果理解构造函数与原型对象的关系
这张图适合帮助我们建立第一个直觉:构造函数不是孤立存在的,它天然带着一个 prototype 对象。
2. 为什么 p1 和 p2 可以访问同一套原型内容?
function Person() {}
const p1 = new Person()
const p2 = new Person()
这里最值得记住的不是“创建了两个实例”,而是“这两个实例的原型指向同一个地方”。
console.log(Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2)) // true
console.log(Object.getPrototypeOf(p1) === Person.prototype) // true
console.log(Object.getPrototypeOf(p2) === Person.prototype) // true
图:p1 与 p2 实例对象共享同一个原型对象
这就解释了一个很重要的工程现象:
- 改
Person.prototype.xxx - 实际上影响的是所有还指向这个原型对象的实例
function Person() {}
const p1 = new Person()
const p2 = new Person()
console.log(Object.getPrototypeOf(p1) === Person.prototype) // true
console.log(Object.getPrototypeOf(p2) === Person.prototype) // true
图:通过相等比较验证实例原型是否一致
3. 一个很适合面试和排错的思考题:p1.name 到底能从哪里拿到?
假设 p1 自身没有 name,那 p1.name 还能不能拿到值?
答案是能,而且方式不止一种。本质上,这些方式最终都在改同一个共享原型对象。
function Person() {}
const p1 = new Person()
const p2 = new Person()
Object.getPrototypeOf(p1).name = '小吴'
console.log(p1.name) // 小吴
Person.prototype.name = 'XiaoWu'
console.log(p1.name) // XiaoWu
Object.getPrototypeOf(p2).name = 'why'
console.log(p1.name) // why
为什么第三种改 p2 的原型,也会影响 p1?
因为:
Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2) === Person.prototype
它们最终都指向同一个共享对象。
把这个关系进一步抽象成“内存地址”,就更容易理解了。你可以把上面的变化想成:
// 假设共享原型对象就像一个地址 0x100
0x100.name = '小吴'
console.log(0x100.name) // 小吴
0x100.name = 'XiaoWu'
console.log(0x100.name) // XiaoWu
0x100.name = 'why'
console.log(0x100.name) // why
图:从“内存指向”视角理解实例、构造函数与原型的关系
这个视角非常关键,因为后面理解“共享方法”“重写原型”“原型链继承”时,本质都是在理解引用关系,而不是背结论。
本章小结
- 同一个构造函数创建的实例,默认共享同一个原型对象
Object.getPrototypeOf(p1) === Person.prototype是原型学习中的第一条黄金验证公式- 改共享原型,相当于影响所有还连接到它的实例
- 原型问题一旦抽象成“引用地址”,很多现象都会变得很好解释
- 面试里问“为什么改
p2的原型会影响p1”,本质在考你是否理解“共享引用”
四、函数原型上的高频知识点:共享属性与 constructor
前面讲的是“为什么原型存在”,这一节讲“原型上通常放什么”。
1. 原型上放的是“共享能力”
在 JavaScript 中,函数的 prototype 对象,本质上就是给实例共享用的。
function Person() {}
Person.prototype.name = 'why'
Person.prototype.age = 18
const p1 = new Person()
const p2 = new Person()
console.log(p1.name, p2.age) // why 18
这意味着:
name和age不在p1、p2自身上- 它们来自共享原型
- 所有实例都能访问,但并不各自拷贝一份
图:往原型上添加共享属性后的结构示意
这里顺便给一个工程建议:
如果一个值会因实例不同而不同,就不要放原型上;如果一段行为对所有实例都一致,就优先考虑放原型上。
2. constructor 是什么?为什么平时看不见?
默认情况下,函数的原型对象上会有一个 constructor 属性,它指回构造函数本身。
function Foo() {}
console.log(Foo.prototype.constructor === Foo) // true
但很多同学在控制台直接打印 Foo.prototype 时,看见的是个空对象,于是误以为它什么都没有。其实不是没有,而是:
constructor默认是不可枚举的。
所以直接打印、遍历时看不明显,但你可以通过属性描述符把它“看见”。
function Foo() {}
console.log(Foo.prototype) // 看起来像 {}
console.log(Object.getOwnPropertyDescriptors(Foo.prototype))
图:在 Node 中查看 constructor 的真实属性描述符
3. constructor 存在的意义是什么?
constructor 的工程意义,不是“让你炫技”,而是帮我们保留一条从原型对象追溯回构造函数的路径。
function Foo() {}
console.log(Foo.prototype.constructor.name) // Foo
这相当于让原型系统形成了一个闭环:
- 实例通过
[[Prototype]]指向原型对象 - 原型对象通过
constructor指回构造函数
这条关系能帮助我们做理解、调试和某些类型判断。但也要注意一点:
constructor可以被改写,所以它不是绝对可靠的类型判断依据。
在工程里,如果你想做类型判断:
- 优先考虑
instanceof - 或者基于更稳定的品牌判断方式
- 不要把
constructor当成唯一真理
4. 一个有意思但不建议滥用的闭环验证
function Foo() {}
console.log(
Foo.prototype.constructor.prototype.constructor.prototype.constructor.name
) // Foo
这段代码能跑通,不是因为 JavaScript 神秘,而是因为这条引用关系本来就存在。
不过知道就好,别把它写进业务代码里。
本章小结
- 原型对象最适合承载共享属性和共享方法
constructor默认存在于函数原型对象上,只是不可枚举Foo.prototype.constructor === Foo是默认成立的constructor适合理解原型结构,但不适合作为唯一类型判断依据- 共享逻辑放原型,是 JavaScript 节省内存、复用能力的关键设计
五、重写原型对象时,为什么最容易踩坑
前面讲的是“给现有原型追加内容”,这一节讲的是另一种更激进的操作:直接重写整个原型对象。
1. 什么叫“重写原型对象”?
不是这样:
Person.prototype.name = '小吴'
Person.prototype.age = 20
而是这样:
function Person() {}
Person.prototype = {
name: '小吴',
age: 20,
learn() {
console.log(this.name + '在学习')
}
}
这种写法在属性比较多时很常见,结构也更集中。
先看原始的“构造函数与原型相互关联”视角:
图:默认原型对象与构造函数之间的关联
当你执行 Person.prototype = { ... } 时,本质上是让 Person.prototype 指向了一个全新的对象。
图:重写原型后,构造函数指向了新的原型对象
继续把内容填进去之后,新的结构才完整:
图:新的原型对象被填充内容后的状态
2. 这里最容易掉的坑:constructor 丢了
看下面的代码:
function Person() {}
Person.prototype = {
name: '小吴',
age: 18,
height: 1.88
}
const f1 = new Person()
console.log(f1.name + '今年' + f1.age) // 小吴今年18
功能看起来没问题,但有一个隐藏变化:
console.log(Person.prototype.constructor === Person) // false
console.log(Person.prototype.constructor === Object) // true
原因并不复杂:
- 默认创建函数时,引擎会为它生成一个带
constructor的原型对象 - 但你手动赋值的新对象只是一个普通对象字面量
- 它自己的
constructor并不是Person - 查找时会沿着这个新对象的原型往上找到
Object.prototype.constructor
图:重写原型后,实例仍能访问属性,但 constructor 关系已发生变化
3. 正确做法:手动把 constructor 补回去
最常见的补法如下:
function Foo() {}
Foo.prototype = {
name: '小吴',
age: 18,
height: 1.88
}
Object.defineProperty(Foo.prototype, 'constructor', {
enumerable: false,
writable: true,
configurable: true,
value: Foo
})
const f1 = new Foo()
console.log(f1.name + '今年' + f1.age)
为什么不用下面这种简单写法?
Foo.prototype = {
constructor: Foo,
name: '小吴'
}
因为这样写出来的 constructor 默认是可枚举的,而原生默认行为里,这个属性应该是不可枚举的。
如果你想尽量保持和原生行为一致,Object.defineProperty 更合适。
图:补回 constructor 后,构造函数与新原型对象重新闭合
4. 再补一个容易忽略的边界条件
很多人以为“重写原型后,旧原型会立即消失”,这其实不严谨。
更准确的说法是:
- 如果旧原型对象已经没有任何可达引用,后续才可能被垃圾回收
- 如果已有实例还指向旧原型,那旧原型仍然活着
例如:
function Person() {}
const oldP = new Person()
Person.prototype = {
sayHello() {
console.log('hello')
}
}
const newP = new Person()
console.log(Object.getPrototypeOf(oldP) === Object.getPrototypeOf(newP)) // false
这在排查“为什么新老实例行为不一致”时非常关键。
本章小结
- 给
prototype追加属性,和直接重写整个prototype,是两种不同操作 - 重写原型后,默认的
constructor关联会丢失 - 推荐用
Object.defineProperty把constructor补回去 - 重写原型不会自动“更新”旧实例的原型指向
- 原型对象是否回收,取决于是否还有引用,而不是“看起来不用了”
六、创建对象的推荐姿势:实例数据放 this,共享方法放 prototype
这是原型章节里最重要的工程落点。
1. 一个典型错误:把实例数据塞进共享原型
下面这段代码看似“想省事”,实则会制造共享数据污染:
function Person(name, age, sex, address) {
Person.prototype.name = name
Person.prototype.age = age
Person.prototype.sex = sex
Person.prototype.address = address
}
const p1 = new Person('小吴', 18, '男', '福建')
console.log(p1.name) // 小吴
const p2 = new Person('why', 35, '男', '广州')
console.log(p1.name) // why
为什么 p1.name 最后变成了 why?
因为你不是把数据放进 p1、p2 自身,而是放进了它们共享的 Person.prototype。
这等于让所有实例共用一份可变数据,自然后创建的实例会覆盖前一个实例的结果。
这类问题在工程里很致命,因为它会造成一种非常糟糕的现象:对象看起来是独立的,实际状态却是串联的。
2. 正确做法:实例数据归实例,共享方法归原型
function Person(name, age, sex, address) {
this.name = name
this.age = age
this.sex = sex
this.address = address
}
Person.prototype.eating = function () {
console.log(this.name + '今天吃烤地瓜了')
}
Person.prototype.running = function () {
console.log(this.name + '今天跑了五公里')
}
const p1 = new Person('小吴', 18, '男', '福建')
const p2 = new Person('why', 35, '男', '广州')
console.log(p1.name) // 小吴
console.log(p2.name) // why
console.log(p1.eating === p2.eating) // true
这套写法有三个直接收益:
-
实例数据隔离
每个对象维护自己的状态,不会相互覆盖 -
方法共享
所有实例共用同一个方法引用,减少重复创建 -
结构清晰
一眼能分清“对象自己的数据”和“对象共享的行为”
3. 为什么不要把原型方法写进构造函数内部?
有些代码会这么写:
function Person(name) {
this.name = name
this.eating = function () {
console.log(this.name + '在吃东西')
}
}
它不是不能运行,而是有明显代价:每次 new Person() 都会重新创建一个新的函数对象。
如果实例特别多,这就是实打实的重复内存占用和不必要的函数分配。
更合理的方式还是:
function Person(name) {
this.name = name
}
Person.prototype.eating = function () {
console.log(this.name + '在吃东西')
}
4. 这套模式和 class 有什么关系?
如果你已经在写 class,那更应该理解这部分。因为:
class Person {
constructor(name) {
this.name = name
}
eating() {
console.log(this.name + '在吃东西')
}
}
本质上仍然是:
constructor里放实例数据- 方法定义在原型上
class 改变的是写法,不是底层原理。
本章小结
- 实例间不同的数据,放
this - 所有实例共享的行为,放
prototype - 不要把可变实例数据放到共享原型上
- 不要在构造函数里重复创建所有实例都相同的方法
- 理解这条原则后,再看
class会非常顺手
实战建议
1. 代码评审时重点看这几件事
- 是否把实例级数据错误地挂到了原型上
- 是否把共享方法错误地定义在构造函数内部
- 是否在重写
prototype后忘了补constructor - 是否在正式代码里依赖
__proto__而不是标准 API - 是否出现“旧实例”和“新实例”指向不同原型的潜在风险
2. 调试原型问题时,建议这样验证
console.log(Object.getPrototypeOf(obj))
console.log(Object.getPrototypeOf(obj) === Foo.prototype)
console.log(obj.hasOwnProperty('xxx'))
console.log('xxx' in obj)
console.log(Object.getOwnPropertyDescriptors(Foo.prototype))
这一组排查动作,足够覆盖大多数原型相关问题:
- 属性是自己的,还是继承来的
- 当前实例到底连到哪个原型对象
- 原型对象上的属性描述符是否符合预期
constructor是否被改坏了
3. 团队内可以落地的约束
- 约定:实例状态一律放
this/ 类字段 - 约定:共享方法统一放原型 / 类方法
- 约定:禁止在业务代码里直接依赖
__proto__ - 约定:重写
prototype必须同步恢复constructor - 约定:在 Code Review Checklist 中加入“原型污染”和“共享引用”检查项
4. 性能与可维护性的权衡
- 小量对象场景下,差异可能不明显
- 大量实例场景下,方法是否共享会带来真实内存差异
- 动态改原型虽然灵活,但会明显增加维护成本
- 原型越“魔法化”,后续新人接手成本越高
总结:关键结论与团队落地建议
JavaScript 的原型并不神秘,它本质上解决的是两个问题:
- 对象找不到属性时,去哪里继续找
- 多个实例如何共享同一套行为定义
把这两件事想清楚,原型就不再是零散知识点,而是一套完整的对象模型。
最后用几条结论收尾:
[[Prototype]]是对象的查找链路,prototype是构造函数为实例准备的共享模板new的关键一步,是把实例的[[Prototype]]指向构造函数的prototypeconstructor默认存在于原型对象上,只是不可枚举- 重写
prototype会改变后续实例的继承来源,同时可能破坏constructor - 最稳妥的工程实践是:实例数据放
this,共享方法放prototype
如果要在团队内部继续往下沉淀,建议下一步把下面几个主题串起来学习:
- 原型链完整查找过程
instanceof的底层判断逻辑Object.create与显式指定原型- 组合继承、寄生组合继承
class extends背后的原型链本质
当你把这些知识连起来之后,JavaScript 的对象系统就不再是“记忆题”,而会变成你分析框架、阅读源码、设计抽象时的一套底层能力。