你可能已经习惯了 class 语法,但早期的 JS 没有类,却一样玩转面向对象 ------ 这一切都藏在
prototype和原型链里。今天我们从"用栈模拟队列"这个经典问题出发,彻底搞懂 JS 的原型式面向对象。
一、从栈模拟队列说起
先来看一个经典问题:如何用两个栈实现一个队列?
队列是 FIFO(先进先出),栈是 FILO(先进后出)。两个栈倒腾一下,就能实现队列的效果:
push(x)→ 元素放进队列尾部pop()→ 从队列头部移除元素peek()→ 返回队列头部元素empty()→ 判断队列是否为空
如果用传统面向对象语言(Java/C++),你会先写一个 class MyQueue。但在 JS 里,我们不需要 class 也能完成面向对象。
用函数 + 原型实现 MyQueue
kotlin
// 构造函数:负责创建实例,添加实例私有属性
const MyQueue = function () {
this.stack1 = []; // 用于入队
this.stack2 = []; // 用于出队
}
// 在原型上添加共享方法
MyQueue.prototype.push = function (x) {
this.stack1.push(x);
}
MyQueue.prototype.pop = function () {
if (this.stack2.length === 0) {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop());
}
}
return this.stack2.pop();
}
MyQueue.prototype.peek = function () {
if (this.stack2.length === 0) {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop());
}
}
return this.stack2[this.stack2.length - 1];
}
MyQueue.prototype.empty = function () {
return this.stack1.length === 0 && this.stack2.length === 0;
}
// 使用 new 创建实例
const queue = new MyQueue();
queue.push(1);
queue.push(2);
console.log(queue.peek()); // 1
console.log(queue.pop()); // 1
console.log(queue.empty()); // false
运行这段代码,你会发现完全没有 class 关键字,却实现了完整的队列类。这就是 JS 基于原型的面向对象。
二、JS 的面向对象:没有类,只有对象
JavaScript 的设计哲学是:一切皆对象,没有类。如果你来自 Java 或 C++,可能会觉得奇怪 ------ 没有类怎么做面向对象?
答案是:使用构造函数 + 原型对象。
2.1 构造函数是什么?
任何函数都可以作为构造函数,只要用 new 来调用它。
javascript
function Greeting(name) {
console.log(this); // 指向新创建的空对象
this.name = name;
}
const zzh = new Greeting('zzh');
console.log(zzh.name); // 'zzh'
new 的过程经历了四个步骤:
- 创建一个空的普通对象
{} - 将这个空对象的
__proto__指向构造函数的prototype - 将构造函数的
this绑定到这个空对象上,并执行构造函数 - 如果构造函数返回了一个对象,则返回该对象;否则返回步骤 1 创建的对象
这就是为什么 this.name = name 能给实例添加属性。
2.2 函数是一等对象
在 JS 里,函数不仅是可执行的代码块,它本身也是一个对象 ------ 可以有自己的属性。
javascript
function greeting() {
console.log('hello world');
}
greeting.a = '1'; // 函数也可以添加属性
console.log(greeting.a); // '1'
greeting(); // 'hello world'
这说明了 函数在 JS 中是一等公民:它可以被赋值、作为参数传递、作为返回值,甚至像普通对象一样拥有属性。
三、prototype 与原型链
这是 JS 面向对象的核心。先看一张图(概念图):
javascript
构造函数 (如 MyQueue)
|
|-- prototype 指向 -----> 原型对象 (MyQueue.prototype)
|
|-- constructor 指向 -----> 构造函数
|
|-- __proto__ 指向 -----> Object.prototype
实例 (如 queue)
|
|-- __proto__ 指向 -----> 原型对象 (MyQueue.prototype)
3.1 prototype 属性
每个函数都有一个 prototype 属性,它指向一个原型对象。这个原型对象上的所有属性和方法,都会被通过该函数创建的实例共享。
javascript
function Person(name, age) {
this.name = name;
this.age = age;
}
// 在原型上定义共享方法
Person.prototype.say = function () {
console.log(`我叫${this.name},很高兴认识你`);
}
Person.prototype.work = function () {
console.log(`我叫${this.name},我正在工作`);
}
const zwy = new Person('zwy', 18);
zwy.say(); // 我叫zwy,很高兴认识你
zwy.work(); // 我叫zwy,我正在工作
如果每个实例都有一份 say 方法,会浪费大量内存。放在 prototype 上,所有实例共用同一个方法,这就是共享方法的精髓。
3.2 constructor 属性
原型对象上有一个 constructor 属性,它指回构造函数本身。这形成了一个闭环:
ini
console.log(Person.prototype.constructor === Person); // true
console.log(zwy.constructor === Person); // true(通过原型链找到)
你可以通过 constructor 判断一个实例是由哪个构造函数创建的,虽然实际开发中不太常用,但在某些框架或库中(比如做深拷贝或类型判断)会派上用场。
3.3 __proto__ 与原型链
每个实例都有一个内置属性 __proto__(现在规范中是 Object.getPrototypeOf()),它指向构造函数的 prototype。
javascript
console.log(zwy.__proto__ === Person.prototype); // true
console.log(zwy.__proto__.__proto__ === Object.prototype); // true
console.log(zwy.__proto__.__proto__.__proto__); // null
当访问实例的一个属性或方法时,JS 引擎会沿着这条链一直往上找,直到找到或者到达 null。这就是原型链。
- 先在实例自身找(比如
zwy.name) - 找不到就去
zwy.__proto__(即Person.prototype)找 - 还找不到就去
Person.prototype.__proto__(即Object.prototype)找 - 最终到
null,停止,返回undefined
3.4 顶层对象 Object
Object.prototype 是原型链的倒数第二环(再往上就是 null)。它上面定义了所有对象都有的方法,比如 toString()、hasOwnProperty() 等。
arduino
console.log(zwy.toString());
// 明明 Person 里没有定义 toString,却能调用!
// 因为 zwy -> Person.prototype -> Object.prototype
// 最终在 Object.prototype 上找到了 toString
所有对象最终都继承自 Object.prototype。这也是为什么说"JS 中一切皆对象" ------ 哪怕是普通对象字面量 {},它的原型也是 Object.prototype。
四、this 的指向问题
在上面的例子中,我们多次看到 this.name。this 在构造函数中指向新创建的实例,在原型方法中也指向调用该方法的实例。
javascript
MyQueue.prototype.push = function (x) {
// 这里的 this 指向调用 push 的实例(比如 queue)
this.stack1.push(x);
}
关键规则 :this 在函数执行时动态绑定,谁调用它,它就指向谁。
- 构造函数中:
new绑定了新对象 - 原型方法中:实例调用,所以
this指向实例 - 全局函数中:
this指向window(浏览器)或global(Node.js)
五、共享方法的实际价值
假设你有 1000 个 Person 实例。如果每个实例都包含 say 方法的副本,内存占用会很大。但如果把 say 放在 Person.prototype 上,1000 个实例共用同一个函数,内存节省立竿见影。
javascript
// 不推荐:每个实例都重复创建方法
function BadPerson(name) {
this.name = name;
this.say = function () {
console.log(this.name);
};
}
// 推荐:方法放在原型上
function GoodPerson(name) {
this.name = name;
}
GoodPerson.prototype.say = function () {
console.log(this.name);
};
六、从 ES6 class 看本质
现在你可能会说:"ES6 不是有 class 吗?" 是的,但 ES6 的 class 只是语法糖,底层仍然是原型链。
kotlin
class MyQueueES6 {
constructor() {
this.stack1 = [];
this.stack2 = [];
}
push(x) {
this.stack1.push(x);
}
// ...
}
上面的代码等价于我们最开始写的 MyQueue 构造函数 + 原型方法。class 让代码更清晰,但背后的原理没有变。
七、总结
| 概念 | 说明 |
|---|---|
| 构造函数 | 用 new 调用的函数,负责创建实例和添加私有属性 |
prototype |
函数上的属性,指向原型对象,存放共享方法 |
__proto__ |
实例上的属性,指向构造函数的 prototype |
| 原型链 | 通过 __proto__ 串联起来的查找链,终点是 null |
constructor |
原型对象上的属性,指向构造函数本身 |
| 共享方法 | 定义在 prototype 上的方法,所有实例共用,节省内存 |
this |
动态绑定,谁调用指向谁 |
记住这几点:
- JS 没有类,只有对象,用构造函数 + 原型实现面向对象
- 实例的
__proto__指向构造函数的prototype - 属性/方法查找会沿着原型链向上,直到
null - 共享方法定义在
prototype上,私有属性定义在构造函数中 - 即使你用 ES6
class,背后的原型链机制从未改变
希望这篇文章能帮助你彻底理解 JS 的面向对象。下次再看到原型链相关的面试题,你一定能游刃有余了!