Appearance
JavaScript 中的对象、原型链、class 与继承
JavaScript 的对象模型,和很多传统面向对象语言不太一样。
很多人第一次接触时,会把 class、构造函数、原型链混成一团。更贴近这条主线的问题是:
JavaScript 里的对象是怎么共享属性和方法的,它又是怎么表达“继承”关系的。
1. 对象在 JavaScript 里意味着什么
对象可以看成 一组键值对的集合,用来描述更复杂的数据和行为。
js
const user = {
name: 'tom',
say() {
console.log('hello')
}
}但如果每创建一个对象,就都把一份方法重新写进去,会带来重复。
JavaScript 为了解决“方法复用”这个问题,引入了原型机制。
2. 原型到底是什么
原型可以看成 对象在查找属性或方法时,可以继续向上寻找的共享对象。
也就是说:
- 对象先找自己身上的属性
- 自己没有,再沿着原型往上找
- 一直找到
null为止
这条查找路径,就是原型链。
3. 原型链到底在说什么
js
const animal = {
eat() {
console.log('eat')
}
}
const dog = Object.create(animal)
dog.bark = function () {
console.log('bark')
}
dog.eat()这里 dog 自己没有 eat,但仍然能调用,是因为:它会沿着原型链找到 animal 上的 eat。
3.1 属性查找到底是按什么顺序发生的
如果只记“会沿着原型链向上找”,其实还是太粗了。
更接近真实过程的理解方式是:
mermaid
flowchart TD
A[读取 obj.foo] --> B[先检查对象自己身上有没有 foo]
B -->|有| C[直接返回]
B -->|没有| D[沿 __proto__ 到上一层原型]
D --> E[继续检查当前原型对象]
E -->|找到| F[返回结果]
E -->|还没找到| G[继续向上直到 null]
G --> H[整条链都没有则返回 undefined]这条流程非常重要,因为它能解释很多现象:
- 为什么实例可以访问原型上的方法
- 为什么同名属性会发生遮蔽
- 为什么改实例属性和改原型属性,影响范围完全不同
3.2 实例属性和原型属性同名时,为什么实例会“遮住”原型
js
function User() {}
User.prototype.name = 'prototype-name'
const user = new User()
console.log(user.name) // prototype-name
user.name = 'instance-name'
console.log(user.name) // instance-name这里不是原型属性被删掉了,而是:
- 读取
user.name时,先检查实例自身 - 实例自己一旦有同名属性,就不会继续沿原型链往上找
所以:实例属性优先级高于原型属性,但它并不会改写原型本身,只是把查找结果截住了。
4. 构造函数和 prototype 是什么关系
在 class 出现之前,JavaScript 更常用构造函数来组织对象。
js
function User(name) {
this.name = name
}
User.prototype.say = function () {
console.log(this.name)
}
const user = new User('tom')
user.say()这里有两个容易混的点:
this.name = name是把实例自己的数据挂到对象本身User.prototype.say是把多个实例共享的方法挂到原型上
所以 prototype 可以看成 构造函数创建出来的实例,默认会关联到的共享原型对象。
5. new 到底做了什么
new User('tom') 并不是一句“魔法咒语”,它背后大致会做这些事:
- 创建一个新对象
- 把这个新对象的原型指向
User.prototype - 用这个新对象作为
this执行构造函数 - 如果构造函数没有显式返回对象,就返回这个新对象
5.1 用一张流程图理解 new
mermaid
flowchart TD
A["调用 new User"] --> B["创建一个新对象"]
B --> C["把新对象原型关联到 User.prototype"]
C --> D["以新对象作为 this 执行 User"]
D --> E{构造函数是否显式返回对象}
E -->|是| F["返回这个显式对象"]
E -->|否| G["返回刚创建的新对象"]这张图最关键的作用,是把两个非常容易混的动作拆开:
- “创建对象” 和 “执行构造函数” 不是同一件事
- “实例为什么能访问原型方法” 也不是因为构造函数里手动拷贝了方法,而是因为第二步已经把原型关系接好了
5.2 为什么不推荐把方法都写进构造函数里
js
function User(name) {
this.name = name
this.say = function () {
console.log(this.name)
}
}这类写法能运行,但每次 new User() 都会重新创建一份新的 say 函数。
更常见、也更稳的做法通常是:
js
function User(name) {
this.name = name
}
User.prototype.say = function () {
console.log(this.name)
}因为这样多个实例可以共享同一份方法定义。
所以从工程角度看:
- 实例更适合放“每个对象自己的数据”
- 原型更适合放“多个对象共享的行为”
6. __proto__、prototype、constructor 怎么区分
这是最容易混的三组词。
6.1 prototype
这是构造函数上的属性。
它表示:将来实例默认会关联到哪个原型对象。
6.2 __proto__
这是对象实例上的原型引用。
它表示:当前对象实际沿着哪条原型链向上查找。
6.3 constructor
通常是原型对象上的一个属性,用来指回构造函数。
它主要是一个“来源说明”,不是继承机制的核心本体。
6.4 用一张图看懂它们的引用关系
可以看一个最常见的例子:
js
function User(name) {
this.name = name
}
const user = new User('tom')这时候它们之间的关系,大致可以画成这样:
mermaid
flowchart LR
A[user 实例对象]
B["user.__proto__"]
C["User.prototype"]
D["constructor"]
E["User 构造函数"]
A -- "__proto__ 指向" --> C
A -. "也就是" .-> B
C -- "constructor 指回" --> E
C -. "原型对象上有" .-> D
E -- "prototype 指向" --> C如果把这张图压缩成几句话,可以记住:
User.prototype在构造函数User身上user.__proto__在实例user身上user.__proto__ === User.prototype在默认情况下通常成立User.prototype.constructor === User在默认情况下也通常成立
6.5 再用一句句关系把图读出来
很多人看图时容易一下看晕,真正更稳的方式是把它拆成四句:
- 构造函数有一个
prototype属性 - 通过
new创建出来的实例,会把自己的__proto__关联到这个prototype - 这个
prototype对象上,默认又有一个constructor属性指回构造函数 - 所以实例可以沿着
__proto__找到共享方法,也能间接找到它的构造函数来源
例如:
js
function User(name) {
this.name = name
}
User.prototype.say = function () {
console.log(this.name)
}
const user = new User('tom')
console.log(user.__proto__ === User.prototype) // true
console.log(User.prototype.constructor === User) // true
console.log(user.constructor === User) // true这里最后一行之所以成立,不是因为 constructor 直接长在 user 自己身上,而是因为:
user 会先找自己,再沿着原型链到 User.prototype 上找到 constructor。
7. class 到底带来了什么
class 可以看成 一种更接近传统类写法的语法外壳,用来把原型式对象模型写得更清晰。
js
class User {
constructor(name) {
this.name = name
}
say() {
console.log(this.name)
}
}这里要特别注意:
class 让写法更像类,但底层仍然主要建立在原型机制上。
8. extends 和继承怎么理解
js
class Animal {
eat() {
console.log('eat')
}
}
class Dog extends Animal {
bark() {
console.log('bark')
}
}
const dog = new Dog()
dog.eat()
dog.bark()这里可以直接把它理解成:
Dog继承了Animal- 所以
Dog的实例可以沿着原型链访问到Animal原型上的方法
super 则用于在子类中调用父类构造器或父类方法。
9. 为什么说 JavaScript 更偏“对象复用模型”而不是传统类继承
因为它的核心不是“类定义了对象,实例只是类的复制品”,而更接近:对象之间通过原型关系共享属性和方法。
class 只是把这种机制包装得更容易读写。
10. 一张关系图
mermaid
flowchart TD
A[构造函数或 class] --> B[prototype 共享原型]
B --> C[实例对象]
C --> D[先查自己属性]
D --> E[自己没有再沿原型向上找]11. 工程上最容易踩的坑
11.1 把实例方法都写进构造函数
这样每创建一个实例,就会重新创建一份方法,增加不必要的重复。
11.2 以为 class 改变了 JavaScript 的底层对象模型
它主要改变的是写法和可读性,不是把语言底层彻底换掉。
11.3 混淆 prototype 和 __proto__
一个是构造函数上的“共享原型入口”,一个是实例对象上的“实际原型引用”。
11.4 误把继承当成唯一复用手段
工程里很多场景更适合组合,而不是层层继承。
12. 小结
这条主线可以压缩成几句话:
- JavaScript 的对象复用主要依赖原型机制
- 原型链决定对象属性和方法的向上查找路径
- 构造函数和
class都是在组织对象创建与复用 class更像语法层面的整理,不是底层机制的彻底替换
下一步如果继续往下看,最自然的是进入 执行上下文、调用栈与事件循环,因为“对象和函数怎么组织”之后,就该看“代码到底怎么执行”。