JS 工程师面试三道送命题:this 指向、事件循环、原型与原型链。前两个还能背两句,原型链这个话题随便挑一个老 JS 教程都能讲两个小时,绕来绕去 __proto__ / prototype / constructor 看得人头晕。
这一篇我尝试用最少的概念 + 一张图讲透。看完你应该能闭眼回答:
__proto__和prototype区别是什么?- ES6
class是不是真的"类"? - 为什么
arr.push没定义在arr上,arr.push(1)却能用? Object.create(null)创造的对象到底有什么不一样?
开始。
为什么 JS 有"原型"这种东西
Brendan Eich 1995 年设计 JS 的时候,Netscape 给了他 10 天做出一门"看上去像 Java 的脚本语言"。他骨子里喜欢 Lisp / Scheme,但任务要求"长得像 Java",于是搞了一个披着 Java 外衣的函数式语言。
继承机制上,他没选 Java 的"基于类(class-based)"路线,而是借鉴了 Self 这门小众语言的基于原型(prototype-based) 设计:
没有类。一切都是对象。一个对象通过"链接"另一个对象作为自己的"原型",从而共享后者的属性和方法。
这就是原型继承的核心思想。对象 → 对象,不需要中间隔一层"类"。
所以 JS 里你看到的所有"类"的影子(function + new、Object.create、class),底层全都是同一件事 —— 某个对象的 __proto__ 指针,指向了另一个对象。
把这一条牢记住,后面就好理解了。
三个核心概念,先分清
新人最容易迷糊的就是 __proto__ 和 prototype 这俩长得太像了。先记住它们的归属:
| 名字 | 在谁身上 | 是什么 |
|---|---|---|
prototype |
只有函数有(箭头函数除外) | 一个对象。当这个函数被 new 调用时,产生的实例的 __proto__ 会指向它 |
__proto__ |
几乎所有对象都有 | 指向"我"的原型对象,顺着它能找到祖先 |
constructor |
在原型对象上 | 指回创建这个原型的构造函数 |
一句话区分:
prototype是函数才有的属性,是函数为它的实例准备的"模板"__proto__是对象的内部链接,指向它的原型
Person.prototype.__proto__ 你看着头疼,但其实就是:Person 函数的原型对象(Person.prototype,本身是个对象)的原型(.__proto__,也就是 Object.prototype)。
一张图把关系讲清楚
function Person(name) {
this.name = name
}
Person.prototype.sayHi = function() {
console.log('Hi, I am ' + this.name)
}
const p = new Person('Alice')执行这段代码后,内存里的对象关系是:
┌─────────────────────┐
│ Person (function) │
│ ───────────────── │
│ prototype ───────┼────┐
└─────────────────────┘ │
▼
┌──────────────────────┐
│ Person.prototype │
│ ──────────────── │
│ sayHi: function │
│ constructor ────────┼──── 指回 Person
│ __proto__ ────────┼────┐
└──────────────────────┘ │
▼
┌──────────────────────┐
│ Object.prototype │
│ ──────────────── │
│ toString / hasOwn / │
│ valueOf / ... │
│ __proto__: null │
└──────────────────────┘
┌─────────────────────┐
│ p (实例对象) │
│ ───────────────── │
│ name: 'Alice' │
│ __proto__ ───────┼────┐
└─────────────────────┘ │
▼
┌──────────────────────┐
│ Person.prototype │ ← p 的原型,就是 Person 的 prototype
└──────────────────────┘重点关系:
- 函数
Person自带一个prototype属性(JS 帮你自动建好的对象) Person.prototype.constructor === Person(自动建好的,互相指)- 实例
p的__proto__===Person.prototype(new操作干的事) Person.prototype.__proto__===Object.prototype(因为Person.prototype本身是普通对象,它的祖宗是Object.prototype)Object.prototype.__proto__===null(原型链的尽头)
把这五条关系记死,原型这块就拿下了。
new 究竟干了什么
new Person('Alice') 这一句,JS 引擎实际做了 4 件事:
function newOperator(Ctor, ...args) {
const obj = Object.create(Ctor.prototype)
const ret = Ctor.apply(obj, args)
return (ret !== null && (typeof ret === 'object' || typeof ret === 'function'))
? ret
: obj
}翻译成人话:
- 新建一个空对象
- 把这个对象的
__proto__指向构造函数的prototype(原型链就在这一步连上) - 把构造函数当成方法调用,
this绑定到新对象(这样this.name = name才能挂上属性) - 如果构造函数返回了一个对象,就返回那个对象;否则返回新对象
第 4 步是大多数人忽略的细节 —— 构造函数里如果 return { foo: 1 },那 new 出来就是 { foo: 1 },跟原型一点关系都没有。
原型链是什么
知道了实例 → 原型 → 上层原型 → ... 这条链,原型链(prototype chain) 就只是它的别名。
当你访问 p.sayHi,JS 的查找顺序:
- 先看
p自身有没有sayHi—— 没有(p只有name) - 沿
p.__proto__查,也就是Person.prototype—— 找到了sayHi,返回 - 如果第 2 步也没有,继续沿
Person.prototype.__proto__查,也就是Object.prototype - 还没有,继续沿
Object.prototype.__proto__查,但它是null,链到头了 —— 返回undefined
整个查找过程是 沿着 __proto__ 链向上,直到 null。这就是 原型链查找。
const p = new Person('Alice')
console.log(p.name) // 'Alice' ← 在 p 自身找到
console.log(p.sayHi) // function ← 在 Person.prototype 找到
console.log(p.toString) // function ← 在 Object.prototype 找到
console.log(p.nonExistent) // undefined ← 一路到 null 都没找到这也解释了为什么
arr.push/arr.map没定义在你创建的arr上,却能调用 —— 它们在Array.prototype上,数组实例的__proto__指向那里,沿原型链查就找到了。
Object.create —— 直接玩对象
如果说 new 是用"函数 + 模板"造对象,那 Object.create(proto) 就是 直接指定一个对象当原型,跳过函数那一层。
const animal = {
eat() { console.log('eating') }
}
const dog = Object.create(animal)
dog.bark = function() { console.log('woof') }
dog.bark() // 'woof' ← 自身有
dog.eat() // 'eating' ← 沿原型链在 animal 上找到
Object.getPrototypeOf(dog) === animal // trueObject.create(animal) 做的事:新建一个空对象,把它的 __proto__ 指向 animal。完事。没有什么构造函数,没有 prototype,纯粹的"对象 → 对象"关系。
这反而是 最贴近 JS 原型继承本质 的写法。new 反而是 Eich 当年为了"看起来像 Java"加的糖衣。
ES6 class 是真的类吗?
答案:不是。它是 function + prototype + new 的语法糖。
class Person {
constructor(name) {
this.name = name
}
sayHi() {
console.log('Hi, I am ' + this.name)
}
}
const p = new Person('Alice')跑一下:
typeof Person // 'function' ← 还是函数!
Object.getPrototypeOf(p) === Person.prototype // true
typeof Person.prototype.sayHi // 'function'完全等同于:
function Person(name) {
this.name = name
}
Person.prototype.sayHi = function() {
console.log('Hi, I am ' + this.name)
}所以 ES6 没有改变 JS 的对象模型,只是加了更顺眼的写法。
不过 class 比 function 写法严格得多,这些点容易踩:
| 区别 | function 写法 |
class 写法 |
|---|---|---|
不 new 调用 |
可以(this 变全局或 undefined) |
TypeError |
| 是否提升 | function 整体提升 |
类似 let,暂时性死区 |
| 方法是否可枚举 | 默认可枚举 | 不可枚举(for...in 不会遍历到) |
| 严格模式 | 看上下文 | 永远在严格模式下 |
| 是否能当作对象重新赋值 | 可以 | 类内部方法体里不能 |
继承用 extends:
class Animal {
eat() { console.log('eating') }
}
class Dog extends Animal {
bark() { console.log('woof') }
}
const d = new Dog()
d.bark() // 'woof'
d.eat() // 'eating' ← 沿原型链找到底下其实就是把 Dog.prototype.__proto__ 指向 Animal.prototype,把 Dog.__proto__ 指向 Animal(后者是为了 static 方法能被继承)。
__proto__ 已经"过时",但还活着
__proto__ 这个属性最早是浏览器厂商私下加的,严格来说不是 ES5 标准(ES6 才正式收编)。现代代码应该用:
- 读:
Object.getPrototypeOf(obj) - 写(很少用,有性能问题):
Object.setPrototypeOf(obj, newProto) - 建对象时指定原型:
Object.create(proto)
const arr = [1, 2, 3]
arr.__proto__
Object.getPrototypeOf(arr)
Array.prototype 三种写法拿到的是同一个东西,但前两个表达力差太多。生产代码里看到 __proto__ 直接换成 Object.getPrototypeOf 就行,语义清楚。
还有一个坑:
__proto__属性其实是Object.prototype上定义的一个 getter/setter,所以Object.create(null)创出来的对象 没有__proto__(下文有解释)。这也是为什么标准方法Object.getPrototypeOf是首选 —— 它在任何对象上都能工作。
7 个实战中常踩的坑
1. Object.create(null) 创造"无原型对象"
const dict = Object.create(null)
dict.foo = 1
Object.getPrototypeOf(dict) // null
dict.toString // undefined ← 连 Object.prototype 都没继承
dict.__proto__ // undefined ← 也没有这个属性用途:做 纯字典(map) 用。
为什么?普通 {} 创出来的对象有 Object.prototype 作为原型,所以 obj['toString'] 永远是 function。如果你拿用户输入当 key:
const map = {}
const userInput = '__proto__'
console.log(map[userInput]) 虽然现代 V8 / 浏览器已经做了 __proto__ 写入的防护,但 toString / hasOwnProperty 这些"看上去就有"的属性,会污染你的字典逻辑。
推荐:做 hash map / key-value 缓存,用 Object.create(null) 或者直接 new Map()。
2. 覆盖整个 prototype 会丢 constructor
function Person() {}
Person.prototype = {
sayHi() { console.log('hi') }
}
const p = new Person()
p.constructor === Person
p.constructor === Object 为什么?因为你整体替换了 Person.prototype 对象,新对象是 { sayHi: ... },它的原型是 Object.prototype,所以 constructor 沿原型链找到的是 Object。
正确做法两种:
Person.prototype = {
constructor: Person,
sayHi() { console.log('hi') }
}
Person.prototype.sayHi = function() { console.log('hi') }class 写法自动处理好这件事,所以这个坑只在老 function 写法里有。
3. 箭头函数没有 prototype,不能 new
const Foo = () => {}
Foo.prototype
new Foo() 箭头函数本质上就不是构造器 —— 它没有自己的 this,没有 prototype,也不能 new。这是设计,不是 bug。
记忆口诀:箭头函数 = 只用来当回调的"轻量函数"。要做构造器,老老实实写 function 或者 class。
4. for...in 会遍历原型链上的属性
const proto = { inherited: 1 }
const obj = Object.create(proto)
obj.own = 2
for (const k in obj) {
console.log(k)
}这个老坑了。for...in 沿着原型链遍历所有 可枚举 属性,不只是自身的。
对策:
for (const k of Object.keys(obj)) { ... }
for (const k in obj) {
if (Object.hasOwn(obj, k)) { ... }
}注意 class 定义的方法是不可枚举的,所以 for...in 实例时不会撞到方法名。但你自己 prototype.foo = ... 的话,foo 默认是可枚举的,这就会撞。
5. instanceof 实际是在查原型链
class Animal {}
class Dog extends Animal {}
const d = new Dog()
d instanceof Dog // true
d instanceof Animal // true ← 沿原型链找到
d instanceof Object // true ← 继续往上A instanceof B 的内部逻辑大致是:沿着 A.__proto__ 链向上查,看有没有等于 B.prototype 的节点,找到就 true。
所以你可以手动篡改原型链来骗 instanceof(虽然很少有理由这么干):
function Cat() {}
const c = new Cat()
c instanceof Dog
Object.setPrototypeOf(c, Dog.prototype)
c instanceof Dog 6. Object.hasOwn 比 obj.hasOwnProperty 更安全
ES2022 加了 Object.hasOwn(obj, key),强烈推荐用它替代 obj.hasOwnProperty(key):
const obj = Object.create(null)
obj.foo = 1
obj.hasOwnProperty('foo')
Object.hasOwn(obj, 'foo') Object.create(null) 创的对象没有 hasOwnProperty 方法(因为没原型链)。Object.hasOwn 是个静态方法,不依赖原型链,任何对象都能用。
7. Object.setPrototypeOf 会让 V8 放弃性能优化
const obj = { a: 1 }
Object.setPrototypeOf(obj, somePrototype)V8 等现代引擎会给每个对象建一个隐藏类(Hidden Class) 做属性访问加速。修改一个已存在对象的原型 会让 V8 抛弃这个对象的隐藏类、退回到字典模式,后续属性访问慢 5-10 倍。
对策:
- 建对象之前就用
Object.create(proto)指定好原型 - 不要在运行时
setPrototypeOf - 如果非要继承,用
class extends—— 在对象创建时一次性安排好
这条主要影响热路径代码。偶尔一次
setPrototypeOf没事,但别在循环里干。
一些"高级"原型操作
虽然日常用 class extends 就够,了解下面这些有助于读源码 / 库:
function isPrototypeOf(proto, obj) {
return Object.prototype.isPrototypeOf.call(proto, obj)
}
// 等价于
Animal.prototype.isPrototypeOf(d) // true,d 沿原型链能找到 Animal.prototype
const o = {}
Object.getOwnPropertyNames(o)
Object.getOwnPropertyNames(Object.getPrototypeOf(o))
Object.defineProperty(obj, 'foo', {
value: 1,
writable: false,
enumerable: false,
configurable: false,
})
const obj = {
_x: 0,
get x() { return this._x },
set x(v) { this._x = v * 2 },
}
obj.x = 5
console.log(obj.x) // 10class 内部的 get / set 方法、static 方法的实现,本质上都是用 Object.defineProperty 把方法装到原型上,并设置成不可枚举。所以 class 真的只是糖。
现代写法建议
总结几条 2026 年的最佳实践:
- 写"类"用
class,不要再写function Constructor+prototype.xxx = ...—— 简洁、严格、还少踩坑 - 要"对象 → 对象"继承用
Object.create(proto),语义清晰,性能也好 - 要 hash map 用
new Map()或Object.create(null),别用{}(原型污染) - 读原型用
Object.getPrototypeOf(obj),别用obj.__proto__ - 建对象之后不要改原型(
setPrototypeOf性能坑) - 判断自身属性用
Object.hasOwn(obj, key),不用obj.hasOwnProperty(key) - 能用
for...of + Object.keys()就别for...in
一句话总结
JS 没有类。所谓"类",只是一个对象通过
__proto__指针,认了另一个对象当原型。所谓"继承",只是这条__proto__链可以一直往上找。
class / extends / new / instanceof —— 这些看起来很 OOP 的关键字,全是包装在那一根原型链指针上的糖衣。理解了底下的原型链查找,JS 这块就再也不会绕了。
下次见。
-EOF-