原型链 —— 从 new 一个对象到方法共享的本质
上次聊 this 的时候提到过一句话:普通方法在原型上,所有实例共享一份。箭头函数是实例属性,每个对象独享一份。 当时没展开,这篇文章深入讲讲。
假如没有原型,new 一千个对象会怎样?
先回到最原始的问题:用构造函数创建对象。
function Dog(name: string) {
this.name = name;
this.bark = function () {
console.log(`${this.name}: 汪!`);
};
}
const d1 = new Dog("大黄");
const d2 = new Dog("小黑");
这能跑吗?能。好吗?不好!
console.log(d1.bark === d2.bark); // false
bark 这个函数,每 new 一次就创建一份全新的副本。 d1 和 d2 各自持有一个完全相同的函数定义,但它们在内存里是两个独立的对象。
name 每个实例不一样,属于每个人自己的独立属性,各自存一份,合理。但 bark 的行为完全一样,凭什么存一千份?
解决方法是,把共享的方法抽到一个公共的地方,所有实例都去那找。
最适合的存放位置
既然所有实例都是从一个类诞生的,类的本质又是一个function,那我们直接把所有的共享方法放在这个function的一个叫作prototype的属性里不就可以了!
function Dog(name: string) {
this.name = name; // 每个实例独有的数据
}
// 共享的方法放在这:
Dog.prototype.bark = function () {
console.log(`${this.name}: 汪!`);
};
const d1 = new Dog("大黄");
const d2 = new Dog("小黑");
d1.bark(); // "大黄: 汪!"
d2.bark(); // "小黑: 汪!"
console.log(d1.bark === d2.bark); // true —— 同一个函数!
如此一来,只需要让每一个实例记住自己是哪个类实例化出来的,用一个叫作_proto_的属性指向它的共享仓库
d1 自己身上没有 bark,但 d1.bark() 能正常调用。本质就是JS 引擎在背后做了一次查找:
1. d1 自己有 bark 吗? → 没有
2. 去 d1.__proto__(也就是 Dog.prototype)上找 → 找到了!
3. 执行它,d1调用的,所以天然 d1 作为 this
所以原型最核心的价值:共享方法,节省内存。
深挖 new 到底做了什么事?
// new Dog("大黄") 等价于:
function myNew<T>(constructor: new (...args: any[]) => T, ...args: any[]): T {
// 1. 创建一个空对象,它的 __proto__ 指向 constructor.prototype
const obj = Object.create(constructor.prototype);
// 2. 把 constructor 的 this 绑定到这个空对象,按参执行构造函数
const result = constructor.apply(obj, args);
// 3. 返回该对象
return obj;
}
const d = myNew(Dog, "大黄");
d.bark(); // "大黄: 汪!"
这里注意Object.create(Dog.prototype) 创建了一个空对象,空对象的 __proto__ 被设置为 Dog.prototype。所以它能访问原型上的方法。
原型链
原型不光能共享方法,还可以让一个原型指向另一个原型。链起来了,就叫原型链。
先认识一个前提:函数被创建时,它的 prototype 对象除了你手动加的方法,还默认自带一个 constructor 属性,指回函数本身:
function Animal(type: string) {
this.type = type;
}
Animal.prototype.breathe = function () {
console.log(`${this.type} 在呼吸`);
};
console.log(Animal.prototype.constructor === Animal); // true — 默认就有
constructor 平时用不上,但它是原型对象的”身份证”——声明了这个原型仓库属于哪个构造函数。
现在我们考虑手动实现类继承,让Dog 继承 Animal,分四步走。
第一步:继承属性
type 是每个实例自己的数据,不能共享,必须在 Dog 构造函数里手动调用 Animal 来赋值:
function Dog(name: string) {
Animal.call(this, "狗"); // 手动执行 Animal 构造函数,把 this.type = "狗" 写到当前实例上
this.name = name; // Dog 自己的属性
}
不加这行 → d.type 是 undefined。原型链只管方法的向上查找,管不了属性。
第二步:Object.create 搭桥
要让 Dog 的实例能调用 breathe(),需要把 Dog 的原型仓库连到 Animal 的原型仓库上。
Object.create(x) 做一件事:创建一个空对象 {},并把这个空对象的 __proto__ 设为 x。
Dog.prototype = Object.create(Animal.prototype);
此时 Dog.prototype 长这样:
Dog.prototype = {
// 空对象,里面什么也没有
// __proto__ → Animal.prototype = { breathe, constructor: Animal }
}
为什么不是
Dog.prototype = Animal.prototype?那样两个构造函数共享同一个原型对象,往 Dog.prototype 上加方法等于往 Animal 上加——狗叫变成所有动物都会叫了。必须用Object.create造一个中介对象。
第三步:修复 constructor
Dog.prototype 现在是空对象,它自己没有 constructor 属性。顺着 __proto__ 往上找,找到的是 Animal.prototype.constructor,也就是 Animal:
console.log(Dog.prototype.constructor); // Animal ← 错了,应该是 Dog
Dog 的原型仓库,身份证上写的却是 Animal。直接在空对象上补一个:
Dog.prototype.constructor = Dog;
这个属性平时没人用,但它保证了”这个原型对象属于哪个构造函数”这一事实是正确的。
第四步:加 Dog 自己的方法
桥通了,constructor 也修了,往 Dog 的原型上加独有方法:
Dog.prototype.bark = function () {
console.log(`${this.name}: 汪!`);
};
完整代码 + 验证:
function Animal(type: string) {
this.type = type;
}
Animal.prototype.breathe = function () {
console.log(`${this.type} 在呼吸`);
};
function Dog(name: string) {
Animal.call(this, "狗"); // ① 继承属性
this.name = name;
}
Dog.prototype = Object.create(Animal.prototype); // ② 搭桥
Dog.prototype.constructor = Dog; // ③ 修复 constructor
Dog.prototype.bark = function () {
// ④ 加 Dog 自己的方法
console.log(`${this.name}: 汪!`);
};
const d = new Dog("大黄");
d.bark(); // "大黄: 汪!" — Dog.prototype 上找到
d.breathe(); // "狗 在呼吸" — 沿着 __proto__ 上到 Animal.prototype
d.toString(); // "[object Object]" — 再往上到 Object.prototype
查找链路:
d = { name: "大黄", type: "狗" }
→ __proto__: Dog.prototype = { bark, constructor: Dog }
→ __proto__: Animal.prototype = { breathe, constructor: Animal }
→ __proto__: Object.prototype = { toString, hasOwnProperty, ... }
→ __proto__: null
规则很简单:找属性时,沿着 __proto__ 一层一层往上爬,爬到 null 为止。找不到就返回 undefined。
class 帮你做的语法糖
上一节用四步手工实现了 Dog extends Animal,ES6 的 class 本质就是把这套流程自动化了:
class Animal {
type: string;
constructor(type: string) {
this.type = type;
}
breathe() {
console.log(`${this.type} 在呼吸`);
}
}
class Dog extends Animal {
name: string;
constructor(name: string) {
super("狗"); // ① 继承属性 — 等价于 Animal.call(this, "狗")
this.name = name;
}
bark() {
// ② ③ ④ extends 自动帮你干了
console.log(`${this.name}: 汪!`);
}
}
一行 extends,替你干了三件事:
- 搭桥:
Dog.prototype = Object.create(Animal.prototype)— 对应第二步 - 修复身份证:
Dog.prototype.constructor = Dog— 对应第三步 - 加方法:class body 里的
bark()自动挂到Dog.prototype上 — 对应第四步
验证一下,class 版和手工原型版完全一致:
const d = new Dog("大黄");
// super("狗") 等价于 Animal.call(this, "狗")
console.log(d.type); // "狗"
// bark 在 Dog.prototype 上,和手写一样
console.log(d.hasOwnProperty("bark")); // false
console.log(d.__proto__.hasOwnProperty("bark")); // true
// 原型链结构不变 — 和上一节的查找链路一模一样
console.log(d.__proto__ === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // true
console.log(Dog.prototype.constructor === Dog); // true — constructor 也自动修复了
d.bark(); // "大黄: 汪!"
d.breathe(); // "狗 在呼吸"
class 没有创造新机制,它只是把「四步继承法」包装成了 extends + super。 底层的原型链查找逻辑没有一丝改变。
有了这套对应关系,再看一个容易踩坑的细节——为什么箭头函数作为 class field 时不是共享的:
class Dog {
name: string = "";
// 普通方法 → Dog.prototype.bark
bark() {
console.log(this.name);
}
// 箭头属性 → 每个实例独享,等价于在 constructor 里写 this.run = () => {}
run = () => {
console.log(this.name);
};
}
const d1 = new Dog();
const d2 = new Dog();
console.log(d1.bark === d2.bark); // true —— 原型上共享
console.log(d1.run === d2.run); // false —— 实例属性,各有一份
class field 中的 run = () => {} 本质上等价于:
class Dog {
constructor() {
this.run = () => {
console.log(this.name);
};
}
}
写在 constructor 里的东西都挂在实例身上,不会共享到原型上。
三个高频面试题,用原型链秒答
instanceof 是怎么判断的?
d instanceof Dog; // true
d instanceof Animal; // true
d instanceof Object; // true
它做的事:沿着 d.__proto__ 一路往上爬,看能不能找到 Dog.prototype(或 Animal.prototype、Object.prototype)。找到了就返回 true。
hasOwnProperty 有什么用?
const d = new Dog("大黄");
console.log(d.hasOwnProperty("name")); // true —— name 是自己的
console.log(d.hasOwnProperty("bark")); // false —— bark 在原型上
遍历对象时常用它来过滤掉原型上的方法:
for (const key in d) {
if (d.hasOwnProperty(key)) {
console.log(`自己的属性: ${key}`);
}
}
// 输出: "自己的属性: name"
Object.create(null) 是什么意思?
const pureObj = Object.create(null);
pureObj.name = "pure";
console.log(pureObj.toString); // undefined
console.log(pureObj.__proto__); // undefined
创建一个没有原型的纯字典对象,连 toString、hasOwnProperty 都没有。适合当纯 key-value 映射用,不用担心原型属性污染。
原型链简图
总结:
- 原型的存在是为了共享方法,节省内存。 方法放在
prototype上,实例通过__proto__去找。 prototype是函数创建的”模板仓库”,仓库内也有__proto__继续指向上层仓库,是对象查找的”路线图”。- class 语法没有创造任何新东西,它只是原型机制的语法糖。 理解了原型链,class 里的一切行为都有据可查。