Appearance
原型链与继承
理解路径
JavaScript 对象复用的基础是原型委托。对象本身没有某个属性时,会沿 [[Prototype]] 链继续查找。构造函数、new 和 class 都是在这套原型机制之上的语法和约定。
原型和原型链是什么?
是什么
原型链就是由多个 [[Prototype]] 引用连接起来的链状结构。读取属性时,如果对象自身没有该属性,JavaScript 引擎会继续去它的原型上找,再去原型的原型上找,直到链尾。
原型对象
除 null 外,JavaScript 中的对象在创建时通常会关联另一个对象,这个被关联的对象就是它的原型。对象可以通过原型复用属性和方法,但这种“继承”不是复制,而是读取属性时发生的委托查找。
js
const parent = { role: "admin" };
const child = Object.create(parent);
child.name = "Ada";
child.role; // 'admin'原型链查找规则概述
读取对象属性时,JavaScript 引擎会按下面的顺序查找:
- 先在对象自身属性中寻找。
- 如果自身有这个属性,直接使用。
- 如果自身没有这个属性,就去对象的原型中寻找。
- 如果原型上找到了就使用;如果没有,就继续去原型的原型上寻找。
- 以此类推,直到查到原型链末端;如果仍然没有找到,就返回
undefined。
js
const base = {
role: "member",
permissions: ["read"],
};
const user = Object.create(base);
user.name = "Ada";
console.log(user.name); // 'Ada',来自自身
console.log(user.role); // 'member',来自原型
console.log(user.missing); // undefined,查到链尾仍未找到原型链图例
原型与原型链结构可以参考下图:

