目录
JavaScript 6. 原型继承与面向对象编程
JavaScript 可以使用普通对象、工厂函数、构造函数和类组织程序。对于只包含少量数据的记录,普通对象通常已经足够;当一组对象需要维护各自状态、共享行为并保持明确的不变量时,类和原型继承能够提供更清晰的结构。
JavaScript 的对象继承建立在原型链上。class 并没有用另一套与原型无关的对象模型取代它,而是为构造、方法共享、继承、私有元素和静态成员提供了统一语法。
不过,把类简单称为“构造函数和原型的语法糖”也不够准确。类具有普通构造函数不自动具备的语义,例如:
- 类构造器不能脱离
new直接调用; - 类体始终在严格模式下执行;
- 类方法默认不可枚举;
- 类声明具有暂时性死区;
- 类支持公有和私有字段、私有方法、静态块等专门语法;
- 派生类构造过程具有
super()和this初始化约束。
因此,理解 JavaScript 面向对象编程需要同时掌握两层内容:
- 对象通过原型链继承属性和方法;
- 类语法如何在这一模型上定义实例、继承关系和封装边界。
第二章已经介绍普通函数与箭头函数的 this 绑定。本章只讨论 this 在构造和实例方法中的建模作用。
6.1 面向对象建模的基本概念
面向对象编程通常围绕对象的状态与行为组织代码。
const account = {
balance: 100,
deposit(amount) {
this.balance += amount;
},
};
这里:
balance表示状态;deposit()表示行为;- 对象把二者组织在同一接口下。
6.1.1 抽象
抽象是从具体实现中提取稳定接口。
例如,调用者只需要知道账户可以存款和读取余额,不需要知道余额具体保存在普通属性、私有字段还是外部数据库中。
account.deposit(50);
console.log(account.balance);
良好的抽象应使调用者依赖行为契约,而不是依赖内部表示。
6.1.2 封装
封装用于限制内部状态的直接访问,并把验证规则集中到少量入口。
class BankAccount {
#balance = 0;
deposit(amount) {
if (
typeof amount !== "number"
|| !Number.isFinite(amount)
|| amount <= 0
) {
throw new RangeError(
"amount 必须是正的有限数值",
);
}
this.#balance += amount;
}
get balance() {
return this.#balance;
}
}
调用者只能通过公开方法修改余额,从而避免绕过验证规则。
6.1.3 继承
继承允许一个对象或类复用另一个对象或类的公开行为,并在此基础上扩展或重写。
class Animal {
speak() {
return "The animal makes a sound.";
}
}
class Dog extends Animal {
speak() {
return "The dog barks.";
}
}
继承表达的是一种较强的“属于同一抽象类别”关系。并非所有代码复用都适合继承,组合通常更加灵活。
6.1.4 多态
多态表示不同对象可以通过相同接口被统一使用:
class Circle {
area() {
return Math.PI;
}
}
class Rectangle {
area() {
return 2;
}
}
function printArea(shape) {
console.log(shape.area());
}
printArea(new Circle());
printArea(new Rectangle());
printArea() 不需要知道对象的具体类,只要求对象提供可调用的 area() 方法。这种基于行为的方式通常称为鸭子类型。
6.2 原型与原型链
第五章已经介绍对象属性查找的基本过程。本节从面向对象建模的角度进一步整理原型关系。
6.2.1 对象的 [[Prototype]]
普通对象具有规范内部槽 [[Prototype]],其值为另一个对象或 null。
const animal = {
eats: true,
};
const rabbit = Object.create(animal);
rabbit.jumps = true;
原型关系可以表示为:
rabbit
↓ [[Prototype]]
animal
↓ [[Prototype]]
Object.prototype
↓ [[Prototype]]
null
读取属性时,运行时先检查对象自身,再沿原型链向上查找:
console.log(rabbit.jumps); // true
console.log(rabbit.eats); // true
检查属性来自自身还是原型:
console.log(
Object.hasOwn(rabbit, "jumps"),
); // true
console.log(
Object.hasOwn(rabbit, "eats"),
); // false
6.2.2 获取原型
console.log(
Object.getPrototypeOf(rabbit)
=== animal,
); // true
应优先使用 Object.getPrototypeOf(),而不是依赖历史访问器 __proto__。
6.2.3 设置原型
创建对象时直接指定原型:
const rabbit = Object.create(
animal,
);
也可以修改已有对象的原型:
Object.setPrototypeOf(
rabbit,
anotherAnimal,
);
但运行时修改原型会改变属性查找结构,并可能使引擎已有优化失效。对象的原型关系通常应在创建时确定。
6.2.4 prototype 属性与 [[Prototype]] 的区别
函数对象的 prototype 是一个普通属性:
function User() {}
console.log(
typeof User.prototype,
); // object
对象的 [[Prototype]] 是规范内部关系:
const user = new User();
console.log(
Object.getPrototypeOf(user)
=== User.prototype,
); // true
两者不能混为一谈:
User.prototype是构造器上的普通属性;user.[[Prototype]]是实例的内部原型关系;- 执行
new User()时,通常会使用User.prototype建立新对象的原型。
函数对象自身也有自己的 [[Prototype]]:
console.log(
Object.getPrototypeOf(User)
=== Function.prototype,
); // true
因此,一个类或构造函数同时参与两类关系:
- 实例通过
Constructor.prototype继承实例方法; - 构造函数对象自身也位于函数对象的原型链中。
6.2.5 原型方法中的 this
const animal = {
describe() {
return `name: ${this.name}`;
},
};
const rabbit = Object.create(animal);
rabbit.name = "Rabbit";
console.log(
rabbit.describe(),
); // name: Rabbit
方法在原型 animal 上找到,但调用表达式是 rabbit.describe(),因此普通方法内部的 this 是 rabbit。
原型决定从哪里找到方法,调用形式决定普通函数中的 this。二者是不同问题。
6.3 构造函数
在类语法出现之前,JavaScript 常使用可构造函数和原型建立同类对象。
6.3.1 基本形式
function User(name) {
this.name = name;
}
User.prototype.greet = function () {
return `Hello, ${this.name}!`;
};
const alice = new User("Alice");
const bob = new User("Bob");
console.log(alice.greet());
console.log(bob.greet());
约定上,可构造函数名称使用大驼峰,以提示调用者需要使用 new。
6.3.2 [[Call]] 与 [[Construct]]
ECMAScript 区分两个内部能力:
[[Call]]:作为普通函数调用;[[Construct]]:通过new或其他构造语义创建对象。
许多普通函数同时具有二者:
function User(name) {
this.name = name;
}
User("Alice");
new User("Alice");
两种调用形式语义不同。
并不是所有函数都可构造。箭头函数、对象方法简写、异步函数和生成器函数等不具有普通构造能力:
const createUser = name => ({
name,
});
// TypeError
// new createUser("Alice");
是否可以通过 new 调用,取决于函数对象是否具有 [[Construct]],而不是仅看 typeof value === "function"。
6.3.3 new 的基本过程
对于普通基类构造函数,下面的代码:
const user = new User("Alice");
可以从可观察语义上概括为:
- 创建一个新对象;
- 使用构造器的
prototype属性确定新对象的原型; - 以新对象作为
this调用构造函数; - 根据构造函数的显式返回值决定最终结果。
用于教学的近似实现:
function construct(
Constructor,
...args
) {
const prototype = (
typeof Constructor.prototype
=== "object"
&& Constructor.prototype !== null
)
? Constructor.prototype
: Object.prototype;
const instance = Object.create(
prototype,
);
const result = Constructor.apply(
instance,
args,
);
const returnsObject = (
result !== null
&& (
typeof result === "object"
|| typeof result === "function"
)
);
return returnsObject
? result
: instance;
}
这段代码只用于说明普通构造函数的主要行为,不能完整替代规范的 [[Construct]] 过程,也不能正确模拟所有内置构造器、代理、类和派生构造器。
6.3.4 prototype 不是对象模板
构造函数的 prototype 对象主要用于实例继承共享行为:
function Point(
x,
y,
) {
this.x = x;
this.y = y;
}
Point.prototype.distanceFromOrigin
= function () {
return Math.hypot(
this.x,
this.y,
);
};
实例字段分别属于每个实例:
const first = new Point(3, 4);
const second = new Point(5, 12);
console.log(
Object.hasOwn(first, "x"),
); // true
方法由原型共享:
console.log(
Object.hasOwn(
first,
"distanceFromOrigin",
),
); // false
console.log(
first.distanceFromOrigin
=== second.distanceFromOrigin,
); // true
原型对象不是把全部属性复制给实例的“模板”。实例通过原型链动态查找共享属性。
6.3.5 constructor 属性
普通函数创建时,其初始 prototype 对象通常具有指回函数的 constructor 属性:
function User() {}
console.log(
User.prototype.constructor
=== User,
); // true
如果整体替换 prototype,该默认属性会丢失:
User.prototype = {
greet() {
return "Hello";
},
};
console.log(
User.prototype.constructor
=== User,
); // false
可以手动恢复:
Object.defineProperty(
User.prototype,
"constructor",
{
value: User,
writable: true,
configurable: true,
},
);
不过,constructor 是普通可变属性,不应作为可靠的安全类型标记。
6.3.6 构造函数的返回值
普通基类构造函数没有显式返回对象时,new 返回创建的实例:
function User(name) {
this.name = name;
}
const user = new User("Alice");
显式返回原始值通常被忽略:
function User(name) {
this.name = name;
return 42;
}
console.log(
new User("Alice").name,
); // Alice
显式返回对象会替代默认实例:
function User(name) {
this.name = name;
return {
name: "Replacement",
};
}
console.log(
new User("Alice").name,
); // Replacement
这种写法会使原型关系和实例初始化变得难以推理,普通构造函数通常不应返回替代对象。
6.3.7 new.target
在函数或构造器内部,new.target 表示当前构造调用所使用的构造器;普通调用时通常为 undefined:
function User(name) {
if (new.target === undefined) {
throw new TypeError(
"User 必须通过 new 调用",
);
}
this.name = name;
}
类构造器已经由语言强制要求构造调用,不需要用这种方式重复检查。
6.4 class 基础语法
6.4.1 类声明
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, ${this.name}!`;
}
}
创建实例:
const user = new User("Alice");
console.log(user.greet());
6.4.2 类是函数对象
console.log(
typeof User,
); // function
console.log(
User.prototype.constructor
=== User,
); // true
类值可以像函数一样赋给变量、传递和返回:
const UserClass = User;
const instance = new UserClass(
"Alice",
);
不过,类构造器不能作为普通函数调用:
// TypeError
// User("Alice");
这不是普通严格模式函数调用的 this 问题,而是类构造器本身不允许通过 [[Call]] 方式执行。
6.4.3 类声明具有暂时性死区
// ReferenceError
// const user = new User();
class User {}
类声明的绑定在声明执行前处于未初始化状态。不能像函数声明一样在源码中的定义位置之前正常使用。
6.4.4 类表达式
匿名类表达式:
const User = class {
constructor(name) {
this.name = name;
}
};
具名类表达式:
const User = class InternalUser {
static getInternalName() {
return InternalUser.name;
}
};
console.log(
User.getInternalName(),
); // InternalUser
// ReferenceError
// console.log(InternalUser);
内部类名只在类体内部可见,类似具名函数表达式。
6.4.5 类体处于严格模式
类体中的构造器、方法、字段初始化器和静态块都使用严格模式语义。
class Example {
method() {
return this;
}
}
const method = (
new Example()
).method;
console.log(method()); // undefined
把方法提取后独立调用,this 不会自动变为全局对象。
6.4.6 类方法默认不可枚举
class User {
greet() {
return "Hello";
}
}
console.log(
Object.keys(User.prototype),
); // []
方法仍然存在:
console.log(
typeof User.prototype.greet,
); // function
查看描述符:
console.log(
Object.getOwnPropertyDescriptor(
User.prototype,
"greet",
),
);
类方法的 enumerable 为 false。这与直接使用普通赋值添加原型方法不同:
function LegacyUser() {}
LegacyUser.prototype.greet
= function () {
return "Hello";
};
console.log(
Object.keys(
LegacyUser.prototype,
),
); // ["greet"]
如果希望手写构造函数更接近类方法行为,可以使用 Object.defineProperty()。
6.5 实例方法与实例字段
6.5.1 原型方法
类体中的普通实例方法定义在 Class.prototype 上,由实例共享:
class Point {
constructor(
x,
y,
) {
this.x = x;
this.y = y;
}
distanceFromOrigin() {
return Math.hypot(
this.x,
this.y,
);
}
}
const first = new Point(3, 4);
const second = new Point(5, 12);
console.log(
first.distanceFromOrigin
=== second.distanceFromOrigin,
); // true
原型方法适合不需要每个实例保存独立函数对象的行为。
6.5.2 公有实例字段
class Point {
x = 0;
y = 0;
}
每个实例都会获得自己的字段:
const first = new Point();
const second = new Point();
first.x = 10;
console.log(first.x); // 10
console.log(second.x); // 0
公有字段是实例自有属性:
console.log(
Object.hasOwn(first, "x"),
); // true
默认情况下,公有字段创建的属性可写、可枚举、可配置。
6.5.3 字段初始化顺序
字段初始化器按照类体中的声明顺序执行:
class Example {
first = 10;
second = this.first + 5;
}
console.log(
new Example().second,
); // 15
后面的字段可以读取前面已经初始化的字段。前面的字段不能依赖尚未初始化的后续字段:
class Example {
first = this.second;
second = 20;
}
const example = new Example();
console.log(
example.first,
); // undefined
对于基类,实例字段在构造器函数体执行前初始化:
class Example {
value = 10;
constructor() {
console.log(this.value); // 10
}
}
对于派生类,派生类字段在 super() 返回并建立 this 后初始化,然后才继续执行派生构造器中 super() 后的语句:
class Parent {
constructor() {
console.log(
"parent constructor",
);
}
}
class Child extends Parent {
value = 10;
constructor() {
super();
console.log(this.value); // 10
}
}
父类字段属于父类的初始化过程,派生类字段属于派生类的初始化过程。
6.5.4 字段初始化器中的 this
字段初始化器中的 this 指向当前实例:
class User {
name = "Alice";
greeting = `Hello, ${this.name}!`;
}
字段初始化器不能使用构造器参数,除非参数值已经通过其他方式可访问。依赖构造参数的状态通常在构造器中设置:
class User {
name;
constructor(name) {
this.name = name;
}
}
6.5.5 箭头函数类字段
class Button {
label = "Save";
handleClick = () => {
console.log(this.label);
};
}
每次创建实例时都会创建一个新的箭头函数,并把它保存为实例自有属性:
const first = new Button();
const second = new Button();
console.log(
first.handleClick
=== second.handleClick,
); // false
箭头函数继承字段初始化环境中的 this,因此提取后仍能访问原实例:
const handler = first.handleClick;
handler(); // Save
与原型方法相比,它的优点是:
- 传递为回调时不容易丢失
this; - 每个实例可以拥有独立函数状态。
代价是:
- 每个实例创建独立函数;
- 方法不位于原型上;
- 子类不能通过普通
super.handleClick()调用该字段; - 覆盖和测试方式与原型方法不同。
默认应使用原型方法。只有在明确需要词法绑定 this,并且实例数量和内存成本可接受时,才使用箭头函数字段。
6.5.6 绑定原型方法
另一种方式是在构造器中绑定原型方法:
class Button {
constructor(label) {
this.label = label;
this.handleClick
= this.handleClick.bind(this);
}
handleClick() {
console.log(this.label);
}
}
这同样会在实例上创建绑定函数,但原始方法仍然定义在原型上。
在 DOM 事件监听中,保存稳定函数引用也有助于正确移除监听器:
class ButtonController {
constructor(element) {
this.element = element;
this.handleClick
= this.handleClick.bind(this);
}
start() {
this.element.addEventListener(
"click",
this.handleClick,
);
}
stop() {
this.element.removeEventListener(
"click",
this.handleClick,
);
}
handleClick() {
console.log("clicked");
}
}
6.6 getter 与 setter
第五章已经介绍普通对象的访问器属性。类中的 getter 和 setter 默认定义在原型上。
6.6.1 getter
class Rectangle {
constructor(
width,
height,
) {
this.width = width;
this.height = height;
}
get area() {
return (
this.width
* this.height
);
}
}
const rectangle = new Rectangle(
4,
5,
);
console.log(rectangle.area); // 20
调用者以属性形式读取 area,但每次读取都会执行 getter。
getter 不应接受参数。需要参数时,应使用普通方法。
6.6.2 setter
class User {
#age = 0;
get age() {
return this.#age;
}
set age(value) {
if (
!Number.isInteger(value)
|| value < 0
|| value > 150
) {
throw new RangeError(
"age 必须是 0 到 150 之间的整数",
);
}
this.#age = value;
}
}
const user = new User();
user.age = 30;
console.log(user.age); // 30
setter 必须恰好接收一个参数。
6.6.3 只读外观
只定义 getter,不定义 setter,可以为调用者提供只读外观:
class Circle {
#radius;
constructor(radius) {
this.#radius = radius;
}
get radius() {
return this.#radius;
}
}
严格模式下给只读访问器赋值会抛出 TypeError:
const circle = new Circle(5);
// TypeError
// circle.radius = 10;
这并不表示整个对象不可变。其他公开字段和方法仍可能改变内部状态。
6.6.4 getter 与方法的选择
使用 getter:
- 读取成本较低;
- 结果表现为对象当前状态的派生属性;
- 不需要参数;
- 调用不会产生令人意外的副作用。
使用方法:
- 操作可能较昂贵;
- 需要参数;
- 可能执行 I/O;
- 会显著改变状态;
- 调用失败具有业务意义。
例如,account.balance 适合作为 getter,而 account.transferTo(target, amount) 应是方法。
6.7 私有元素
JavaScript 类使用 # 声明私有字段、方法和访问器。
6.7.1 私有字段
class BankAccount {
#balance = 0;
deposit(amount) {
this.#balance += amount;
}
get balance() {
return this.#balance;
}
}
私有名称必须先在类体中声明:
class Example {
#value;
}
不能在运行时动态创建新的私有字段:
class Example {
method() {
// SyntaxError:
// #value 未在当前类中声明。
// this.#value = 10;
}
}
6.7.2 私有元素不是普通属性
const account = new BankAccount();
console.log(
Object.keys(account),
); // []
console.log(
Reflect.ownKeys(account),
); // []
私有字段不属于普通属性键集合,不能通过字符串、Symbol 或反射 API 访问:
console.log(
account["#balance"],
); // undefined
给 account["#balance"] 赋值只会创建一个名称为 "#balance" 的普通公开属性,与真正私有字段无关。
6.7.3 类外访问是语法错误
// SyntaxError
// console.log(account.#balance);
这是语法层面的限制,而不是返回 undefined 的普通属性查找失败。
部分开发者工具可能为了调试而放宽交互式控制台限制,但这不属于普通 JavaScript 程序的语言行为。
6.7.4 同一类可以访问其他实例的私有字段
class Point {
#x;
constructor(x) {
this.#x = x;
}
distanceTo(other) {
return Math.abs(
this.#x - other.#x,
);
}
}
const first = new Point(3);
const second = new Point(10);
console.log(
first.distanceTo(second),
); // 7
私有访问权属于类体,而不是只属于当前 this。
如果 other 不是具有相同私有品牌的对象,访问会抛出错误。
6.7.5 私有品牌检查
class Point {
#x;
constructor(x) {
this.#x = x;
}
static isPoint(value) {
return (
value !== null
&& (
typeof value === "object"
|| typeof value === "function"
)
&& #x in value
);
}
}
使用:
console.log(
Point.isPoint(
new Point(1),
),
); // true
console.log(
Point.isPoint({
x: 1,
}),
); // false
#x in value 检查的是特定私有名称对应的品牌,而不是检查普通属性名。
右侧必须是对象,否则会抛出 TypeError,因此示例先进行了对象检查。
6.7.6 私有方法
class User {
#normalizeName(name) {
return name.trim();
}
constructor(name) {
this.name = this.#normalizeName(
name,
);
}
}
私有方法适合只供类内部使用的行为。
6.7.7 私有访问器
class Counter {
#value = 0;
get #normalizedValue() {
return Math.max(
0,
this.#value,
);
}
get value() {
return this.#normalizedValue;
}
}
6.7.8 子类不能访问父类私有元素
class Parent {
#value = 10;
getValue() {
return this.#value;
}
}
class Child extends Parent {
readValue() {
// SyntaxError
// return this.#value;
}
}
父类私有元素只在声明它们的类体中可见。它们不是“受保护成员”,也不会因为继承关系自动对派生类开放。
子类应通过父类公开或受控接口访问相关状态:
class Parent {
#value = 10;
get value() {
return this.#value;
}
}
class Child extends Parent {
doubleValue() {
return this.value * 2;
}
}
JavaScript 没有原生 protected 关键字。以下划线开头的属性只是团队约定,不具有语言级访问限制:
class Parent {
_value = 10;
}
6.7.9 私有字段与 Proxy
私有元素依赖对象品牌,而普通 Proxy 不会自动把私有访问转发到目标对象:
class Counter {
#value = 0;
increment() {
this.#value += 1;
}
}
const counter = new Counter();
const proxy = new Proxy(
counter,
{},
);
// TypeError:this 是 proxy,
// proxy 不具有 Counter 的私有品牌。
// proxy.increment();
可以在代理中绑定方法到目标对象,但这会改变一般方法和访问器语义,必须谨慎设计。
私有元素适合封装类内部状态,但与透明代理、对象序列化和部分元编程技术并不总能直接组合。
6.8 静态成员
静态成员属于类构造器本身,而不属于实例。
6.8.1 静态方法
class Point {
constructor(
x,
y,
) {
this.x = x;
this.y = y;
}
static distance(
first,
second,
) {
return Math.hypot(
first.x - second.x,
first.y - second.y,
);
}
}
const first = new Point(0, 0);
const second = new Point(3, 4);
console.log(
Point.distance(
first,
second,
),
); // 5
实例不能直接访问静态方法:
console.log(
first.distance,
); // undefined
静态方法适合:
- 工厂方法;
- 类型判断;
- 与该类相关的工具函数;
- 多个实例之间的比较;
- 缓存和注册管理。
6.8.2 静态字段
class User {
static count = 0;
constructor(name) {
this.name = name;
User.count += 1;
}
}
new User("Alice");
new User("Bob");
console.log(User.count); // 2
如果希望派生类分别计数,使用 this 还是基类名会产生不同语义,需要明确设计。
6.8.3 静态 this
静态方法中的 this 是调用该方法的类对象:
class User {
static label = "User";
static describe() {
return this.label;
}
}
console.log(
User.describe(),
); // User
派生类继承静态方法时:
class Administrator extends User {
static label = "Administrator";
}
console.log(
Administrator.describe(),
); // Administrator
这里 this 是 Administrator。
6.8.4 静态私有元素
class Identifier {
static #nextValue = 1;
static create() {
const value = Identifier.#nextValue;
Identifier.#nextValue += 1;
return value;
}
}
使用类名访问意味着方法固定操作 Identifier 的私有静态状态。
如果写成:
class Identifier {
static #nextValue = 1;
static create() {
const value = this.#nextValue;
this.#nextValue += 1;
return value;
}
}
直接调用 Identifier.create() 可以正常工作,但继承调用可能失败:
class ChildIdentifier
extends Identifier {}
// TypeError:ChildIdentifier
// 不具有 Identifier 的私有静态品牌。
// ChildIdentifier.create();
因此:
- 静态方法需要对派生类多态时,可以使用公开静态字段和
this; - 静态私有状态只属于声明类时,通常使用声明类名访问更加明确。
6.8.5 静态初始化块
静态块在类定义求值期间按声明顺序执行,可访问类的私有静态元素:
class Configuration {
static #settings;
static {
const environment = (
globalThis.APP_ENV
?? "development"
);
Configuration.#settings = {
environment,
};
}
static get settings() {
return {
...Configuration.#settings,
};
}
}
静态块适合:
- 多语句静态初始化;
try...catch;- 条件分支;
- 使用临时变量;
- 初始化私有静态状态。
静态块同步执行,不能直接使用 await。需要异步初始化时,应提供显式异步工厂或初始化方法,而不是在类定义阶段隐藏异步过程。
6.8.6 静态成员的初始化顺序
类元素按照声明顺序求值和初始化。静态字段和静态块可以交错:
class Example {
static first = 1;
static {
console.log(
Example.first,
); // 1
}
static second = (
Example.first + 1
);
}
依赖尚未初始化的后续静态字段可能得到 undefined 或产生错误,因此初始化顺序应保持清晰。
6.9 继承
6.9.1 extends
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return (
`${this.name} makes a sound.`
);
}
}
class Dog extends Animal {
speak() {
return `${this.name} barks.`;
}
}
实例:
const dog = new Dog("Max");
console.log(dog.speak());
// Max barks.
extends 后的表达式必须求值得到可作为父类的构造器或 null。最常见写法是直接使用父类标识符。
6.9.2 两条原型关系
console.log(
Object.getPrototypeOf(
Dog.prototype,
) === Animal.prototype,
); // true
这条关系使 Dog 实例可以继承 Animal 的实例方法。
console.log(
Object.getPrototypeOf(Dog)
=== Animal,
); // true
这条关系使 Dog 类可以继承 Animal 的静态成员。
实例链:
dog
↓
Dog.prototype
↓
Animal.prototype
↓
Object.prototype
↓
null
类对象链:
Dog
↓
Animal
↓
Function.prototype
↓
Object.prototype
↓
null
6.9.3 默认构造器
没有显式构造器的基类,行为近似:
class Base {
constructor() {}
}
没有显式构造器的派生类,行为近似:
class Derived extends Base {
constructor(...args) {
super(...args);
}
}
因此,简单派生类可以省略只负责转发参数的构造器:
class Dog extends Animal {
speak() {
return `${this.name} barks.`;
}
}
6.9.4 派生构造器与 super()
派生类显式定义构造器时,通常必须在访问 this 之前调用 super():
class Dog extends Animal {
constructor(
name,
breed,
) {
super(name);
this.breed = breed;
}
}
以下写法会抛出 ReferenceError:
class Dog extends Animal {
constructor(name) {
// ReferenceError
// this.name = name;
super(name);
}
}
基类构造器在构造过程中建立并初始化实例,super() 返回后,派生构造器中的 this 才可用。
一种少见的边界情况是,派生构造器可以显式返回另一个对象而不使用 this,从而不调用 super():
class Replacement
extends Animal {
constructor() {
return {
replacement: true,
};
}
}
console.log(
new Replacement(),
);
// { replacement: true }
这种写法会绕过正常实例和原型建模,通常不应在普通业务代码中使用。
6.9.5 super() 的实际目标
在派生构造器中,super() 调用父构造器,但实际使用的 new.target 仍然是最初被 new 调用的派生类。
class Parent {
constructor() {
console.log(
new.target.name,
);
}
}
class Child extends Parent {}
new Child(); // Child
这使父构造器能够为最终派生类型参与构造过程,而不只是创建一个固定的 Parent 实例。
6.9.6 方法重写
class Animal {
speak() {
return "sound";
}
}
class Dog extends Animal {
speak() {
return "bark";
}
}
读取 dog.speak 时,先在 Dog.prototype 找到方法,因此不会继续使用 Animal.prototype.speak。
6.9.7 super.method()
子类可以扩展父类方法:
class Animal {
speak() {
return (
`${this.name} makes a sound`
);
}
}
class Dog extends Animal {
speak() {
return (
`${super.speak()} and barks`
);
}
}
调用:
const dog = new Dog();
dog.name = "Max";
console.log(dog.speak());
// Max makes a sound and barks
super.speak() 从父级原型取得方法,但调用时的 this 仍然是当前实例 dog。
6.9.8 [[HomeObject]]
使用方法定义语法创建的方法可以具有规范内部槽 [[HomeObject]]。super 通过方法定义时的所属对象确定父级查找起点。
const parent = {
greet() {
return "parent";
},
};
const child = {
__proto__: parent,
greet() {
return (
`${super.greet()} child`
);
},
};
即使把方法赋给另一个对象,super 的查找起点仍由原方法的 [[HomeObject]] 决定:
const another = {
greet: child.greet,
};
console.log(
another.greet(),
);
// parent child
这里普通方法内部的 this 是 another,但 super 的父级查找起点仍与 child 的方法定义相关。
[[HomeObject]] 是规范内部槽,不能作为普通属性访问或修改。
6.9.9 super 不是普通变量
不能把 super 保存到变量中,也不能在任意函数中使用。它只允许出现在具有相应语法环境的方法或派生构造器中。
箭头函数没有自己的 super 绑定,但可以在合法方法内部词法使用外层的 super:
class Child extends Parent {
method() {
const action = () => {
return super.method();
};
return action();
}
}
6.9.10 继承静态成员
class Animal {
static category = "animal";
static describeCategory() {
return this.category;
}
}
class Dog extends Animal {
static category = "dog";
}
console.log(
Dog.describeCategory(),
); // dog
静态成员通过类对象原型链继承。
父类私有静态元素不对派生类开放,相关边界见前文。
6.10 instanceof 与类型判断
6.10.1 基本语义
class Animal {}
class Dog extends Animal {}
const dog = new Dog();
console.log(
dog instanceof Dog,
); // true
console.log(
dog instanceof Animal,
); // true
console.log(
dog instanceof Object,
); // true
普通情况下,instanceof 检查右侧构造器的 prototype 是否出现在左侧对象的原型链中。
6.10.2 instanceof 不等同于“由它创建”
function User() {}
const object = Object.create(
User.prototype,
);
console.log(
object instanceof User,
); // true
该对象并没有执行 new User(),但其原型链包含 User.prototype。
反过来,修改原型也可能改变结果:
function User() {}
const user = new User();
console.log(
user instanceof User,
); // true
User.prototype = {};
console.log(
user instanceof User,
); // false
因此,instanceof 描述当前原型链关系,不是不可变的创建历史记录。
6.10.3 Symbol.hasInstance
类或函数可以自定义 instanceof:
class PositiveNumber {
static [
Symbol.hasInstance
](value) {
return (
typeof value === "number"
&& value > 0
);
}
}
console.log(
10 instanceof PositiveNumber,
); // true
自定义后,instanceof 可以完全不依赖普通原型链,因此不能把它理解为始终执行固定地址比较。
6.10.4 跨 Realm 问题
不同 iframe、Window 或其他 Realm 具有不同的内置构造器和原型对象。
来自另一个 Realm 的数组可能无法通过当前 Realm 的 Array 检查:
// arrayFromAnotherRealm
// instanceof Array
// 可能为 false。
检测数组应使用:
Array.isArray(value);
对于日期、错误对象和自定义类,跨 Realm 类型判断也需要根据接口设计选择更可靠的品牌、结构验证或序列化边界。
6.10.5 isPrototypeOf
已知原型对象时,可以直接检查:
console.log(
Animal.prototype.isPrototypeOf(
dog,
),
); // true
它与普通 instanceof 的关注点相近,但左侧直接是原型对象,不经过构造器和 Symbol.hasInstance。
6.10.6 私有品牌作为类型检查
class User {
#id;
constructor(id) {
this.#id = id;
}
static isUser(value) {
return (
value !== null
&& (
typeof value === "object"
|| typeof value === "function"
)
&& #id in value
);
}
}
这种检查确认对象具有当前类的私有品牌,比单纯原型链更难被普通对象伪造。
它也具有局限:
- 只适用于拥有私有元素的类;
- 不能跨不共享类定义的 Realm 或重复模块实例;
- 需要由类主动暴露判断方法;
- 类型兼容仍不等于业务数据有效。
6.10.7 鸭子类型
很多函数只需要对象提供特定行为:
function printArea(shape) {
if (
shape === null
|| typeof shape.area
!== "function"
) {
throw new TypeError(
"shape 必须提供 area() 方法",
);
}
console.log(shape.area());
}
调用者可以传入不同类的实例,也可以传入普通对象:
printArea({
area() {
return 10;
},
});
这比强制要求某个 instanceof 关系更灵活,也更符合 JavaScript 的动态类型特点。
6.11 多态与方法分派
6.11.1 基于原型的方法分派
class Shape {
area() {
throw new Error(
"Subclass must implement area()",
);
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
area() {
return (
Math.PI
* this.radius ** 2
);
}
}
class Rectangle extends Shape {
constructor(
width,
height,
) {
super();
this.width = width;
this.height = height;
}
area() {
return (
this.width
* this.height
);
}
}
统一使用:
const shapes = [
new Circle(2),
new Rectangle(3, 4),
];
const areas = shapes.map(
shape => shape.area(),
);
每个对象根据自身原型链找到相应实现。
6.11.2 JavaScript 没有抽象类关键字
上面的 Shape 只是一种约定。JavaScript 没有原生 abstract class 语法。
可以阻止直接创建基类:
class Shape {
constructor() {
if (new.target === Shape) {
throw new TypeError(
"Shape 不能直接实例化",
);
}
}
area() {
throw new Error(
"area() 必须由子类实现",
);
}
}
这是一种运行时检查,而不是静态类型系统中的抽象类约束。
如果项目使用 TypeScript,可以在类型层提供接口和抽象类检查,但那属于 TypeScript,而不是 JavaScript 本身。
6.11.3 不应在基类构造器调用可重写方法
class Base {
constructor() {
this.initialize();
}
initialize() {}
}
class Derived extends Base {
value = 10;
initialize() {
console.log(this.value);
}
}
创建 Derived 时,基类构造器调用派生类重写后的 initialize()。此时派生类字段尚未完成初始化,因此 this.value 可能是 undefined。
new Derived();
// undefined
基类构造器应只处理基类自身能够保证已经建立的状态。需要派生初始化时,可以:
- 在派生构造器中显式调用初始化方法;
- 使用工厂函数;
- 把依赖作为构造参数;
- 避免构造期间的动态分派。
6.11.4 不变量与方法边界
类的价值不只是把函数放进对象,而是使状态始终满足明确条件。
class Temperature {
#celsius;
constructor(celsius) {
this.#setCelsius(celsius);
}
#setCelsius(value) {
if (
typeof value !== "number"
|| !Number.isFinite(value)
) {
throw new TypeError(
"temperature 必须是有限数值",
);
}
if (value < -273.15) {
throw new RangeError(
"温度不能低于绝对零度",
);
}
this.#celsius = value;
}
get celsius() {
return this.#celsius;
}
get kelvin() {
return (
this.#celsius
+ 273.15
);
}
}
无论通过哪个公开入口创建或修改对象,都应维护同一不变量。
6.12 继承与组合
6.12.1 继承适合稳定的 is-a 关系
class Employee {}
class Manager extends Employee {}
如果所有 Manager 都应当作为 Employee 使用,并且父类接口稳定,这种关系可能适合继承。
6.12.2 组合适合 has-a 关系
class Logger {
log(message) {
console.log(message);
}
}
class UserService {
constructor(logger) {
this.logger = logger;
}
createUser(name) {
this.logger.log(
`Creating ${name}`,
);
}
}
UserService 拥有 Logger,而不是 Logger 的一种。
组合的优点包括:
- 依赖可以替换;
- 更容易测试;
- 不受单继承限制;
- 各组件职责更清晰;
- 不需要暴露完整父类接口。
6.12.3 优先组合并非禁止继承
“组合优于继承”是一条设计倾向,不是绝对规则。
继承适合:
- 明确且稳定的抽象层次;
- 派生对象能够完整替代父类对象;
- 需要复用受控的公开协议;
- 框架明确要求继承;
- 内置类型扩展具有清晰目的。
组合适合:
- 只需要复用部分能力;
- 行为需要运行时替换;
- 不同能力需要自由组合;
- 父类变化可能影响大量子类;
- 关系更接近“拥有”而不是“属于”。
6.12.4 工厂函数
不需要继承、私有字段和 instanceof 时,工厂函数可能更简单:
function createCounter(
initialValue = 0,
) {
let value = initialValue;
return {
increment() {
value += 1;
},
getValue() {
return value;
},
};
}
它使用闭包封装状态。第二章已经介绍这一模式。
类与工厂函数并无普遍优劣,应根据接口、实例数量、继承需求、序列化和测试方式选择。
6.12.5 Mixins
JavaScript 类只支持单继承。需要组合多个行为时,可以使用函数把方法复制或定义到类原型上:
const canSerialize = {
toJSON() {
return {
...this,
};
},
};
class User {
constructor(name) {
this.name = name;
}
}
Object.assign(
User.prototype,
canSerialize,
);
这种简单 mixin 只复制可枚举自有属性,并可能丢失描述符或发生命名冲突。
保留描述符:
Object.defineProperties(
User.prototype,
Object.getOwnPropertyDescriptors(
canSerialize,
),
);
仍需处理:
constructor;- Symbol 属性;
- 方法冲突;
- 私有元素无法复制;
super的[[HomeObject]]语义;- 类型判断;
- 初始化状态。
对于复杂系统,显式组合对象通常比大规模 mixin 更容易维护。
6.13 类与内置对象
6.13.1 内置类也使用原型方法
const values = [
1,
2,
3,
];
console.log(
Object.getPrototypeOf(values)
=== Array.prototype,
); // true
数组实例通过原型链使用 map()、filter() 等方法。
6.13.2 扩展内置类
class NumberList extends Array {
average() {
if (this.length === 0) {
return null;
}
const total = this.reduce(
(
sum,
value,
) => sum + value,
0,
);
return total / this.length;
}
}
const values = new NumberList(
1,
2,
3,
);
console.log(
values.average(),
); // 2
扩展内置类虽然可行,但应谨慎评估:
- 内置方法返回值是否继续使用子类;
- 序列化和跨 Realm 行为;
- 第三方代码是否预期普通内置实例;
- 子类构造参数语义;
- 是否真的需要继承全部内置接口。
很多场景下,包装一个普通数组比继承 Array 更容易控制。
6.13.3 Symbol.species
某些内置派生对象的方法会使用构造器相关协议决定返回对象类型。Symbol.species 曾被用于自定义派生结果构造器。
现代代码通常不应主动依赖这一机制,因为它增加优化和推理成本,而且新标准方法逐渐避免使用 species 模式。除非维护已有类库,否则不需要把它作为一般 OOP 工具。
6.14 完整示例:图形对象模型
下面的示例综合使用:
- 抽象基类约定;
- 私有字段;
- getter;
- 静态工厂;
- 继承;
- 方法重写;
super;- 多态;
- 组合式格式化。
class Shape {
#name;
constructor(name) {
if (new.target === Shape) {
throw new TypeError(
"Shape 不能直接实例化",
);
}
this.#name = name;
}
get name() {
return this.#name;
}
area() {
throw new Error(
"area() 必须由子类实现",
);
}
describe() {
return (
`${this.name}: `
+ `area = ${this.area()}`
);
}
static isShape(value) {
return value instanceof Shape;
}
}
class Circle extends Shape {
#radius;
constructor(radius) {
super("Circle");
if (
typeof radius !== "number"
|| !Number.isFinite(radius)
|| radius <= 0
) {
throw new RangeError(
"radius 必须是正的有限数值",
);
}
this.#radius = radius;
}
get radius() {
return this.#radius;
}
area() {
return (
Math.PI
* this.#radius ** 2
);
}
describe() {
return (
`${super.describe()}, `
+ `radius = ${this.#radius}`
);
}
static fromDiameter(diameter) {
return new this(
diameter / 2,
);
}
}
class Rectangle extends Shape {
#width;
#height;
constructor(
width,
height,
) {
super("Rectangle");
if (
typeof width !== "number"
|| !Number.isFinite(width)
|| width <= 0
) {
throw new RangeError(
"width 必须是正的有限数值",
);
}
if (
typeof height !== "number"
|| !Number.isFinite(height)
|| height <= 0
) {
throw new RangeError(
"height 必须是正的有限数值",
);
}
this.#width = width;
this.#height = height;
}
get width() {
return this.#width;
}
get height() {
return this.#height;
}
area() {
return (
this.#width
* this.#height
);
}
}
使用:
const shapes = [
Circle.fromDiameter(10),
new Rectangle(4, 5),
];
for (const shape of shapes) {
console.log(
shape.describe(),
);
}
统一处理总面积:
const totalArea = shapes.reduce(
(
total,
shape,
) => {
return total + shape.area();
},
0,
);
console.log(totalArea);
这个模型仍然可以进一步改进。例如,重复的数值验证可以提取为普通函数,而不必通过继承复用:
function assertPositiveFinite(
value,
name,
) {
if (
typeof value !== "number"
|| !Number.isFinite(value)
|| value <= 0
) {
throw new RangeError(
`${name} 必须是正的有限数值`,
);
}
}
这体现了继承与组合可以同时使用:类型层次通过继承表达,独立验证逻辑通过函数组合复用。
6.15 常见设计问题
6.15.1 把所有数据都包装成类
简单记录不一定需要类:
const user = {
id: 1,
name: "Alice",
};
如果对象主要用于 JSON 传输、配置或数据库记录,普通对象通常更加直接。
类更适合:
- 需要维护不变量;
- 具有较多行为;
- 实例长期存在;
- 需要原型方法共享;
- 需要私有状态;
- 存在明确继承层次。
6.15.2 只为了复用代码而继承
class Report extends Array {}
如果 Report 只是“内部拥有若干条目”,它未必应该继承 Array。包装通常更明确:
class Report {
#items = [];
add(item) {
this.#items.push(item);
}
get items() {
return [
...this.#items,
];
}
}
6.15.3 过深继承层次
Base
↓
Intermediate
↓
Specialized
↓
VerySpecialized
继承层次过深会增加:
- 方法查找和重写理解成本;
- 父类修改的影响范围;
- 构造顺序复杂度;
super调用依赖;- 隐式耦合。
通常应保持层次浅,并把正交能力拆为组合对象或普通函数。
6.15.4 公开可变内部对象
class Team {
members = [];
}
调用者可以任意修改:
team.members.length = 0;
如果成员列表需要受控,应使用私有字段并返回副本或只读视图:
class Team {
#members = [];
addMember(member) {
this.#members.push(member);
}
get members() {
return [
...this.#members,
];
}
}
返回浅副本只能保护数组结构,不能自动冻结成员对象本身。
6.15.5 在构造器中执行复杂异步工作
构造器不能是 async 函数,也不能返回一个供调用者自然 await 的普通实例初始化过程。
不推荐:
class User {
constructor(id) {
void this.load(id);
}
async load(id) {
// 初始化时机不明确。
}
}
可以使用异步静态工厂:
class User {
constructor(data) {
this.id = data.id;
this.name = data.name;
}
static async load(id) {
const response = await fetch(
`/api/users/${id}`,
);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`,
);
}
const data = await response.json();
return new User(data);
}
}
const user = await User.load(1);
这样,实例返回时已经完成必要初始化。
6.15.6 依赖 constructor.name
console.log(
instance.constructor.name,
);
类名可能因以下因素变化:
- 代码压缩;
- 重命名;
- 原型修改;
- 跨 Realm;
- 构造器属性被覆盖。
它适合调试,不适合作为稳定业务类型标识。需要持久类型标识时,应定义明确字段或协议。
6.15.7 滥用 instanceof
instanceof 适合检查原型关系,但不适合所有输入验证。
从网络或 JSON 得到的数据没有自定义类原型:
const data = JSON.parse(
'{"radius":5}',
);
console.log(
data instanceof Circle,
); // false
外部数据应先验证字段,再显式创建类实例:
const circle = new Circle(
data.radius,
);
6.15.8 用类隐藏全局可变状态
class Registry {
static items = [];
}
静态字段仍然是全局生命周期的共享可变状态,只是被放在类命名空间中。它可能带来:
- 测试互相影响;
- 模块加载顺序依赖;
- 状态难以重置;
- 并发请求之间污染。
应根据需要使用实例化服务、依赖注入或模块级受控接口。
6.16 类、工厂函数与普通对象的选择
| 方式 | 优点 | 适用场景 | 主要限制 |
|---|---|---|---|
| 普通对象 | 简单、直接、易于 JSON 化 | 配置、记录、一次性数据 | 行为复用和封装能力有限 |
| 工厂函数 | 可使用闭包封装,组合灵活 | 小型状态对象、函数式设计 | 每个实例可能创建独立方法 |
| 构造函数与原型 | 兼容旧代码,原型关系明确 | 维护旧式库和底层模式 | 语法分散,私有元素支持不自然 |
| 类 | 结构统一,支持字段、私有元素、继承和静态成员 | 状态丰富、行为较多的对象模型 | 容易被过度使用或形成深继承 |
| 组合对象 | 依赖可替换,职责清晰 | 服务对象、策略、插件能力 | 需要显式设计协作接口 |
选择时可以依次询问:
- 这只是一个数据记录吗?
- 对象是否需要维护不变量?
- 是否需要隐藏内部状态?
- 多个实例是否共享相同行为?
- 是否存在稳定的继承关系?
- 能否用组合表达得更清楚?
- 数据是否需要频繁序列化和跨边界传输?
- 测试时是否需要替换依赖?
6.17 本章小结
本章从原型模型出发,系统介绍了 JavaScript 的面向对象机制:
- JavaScript 对象通过
[[Prototype]]关系形成原型链,属性查找会沿原型链逐级进行; - 构造函数的
prototype属性与实例的[[Prototype]]是不同概念; - 可构造函数具有
[[Construct]]能力,不能根据typeof value === "function"判断其一定可构造; new会创建实例、建立原型关系、以实例作为this调用构造器,并根据显式返回值决定最终结果;- 构造函数原型上的方法由实例共享,实例字段分别属于各实例;
- 类建立在原型继承模型上,但还具有严格模式、暂时性死区、不可直接调用、私有元素等专门语义;
- 类方法定义在原型上且默认不可枚举,公有实例字段是实例自身可写、可枚举、可配置的属性;
- 基类字段在基类构造器函数体前初始化,派生类字段在
super()返回后初始化; - 箭头函数类字段能够词法绑定实例
this,但每个实例都会保存独立函数,并且不能像原型方法一样通过super重用; - getter 和 setter 适合表示派生属性、验证写入和提供受控访问;
- 私有元素不是普通属性,不能通过字符串、Symbol 或反射 API 访问;
- 同一类体可以访问同类其他实例的私有字段,
#field in object可以进行私有品牌检查; - 子类不能直接访问父类私有元素,JavaScript 也没有原生
protected关键字; - 静态成员属于类构造器本身,并可以通过类对象的原型链继承;
- 静态私有元素只属于声明类,继承静态方法中使用
this.#field可能因派生类缺少私有品牌而失败; extends同时建立实例原型链和类对象原型链;- 派生构造器通常必须先调用
super(),随后才能访问this; super.method()从父级取得方法,但方法调用中的this仍然是当前实例;- 方法的
[[HomeObject]]决定super的查找起点,super不是普通变量; instanceof通常检查构造器原型是否位于对象原型链中,但其行为可以被Symbol.hasInstance自定义;instanceof描述当前原型关系,不是可靠的创建历史记录,并且可能受跨 Realm 影响;- JavaScript 多态常通过共享方法接口和鸭子类型实现,不一定要求共同父类;
- 基类构造器不应调用可能被派生类重写的方法,以免访问尚未初始化的派生状态;
- 继承适合稳定的“属于”关系,组合适合可替换的“拥有”关系;
- 普通对象、工厂函数和类各有适用场景,不应把所有数据都包装为类;
- 良好的类设计应围绕不变量、公开接口和依赖边界,而不只是把数据和函数放在同一个语法块中。
参考资料
- javascript.info: Prototypal inheritance
- javascript.info: Constructor, operator new
- javascript.info: Class basic syntax
- javascript.info: Class inheritance
- javascript.info: Static properties and methods
- javascript.info: Private and protected properties and methods
- javascript.info: Class checking with instanceof
- MDN JavaScript Guide: Inheritance and the prototype chain
- MDN JavaScript Guide: Using classes
- MDN JavaScript Reference: Classes
- MDN JavaScript Reference: Private elements
- MDN JavaScript Reference: instanceof
- ECMAScript 2026 Language Specification: ECMAScript Language—Classes
- ECMAScript 2026 Language Specification: Ordinary and Exotic Object Behaviours