彻底搞懂 JavaScript 原型链 (一篇就够了)

彻底搞懂 JavaScript 原型链 (一篇就够了)

你是否曾经想过,为什么你创建一个简单的 JavaScript 对象 const obj = {},明明它“什么都没有”,但你却可以调用 obj.toString() 或者 obj.hasOwnProperty() 这样的方法?

这些方法是从哪里来的?

答案就是 原型链 (Prototype Chain)。

核心思想:它不是复制,而是“委托”

在很多面向对象语言(比如 Java 或 C#)中,“继承”通常意味着“复制”。子类会把父类的属性和方法复制一份作为自己的。
但在 JavaScript 中,继承的机制完全不同。它不是复制,而是委托 (Delegation)。
一个简单的比喻:向上级求助

  1. 你(实例 instance)接到了一个任务(比如 toString())。
  2. 你先看自己的任务列表(自身属性)里有没有这个任务。
    • 有:你立刻自己搞定。
    • 没有:你不会说“我不会”,而是把任务“委托”给你的直属上司(原型 prototype)。
  3. 你的上司(原型)也重复这个过程:
    • 有:他来搞定。
    • 没有:他再“委托”给他的上司(原型的原型)。
  4. 这个“委托”链条会一直持续下去,直到找到任务,或者到达公司的最高层 CEO(也就是 Object.prototype)。
  5. 如果 CEO 也不会(或者他没有上司了,即 null),那只能告诉你:这个任务做不了(返回 undefined 或报错)。

    这个“你 -> 上司 -> CEO -> (null)”的查找链条,就是原型链。

两大核心:prototype 和 proto

要理解原型链,你必须先(并且要永远)区分这两个属性。它们是所有混乱的根源。

  1. prototype (显式原型)
    • 谁有它? 函数 (Function)。
    • 它是什么? 它是一个对象。
    • 有什么用? 当这个函数被用作构造函数(通过 new 关键字)来创建新对象时,这个 prototype 对象就会被自动分配为新创建实例的“原型”。
    • 目的? 它的存在,就是为了让所有由它创建的实例 共享 属性和方法。这极大地节省了内存。
1
2
3
4
5
6
7
8
9
10
11
12
// 1. 定义一个构造函数
function Foo(name) {
this.name = name;
}

// 2. Foo 是一个函数,所以它天生就有一个 .prototype 属性
console.log(Foo.prototype); // { constructor: f Foo(), ... }

// 3. 我们可以给这个“原型对象”添加共享方法
Foo.prototype.sayHello = function() {
console.log('Hi, I am ' + this.name);
};

在这里,Foo.prototype 就像一个“共享技能包”。
2. proto (隐式原型)

  • 谁有它? 几乎所有的 JavaScript 对象 (实例)。
  • 它是什么? 它是一个指针(或引用)。
  • 有什么用? 它指向创建这个对象的构造函数的 prototype。
  • 目的? 这就是原型链“链接”的关键。当你访问一个对象的属性时,JavaScript 引擎就是通过这个 proto 指针去查找它的“上司”的。

    注意:proto 是一个非标准的历史遗留属性。在现代 JavaScript 中,官方推荐使用 Object.getPrototypeOf(obj) 来访问,使用 Object.setPrototypeOf(obj, proto) 来设置。但为了教学和理解,proto 更直观。

魔法时刻:new 到底做了什么?

prototype 和 proto 是如何关联起来的?答案就在 new 关键字。

1
const foo = new Foo('张三');

avaScript 引擎在背后(大致)做了这四件事:

  1. 创建新对象:创建一个全新的空对象 {}。
  2. 链接原型:将这个新对象的 proto 属性指向构造函数(Foo)的 prototype 对象。新对象.proto = Foo.prototype; (这是最关键的一步!)
  3. 绑定 this:将构造函数(Foo)的 this 指向这个新对象,并执行函数体(即 this.name = ‘张三’)。
  4. 返回新对象:返回这个被“加工”过的新对象(foo)。
    现在,foo 这个实例就通过它的 proto 链接到了 Foo.prototype 这个“共享技能包”上。

完整链条:一个详细的查找过程

让我们把所有东西串起来,看看原型链是如何工作的。

1
2
3
4
5
6
7
8
9
10
11
12
// 构造函数
function Foo(name) {
this.name = name;
}

// 原型方法
Foo.prototype.sayHello = function() {
console.log('Hi, I am ' + this.name);
};

// 实例
const foo = new Foo('张三');
  • 场景一:foo.name
    1. JS 查找 foo 对象。
    2. foo 啊,你自己有 name 属性吗?
    3. foo:有!在构造函数里刚赋值的,是 ‘张三’。
    4. 查找结束。返回 ‘张三’。
  • 场景二:foo.sayHello()
    1. JS 查找 foo 对象。
    2. foo 啊,你自己有 sayHello 方法吗?
    3. foo:没有。
    4. JS:好的,我去你的 proto 上找。
    5. JS 找到了 foo.proto,它发现这等于 Foo.prototype。
    6. Foo.prototype 啊,你有 sayHello 方法吗?
    7. Foo.prototype:有!在这里。
    8. 查找结束。执行这个方法。
  • 场景三:foo.toString() (最关键)
    1. JS 查找 foo 对象。
    2. foo 啊,你有 toString 吗? -> 没有。
    3. JS 沿着 foo.proto 找到 Foo.prototype。
    4. Foo.prototype 啊,你有 toString 吗? -> 没有。
    5. JS:好的,我去你的 proto 上找。 (这一步是链条的延伸!)
    6. JS 找到了 Foo.prototype.proto
    7. Foo.prototype 本身是一个普通对象,它是由 Object 构造函数创建的。因此,Foo.prototype.proto 指向的就是 Object.prototype。
    8. Object.prototype 啊,你有 toString 吗?
    9. Object.prototype:有!我这里有所有对象通用的 toString,hasOwnProperty 等方法。
    10. 查找结束。执行这个方法。
  • 场景四:foo.someNonExistentMethod()
    1. 重复上述过程,一直找到 Object.prototype
    2. Object.prototype 啊,你有 someNonExistentMethod 吗? -> 没有。
    3. JS:好的,我去你的 proto 上找。
    4. JS 找到了 Object.prototype.proto,发现它是 null。
    5. 查找结束。null 代表链条的终点,不能再找了。
    6. 如果是在获取属性,就返回 undefined。如果是在调用方法,就抛出错误 TypeError: foo.someNonExistentMethod is not a function。

原型链关系图


关键总结:

  • 实例 foo 通过 proto 链接到 Foo.prototype。
  • Foo.prototype (它也是个对象) 通过 proto 链接到 Object.prototype。
  • Object.prototype 通过 proto 链接到 null。
    这就是 foo 对象的完整原型链。

还有两种改变原型链的方式

  1. Object.create()
    这是另一种创建对象的方式,它可以让你显式指定新对象的原型。
1
2
3
4
5
6
7
8
9
10
11
12
13
const FooPrototype = {
sayHello: function() {
console.log('Hello!');
}
};

// 创建一个新对象 'student',并将其 __proto__ 直接指向 'FooPrototype'
const student = Object.create(FooPrototype);
student.name = "小明";

student.sayHello(); // "Hello!"

// 它的原型链: student -> FooPrototype -> Object.prototype -> null
  1. ES6 class 语法糖
    ES6 引入了 class 关键字,这让 JavaScript 看起来更像传统的面向对象语言。但请记住:

class 只是原型继承的“语法糖”!它的底层实现… 完完全全… 还是原型链!

下面的 class 写法 等价于 我们上面写的 function Foo 的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Foo {
// 构造函数
constructor(name) {
this.name = name;
}

// 这个方法会被自动添加到 Foo.prototype 上
sayHello() {
console.log('Hi, I am ' + this.name);
}
}

const foo = new Foo('张三');
foo.sayHello(); // "Hi, I am 张三"

// 验证一下
console.log(foo.__proto__ === Foo.prototype); // true
console.log(Foo.prototype.constructor === Foo); // true

总结:为什么原型链如此重要?

  1. 实现继承:它是 JS 继承的根基。class 的 extends 关键字,本质上也是在操作原型链。
  2. 节省内存:所有实例共享原型上的方法。你创建一千个 Foo 实例,sayHello 方法在内存中也只存在一份(在 Foo.prototype 上),而不是一千份。
  3. 理解 JS:不理解原型链,你就无法真正理解 JS 中的对象、this 的指向、以及 class 的工作原理。