Skip to content

JavaScript 中的对象、原型链、class 与继承

JavaScript 的对象模型,和很多传统面向对象语言不太一样。

很多人第一次接触时,会把 class、构造函数、原型链混成一团。更贴近这条主线的问题是:

JavaScript 里的对象是怎么共享属性和方法的,它又是怎么表达“继承”关系的。

1. 对象在 JavaScript 里意味着什么

对象可以看成 一组键值对的集合,用来描述更复杂的数据和行为

js
const user = {
  name: 'tom',
  say() {
    console.log('hello')
  }
}

但如果每创建一个对象,就都把一份方法重新写进去,会带来重复。

JavaScript 为了解决“方法复用”这个问题,引入了原型机制。

2. 原型到底是什么

原型可以看成 对象在查找属性或方法时,可以继续向上寻找的共享对象

也就是说:

  1. 对象先找自己身上的属性
  2. 自己没有,再沿着原型往上找
  3. 一直找到 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]

这条流程非常重要,因为它能解释很多现象:

  1. 为什么实例可以访问原型上的方法
  2. 为什么同名属性会发生遮蔽
  3. 为什么改实例属性和改原型属性,影响范围完全不同

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

这里不是原型属性被删掉了,而是:

  1. 读取 user.name 时,先检查实例自身
  2. 实例自己一旦有同名属性,就不会继续沿原型链往上找

所以:实例属性优先级高于原型属性,但它并不会改写原型本身,只是把查找结果截住了。

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()

这里有两个容易混的点:

  1. this.name = name 是把实例自己的数据挂到对象本身
  2. User.prototype.say 是把多个实例共享的方法挂到原型上

所以 prototype 可以看成 构造函数创建出来的实例,默认会关联到的共享原型对象

5. new 到底做了什么

new User('tom') 并不是一句“魔法咒语”,它背后大致会做这些事:

  1. 创建一个新对象
  2. 把这个新对象的原型指向 User.prototype
  3. 用这个新对象作为 this 执行构造函数
  4. 如果构造函数没有显式返回对象,就返回这个新对象

5.1 用一张流程图理解 new

mermaid
flowchart TD
    A["调用 new User"] --> B["创建一个新对象"]
    B --> C["把新对象原型关联到 User.prototype"]
    C --> D["以新对象作为 this 执行 User"]
    D --> E{构造函数是否显式返回对象}
    E -->|是| F["返回这个显式对象"]
    E -->|否| G["返回刚创建的新对象"]

这张图最关键的作用,是把两个非常容易混的动作拆开:

  1. “创建对象” 和 “执行构造函数” 不是同一件事
  2. “实例为什么能访问原型方法” 也不是因为构造函数里手动拷贝了方法,而是因为第二步已经把原型关系接好了

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)
}

因为这样多个实例可以共享同一份方法定义。

所以从工程角度看:

  1. 实例更适合放“每个对象自己的数据”
  2. 原型更适合放“多个对象共享的行为”

6. __proto__prototypeconstructor 怎么区分

这是最容易混的三组词。

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

如果把这张图压缩成几句话,可以记住:

  1. User.prototype 在构造函数 User 身上
  2. user.__proto__ 在实例 user 身上
  3. user.__proto__ === User.prototype 在默认情况下通常成立
  4. User.prototype.constructor === User 在默认情况下也通常成立

6.5 再用一句句关系把图读出来

很多人看图时容易一下看晕,真正更稳的方式是把它拆成四句:

  1. 构造函数有一个 prototype 属性
  2. 通过 new 创建出来的实例,会把自己的 __proto__ 关联到这个 prototype
  3. 这个 prototype 对象上,默认又有一个 constructor 属性指回构造函数
  4. 所以实例可以沿着 __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()

这里可以直接把它理解成:

  1. Dog 继承了 Animal
  2. 所以 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. 小结

这条主线可以压缩成几句话:

  1. JavaScript 的对象复用主要依赖原型机制
  2. 原型链决定对象属性和方法的向上查找路径
  3. 构造函数和 class 都是在组织对象创建与复用
  4. class 更像语法层面的整理,不是底层机制的彻底替换

下一步如果继续往下看,最自然的是进入 执行上下文、调用栈与事件循环,因为“对象和函数怎么组织”之后,就该看“代码到底怎么执行”。

基于 VitePress 构建的个人技术笔记。