如果自身属性和原型属性同名,自身属性会遮蔽原型属性。读取时先命中自身属性,就不会继续向原型链上查。
边界说明
常见普通对象的原型链尾部通常是 Object.prototype,而 Object.prototype 自身的 [[Prototype]] 是 null。但 Object.create(null) 可以创建没有原型的对象,所以不能把“所有对象最终都到 Object.prototype”说成绝对规则。
判断一个属性是不是对象自身拥有时,不能只用 obj.key !== undefined,应使用 Object.hasOwn(obj, key) 或 Object.prototype.hasOwnProperty.call(obj, key)。
构造函数、prototype 和 __proto__ 怎么区分?
- 函数的
prototype属性用于给通过new创建的实例指定原型。 - 实例的内部
[[Prototype]]指向构造函数的prototype。 __proto__是访问内部原型的历史访问器,正式代码优先用Object.getPrototypeOf()和Object.setPrototypeOf()。
js
function User(name) {
this.name = name;
}
User.prototype.say = function say() {
return this.name;
};
const user = new User("Ada");
Object.getPrototypeOf(user) === User.prototype; // truenew 操作符做了什么?
new Fn(...args) 主要做四件事:
- 创建一个新对象。
- 把新对象的
[[Prototype]]指向Fn.prototype。 - 以新对象作为
this调用Fn。 - 如果构造函数显式返回对象,则返回该对象;否则返回新对象。
class 是什么?
class 是基于原型的语法糖,但不只是简单替换构造函数。类声明有暂时性死区,类体默认严格模式,类方法不可枚举,必须用 new 调用构造器。
js
class User {
constructor(name) {
this.name = name;
}
say() {
return this.name;
}
}常见继承方式怎么评价?
JavaScript 继承方式常见可以按下面 6 类理解:
- 原型链继承。
- 借用构造函数,也叫经典继承。
- 组合继承。
- 原型式继承。
- 寄生式继承。
- 寄生组合式继承。
| 方式 | 核心写法 | 优点 | 主要问题 |
|---|---|---|---|
| 原型链继承 | Child.prototype = new Parent() | 可以继承父类原型方法 | 引用属性共享,子类创建实例时不能向父类传参 |
| 借用构造函数 | Parent.call(this, ...args) | 实例属性独立,可以向父类传参 | 不会连接父类原型;构造函数内联方法无法共享 |
| 组合继承 | 借用构造函数 + 原型链继承 | 实例属性独立,原型方法可复用 | 父构造函数会调用两次,子类原型上有冗余属性 |
| 原型式继承 | Object.create(parentLikeObject) | 适合基于已有对象创建新对象 | 本质是浅委托,引用属性仍共享,不能走构造函数传参 |
| 寄生式继承 | 创建对象后再增强对象 | 可以封装增强过程 | 每次创建对象都会重新创建增强方法 |
| 寄生组合式继承 | Object.create(Parent.prototype) + Parent.call(this) | ES5 中较完整,只调用一次父构造函数 | 写法比 class extends 繁琐 |
原型链继承
原型链继承的核心是让子类原型等于父类实例:
js
Child.prototype = new Parent();每个构造函数都有一个 prototype 对象,实例内部有一个指向原型对象的 [[Prototype]] 指针。实例通过这个内部指针访问原型对象,原型对象又通过 constructor 指回构造函数。
js
function Parent() {
this.name = "kevin";
}
Parent.prototype.getName = function getName() {
return this.name;
};
Parent.prototype.stringVal = "parentA";
function Child() {}
Child.prototype = new Parent();
Child.prototype.constructor = Child;
const child = new Child();
console.log(child.getName()); // 'kevin'
console.log(child.stringVal); // 'parentA'原型链继承的主要缺点:
- 父类构造函数里的引用类型属性会被所有子类实例共享。
- 创建子类实例时,不能向父类构造函数传参。父构造函数只在设置 Child.prototype 时执行一次;创建子实例时不会再次执行,因此不能使用不同参数分别初始化每个子实例。
- 子类原型只能直接指向一个对象,不能直接同时指向多个父类原型。
- 给子类原型添加属性和方法,应放在替换
Child.prototype之后、创建子类实例之前。
引用类型属性共享的问题最典型:
js
function Parent() {
this.names = ["kevin", "daisy"];
this.year = 1010;
}
function Child() {}
Child.prototype = new Parent();
Child.prototype.constructor = Child;
const child1 = new Child();
child1.names.push("yayu");
child1.year = "abab";
console.log(child1.names); // ['kevin', 'daisy', 'yayu']
console.log(child1.year); // 'abab'
const child2 = new Child();
console.log(child2.names); // ['kevin', 'daisy', 'yayu']
console.log(child2.year); // 1010names 是共享数组,所以 child1.names.push() 会影响 child2。year 是原始值,child1.year = 'abab' 会在 child1 自身创建同名属性,遮蔽原型上的 year,不会改掉原型上的 year。
借用构造函数,也叫经典继承
借用构造函数的核心是在子类构造函数中,通过 call() 或 apply() 调用父类构造函数:
js
function Parent(name, age) {
this.name = name;
this.age = age;
this.sayHello = function sayHello() {
return `hello, 大家好,我是 ${this.name}`;
};
}
Parent.prototype.getRole = function getRole() {
return "parent";
};
function Child(name, age) {
Parent.call(this, name, age);
}
const child1 = new Child("孙悟空", 18);
const child2 = new Child("猪八戒", 18);
console.log(child1.name); // '孙悟空'
console.log(child1.sayHello()); // 'hello, 大家好,我是 孙悟空'
console.log(child1.sayHello === child2.sayHello); // false
console.log(child1.getRole); // undefined这种方式只是让父构造函数以当前子实例作为 this 执行初始化代码。它会把 name、age、sayHello 等属性直接写到每个子实例上,但不会让 Child.prototype 连接到 Parent.prototype。
优点:
- 避免原型链继承中引用类型属性被所有实例共享。
- 可以在子类中向父类构造函数传参。
缺点:
- 无法继承父类原型方法:
Parent.call(this)只执行父构造函数,不会建立从Child.prototype到Parent.prototype的原型链,因此child1.getRole是undefined。 - 构造函数内联方法无法共享:如果为了让子实例获得方法而在父构造函数中写
this.sayHello = function ...,每个子实例都会拥有独立的sayHello函数对象。对于不需要实例私有闭包状态的公共方法,这会造成不必要的重复创建,因此通常视为缺点;只有确实需要为每个实例保留独立闭包状态时,这种做法才可能是有意设计。
第一点是这种继承方式本身的限制;第二点是为了绕过第一点、把方法放回构造函数时产生的常见代价。可复用的方法更适合放在原型上,因此需要组合继承同时连接父类原型并初始化实例属性。
组合继承
组合继承综合原型链继承和借用构造函数。它通过借用构造函数继承实例属性,通过原型链继承原型方法。
实现步骤:
- 在子类构造函数中调用父类构造函数:
Parent.call(this, name)。 - 让子类原型等于父类实例:
Child.prototype = new Parent()。 - 把子类原型的
constructor指回子类:Child.prototype.constructor = Child。
js
function Parent(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
Parent.prototype.getName = function getName() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = new Parent();
Child.prototype.constructor = Child;
const child1 = new Child("kevin", 18);
child1.colors.push("black");
console.log(child1.name); // 'kevin'
console.log(child1.age); // 18
console.log(child1.colors); // ['red', 'blue', 'green', 'black']
console.log(child1.getName()); // 'kevin'
const child2 = new Child("daisy", 20);
console.log(child2.name); // 'daisy'
console.log(child2.age); // 20
console.log(child2.colors); // ['red', 'blue', 'green']组合继承的优点是同时解决了实例属性独立和原型方法复用的问题。缺点是父类构造函数会执行两次:
- 设置子类原型时,
Child.prototype = new Parent()执行一次。 - 创建子类实例时,
Parent.call(this, name)再执行一次。
第二次调用得到的是每个实例自己的属性;第一次调用会在 Child.prototype 上留下冗余的实例属性。
class extends 可以写出更直观的继承:
js
class Parent {
constructor(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
getName() {
return this.name;
}
}
class Child extends Parent {
constructor(name, age) {
super(name);
this.age = age;
}
}
const child1 = new Child("Alice", 10);
child1.colors.push("black");
const child2 = new Child("Bob", 12);
console.log(child1.getName()); // 'Alice'
console.log(child2.colors); // ['red', 'blue', 'green']
console.log(child1 instanceof Parent); // trueextends 会建立子类原型到父类原型的连接,super() 会执行父类构造函数完成实例初始化。它保留了原型方法复用和实例属性独立这两个目标,同时避免传统组合继承中“设置子类原型时调用一次父构造函数”的冗余。
原型式继承
原型式继承适合“已有一个对象,想基于它再创建一个新对象”的场景。它通过一个临时构造函数把传入对象放到新对象的原型链上,本质上是 Object.create() 的简化模拟。
js
function object(o) {
function F() {}
F.prototype = o;
return new F();
}js
const person = {
name: "kevin",
friends: ["daisy", "kelly"],
};
const person1 = object(person);
const person2 = object(person);
person1.name = "person1";
console.log(person2.name); // 'kevin'
person1.friends.push("taylor");
console.log(person2.friends); // ['daisy', 'kelly', 'taylor']person1.name = 'person1' 会在 person1 自身创建同名属性,不会修改原型对象上的 name。friends 是原型对象上的共享数组,修改数组内容会影响其它委托到同一个原型对象的实例。
原型式继承的缺点和原型链继承类似:引用类型属性共享,也不能通过子类构造函数传参。
寄生式继承
寄生式继承会创建一个仅用于封装继承过程的函数,在函数内部基于某个对象创建新对象,再增强这个新对象,最后返回它。
js
function createObj(o) {
const clone = Object.create(o);
clone.sayName = function sayName() {
return "hi";
};
return clone;
}
const person = { name: "kevin" };
const person1 = createObj(person);
console.log(person1.name); // 'kevin'
console.log(person1.sayName()); // 'hi'它的问题和借用构造函数类似:增强方法写在工厂函数内部时,每次创建对象都会重新创建一遍方法。
寄生组合式继承
寄生组合式继承是为了解决组合继承中“父构造函数调用两次”的问题。它不使用 Child.prototype = new Parent(),而是让 Child.prototype 间接访问 Parent.prototype。
js
function Parent(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
Parent.prototype.getName = function getName() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
const child = new Child("kevin", 18);
console.log(child.getName()); // 'kevin'
console.log(child instanceof Child); // true
console.log(child instanceof Parent); // true
console.log(Parent.prototype.isPrototypeOf(child)); // true可以把连接原型的过程封装起来:
js
function inheritPrototype(child, parent) {
const prototype = Object.create(parent.prototype);
prototype.constructor = child;
child.prototype = prototype;
}
inheritPrototype(Child, Parent);寄生组合式继承的特点:
- 只在创建子类实例时调用一次父构造函数。
- 子类原型不会带有父构造函数初始化出的冗余实例属性。
- 原型链保持正确,
instanceof和isPrototypeOf()仍然可用。 - 在 ES5 写法中,它是相对完整的引用类型继承方案。
ES6 class 的静态成员、实例成员什么时候挂载?
class 在 ES2015 中引入;下面还包含后来加入标准的类字段和静态初始化块。理解“挂载”时,需要同时区分属性最终属于谁和初始化代码何时执行。
| 成员 | 声明示例 | 所在位置 | 定义或初始化时机 |
|---|---|---|---|
| 静态方法、静态访问器 | static create() {} | 类本身,如 Base.create | 类定义求值时安装,并且早于静态字段初始化器和静态块执行 |
| 静态字段 | static type = 'base' | 类本身,如 Base.type | 类定义求值时初始化一次;与静态块按源码顺序执行 |
| 静态初始化块 | static { ... } | 不会形成一个可访问的成员 | 类定义求值时执行一次;与静态字段初始化器按源码顺序执行 |
| 实例方法、实例访问器 | Base.prototype | 原型对象 | 类定义求值时安装,所有实例通过原型链共享 |
| 实例字段 | field = value | 每个实例自身 | 每次 new 都初始化;基类在构造函数体执行前,派生类在 super() 返回后、后续语句执行前 |
| 构造函数内赋值 | this.name = name | 每个实例自身 | 构造函数执行到该语句时 |
下面的完整案例同时展示类定义阶段、实例创建阶段和最终挂载位置:
js
const logs = []
class Base {
// 类定义求值时初始化一次,属性挂在 Base 自身。
static type = (logs.push('1. Base 静态字段'), 'base')
// 类定义求值时执行一次;this 指向 Base。
static {
logs.push('2. Base 静态块')
this.ready = true
}
// 类定义求值时挂在 Base 自身。
static describe() {
return `${this.type}: ${this.ready}`
}
// 每次创建实例都会初始化,属性挂在实例自身。
baseField = (logs.push('6. Base 实例字段'), 'base field')
constructor(name) {
logs.push('7. Base constructor 函数体')
this.name = name
}
// 类定义求值时挂在 Base.prototype,供实例共享。
getLabel() {
return `${this.name}: ${this.baseField}`
}
}
class Child extends Base {
// 父类的静态初始化先完成,然后才轮到子类。
static type = (logs.push('3. Child 静态字段'), 'child')
static {
logs.push('4. Child 静态块')
}
childField = (logs.push('8. Child 实例字段'), 'child field')
constructor(name) {
// super() 之前可以执行不访问 this 的代码。
logs.push('5. Child constructor:super 前')
super(name)
// Child 的实例字段已在 super() 返回后、运行到这里前完成初始化。
logs.push('9. Child constructor:super 后')
this.createdInConstructor = true
}
}
// 此时只定义了类,还没有创建实例。
console.log(logs)
// [
// '1. Base 静态字段',
// '2. Base 静态块',
// '3. Child 静态字段',
// '4. Child 静态块'
// ]
const child = new Child('孙悟空')
console.log(logs.slice(4))
// [
// '5. Child constructor:super 前',
// '6. Base 实例字段',
// '7. Base constructor 函数体',
// '8. Child 实例字段',
// '9. Child constructor:super 后'
// ]
// 静态成员属于类,不属于实例。
console.log(Object.hasOwn(Base, 'type')) // true
console.log(Object.hasOwn(Base, 'describe')) // true
console.log(child.describe) // undefined
// 静态方法可以被子类继承;调用时 this 是 Child。
console.log(Child.describe()) // 'child: true'
// 实例字段是实例自身属性,实例方法则来自原型。
console.log(Object.hasOwn(child, 'baseField')) // true
console.log(Object.hasOwn(child, 'childField')) // true
console.log(Object.hasOwn(child, 'createdInConstructor')) // true
console.log(Object.hasOwn(child, 'getLabel')) // false
console.log(Object.hasOwn(Base.prototype, 'getLabel')) // true
console.log(child.getLabel()) // '孙悟空: base field'关键点:
- 类定义阶段:先安装静态方法和原型方法,再按源码顺序执行静态字段初始化器与静态块;父类的静态初始化先于子类。
- 实例创建阶段:每次
new都会重新初始化实例字段。基类字段先于基类构造函数体,派生类字段则紧跟在super()返回之后。 - 最终挂载位置:静态成员在类本身,实例方法在类的
prototype上,实例字段与构造函数中的this.x在每个实例自身。
ES5 继承和 ES6 继承除了写法,还有什么区别?
不只是语法糖的差异,运行语义也不同:
- 实例的创建主体不同:ES5 寄生组合继承是「子类先用
new建好this,再Parent.call(this)把父类属性塞进来」;ES6 是「父类的 constructor 先创建实例,super()把这个实例返回给子类」。所以 ES6 子类里super()之前不能用this。 - 能否继承内置类型:ES6
class extends Array / Error / HTMLElement能得到行为正确的子类;ES5 用Parent.call(this)继承内置类型通常失败(内置构造函数忽略传入的this,拿不到内部插槽)。 - 提升与调用约束:
class有暂时性死区、类体强制严格模式、方法不可枚举、必须new调用;ES5 构造函数会变量提升,也可能被当普通函数误调用。 super机制:ES6 通过[[HomeObject]]支持在方法里用super.method()调父类原型方法;ES5 只能Parent.prototype.method.call(this)手动转发。
instanceof 的内部逻辑是什么?
obj instanceof Ctor 会检查 Ctor.prototype 是否出现在 obj 的原型链上。
js
function myInstanceof(value, ctor) {
if (value == null || (typeof value !== "object" && typeof value !== "function")) {
return false;
}
let proto = Object.getPrototypeOf(value);
const target = ctor.prototype;
while (proto) {
if (proto === target) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}跨 realm 或构造函数自定义 Symbol.hasInstance 时,instanceof 结果可能变化。
面向对象三特征在 JavaScript 中怎么理解?
封装可以通过闭包、模块、私有字段和对象边界实现;继承主要通过原型委托和 class extends 实现;多态来自动态派发,即不同对象暴露同名方法并在运行时调用。
寄生组合继承
js
function Parent(name) {
this.name = name;
}
Parent.prototype.say = function say() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;class extends
js
class Parent {
constructor(name) {
this.name = name;
}
}
class Child extends Parent {
constructor(name, age) {
super(name);
this.age = age;
}
}原型修改与重写
js
function Person(name) {
this.name = name;
}
// 修改原型(添加属性)
Person.prototype.getName = function () {};
var p = new Person("hello");
console.log(p.__proto__ === Person.prototype); // true
console.log(p.__proto__ === p.constructor.prototype); // true
// 重写原型(直接赋值对象)
Person.prototype = { getName: function () {} };
var p2 = new Person("hello");
console.log(p2.__proto__ === Person.prototype); // true
console.log(p2.__proto__ === p2.constructor.prototype); // false
// constructor 丢失,需要手动修复
p2.constructor = Person;重写原型后,constructor 会指向 Object(根构造函数),需要手动指回来。
原型链指向
js
// 以 Person 构造函数为例
p.__proto__; // Person.prototype
Person.prototype.__proto__; // Object.prototype
p.__proto__.__proto__; // Object.prototype
p.__proto__.constructor; // Person
Person.prototype.constructor; // Person
Object.prototype.__proto__; // null(原型链终点)原型链的终点是什么?如何打印出原型链的终点?
原型链的终点是 null。
所有对象最终都继承自 Object.prototype,而 Object.prototype.__proto__ === null,因此 null 是原型链的终点。
js
// 打印出原型链
function printChain(obj) {
let cur = obj;
while (cur !== null) {
console.log(cur);
cur = Object.getPrototypeOf(cur);
}
}如何获得对象非原型链上的属性?
使用 hasOwnProperty() 方法来判断属性是否是对象自身的属性(而非继承自原型链):
js
function iterate(obj) {
const res = [];
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
res.push(key + ": " + obj[key]);
}
}
return res;
}对象创建的方式有哪些?
- 工厂模式:用函数封装对象创建,可复用,但无法识别对象类型。
- 构造函数模式:通过
new调用,可识别类型,但每次创建实例都会新建函数对象,浪费内存。 - 原型模式:在
prototype上定义方法,方法复用,但引用类型属性被所有实例共享。 - 组合模式(推荐):构造函数定义属性 + 原型定义方法,最常见。
- 动态原型模式:将原型方法的赋值放在构造函数内部,首次调用时才初始化。
- 寄生构造函数模式:基于已有类型扩展实例,不修改原构造函数。
js
// 组合模式示例
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.greet = function () {
return "Hi, I am " + this.name;
};实现 call、apply 及 bind 函数
js
// 实现 call
Function.prototype.myCall = function (context, ...args) {
if (typeof this !== "function") throw new TypeError("Error");
context = context || window;
context.fn = this;
const result = context.fn(...args);
delete context.fn;
return result;
};
// 实现 apply
Function.prototype.myApply = function (context, args) {
if (typeof this !== "function") throw new TypeError("Error");
context = context || window;
context.fn = this;
const result = args ? context.fn(...args) : context.fn();
delete context.fn;
return result;
};
// 实现 bind
Function.prototype.myBind = function (context, ...args) {
if (typeof this !== "function") throw new TypeError("Error");
const fn = this;
return function Fn(...innerArgs) {
return fn.apply(this instanceof Fn ? this : context, args.concat(innerArgs));
};
};