类、继承与组合:把状态和行为放对位置
class 把构造过程、原型方法和静态成员放在一起,让对象类型更容易阅读。它仍然使用原型连接,但还带来严格模式、初始化顺序、私有元素等规则;把它理解成“换了个写法的函数”会漏掉真正容易出错的部分。
一份方法,多份状态
class Notebook {
pages = [];
constructor(title) { this.title = title; }
add(text) { this.pages.push(text); }
get size() { return this.pages.length; }
static empty(title) { return new Notebook(title); }
}
const a = Notebook.empty('语法');
const b = Notebook.empty('机制');
a.add('对象通过原型查找方法');
console.log(a.size, b.size); // 1 0
console.log(a.add === b.add); // true
console.log(Object.hasOwn(a, 'pages')); // true字段初始化表达式每创建一个实例就执行一次,所以两个 pages 是不同数组。add 和 getter 安装在 Notebook.prototype 上,类方法默认不可枚举。static empty 安装在构造器 Notebook 上,实例没有 a.empty()。
类声明在初始化之前处于暂时性死区;类体总按严格模式执行;类构造器不能省略 new 调用。普通方法取出来单独调用会失去实例接收者。箭头字段可以固定词法 this,但每个实例都会创建一个函数,且它不是原型方法,不能指望 super.method() 查到它。
extends 同时连接两条链
class Book {
label = '基础';
constructor() { console.log('构造时', this.label); }
describe() { return this.label; }
static category() { return '读物'; }
}
class Manual extends Book {
label = '手册';
constructor() {
super();
console.log('完成后', this.label);
}
describe() { return super.describe() + '版'; }
}
const m = new Manual(); // 构造时 基础;完成后 手册
console.log(m.describe(), Manual.category()); // 手册版 读物Manual.prototype 连接 Book.prototype,让实例继承方法;Manual 连接 Book,让类继承静态成员。super.describe() 从父类原型开始查找,但调用时 this 仍是当前实例。
初始化顺序解释了前两行输出:基类字段先安装,然后运行基类构造器;派生类通过 super() 得到实例,派生类字段随后安装,最后继续派生构造器正文。因此基类构造器调用可被覆盖的方法尤其危险:此时子类的字段可能尚未安装,访问子类私有字段会抛错。
派生构造器通常必须先执行 super() 才能使用 this。技术上可以直接返回另一个对象而不使用 this,但这会绕过正常实例初始化,通常只在理解构造语义时需要知道。
父类构造器为什么已经能调用子类方法,却读不到子类字段
原型连接与实例字段安装发生在不同步骤。构造派生实例时,实例的原型已经能指向派生类原型;因此基类正文通过 this 调用方法,可能找到派生类版本。但是派生字段要等 super 完成后才安装。
class BaseReader {
constructor() { this.inspect(); }
inspect() {}
}
class Reader extends BaseReader {
#ready = true;
inspect() { console.log(this.#ready); }
}
try { new Reader(); }
catch (error) { console.log(error.name); } // TypeError不是 private 禁止继承,也不是 inspect 无法找到,而是方法执行得太早,当前实例还没有 Reader 的私有元素。把 inspect 调用放在 new Reader() 完成以后,状态才已齐备。常见设计是构造器只建立自身不变量,把依赖完整实例的操作放进显式方法;若必须在构造过程中协作,需要非常清楚地约定允许访问哪些状态。
super 固定查找起点,不固定 this
class BaseLabel {
text() { return `基础:${this.name}`; }
}
class Label extends BaseLabel {
text() { return super.text() + '!'; }
}
const unrelated = { name: '外部对象' };
console.log(Label.prototype.text.call(unrelated)); // 基础:外部对象!借用方法后 this 变成 unrelated,但 super 仍从 Label.prototype 的原型开始找。语言在创建方法时记录它的 home object,super 依这个对象找上一层;并不是先看 this 的原型,再临时猜“父类是谁”。静态方法中,home object 是类构造器,所以静态 super 沿构造器那条链查找。记住两条链,就不必给静态继承另背一套规则。
字段定义与赋值并不相同
公开字段使用定义属性的语义。若父类原型上有 set value(v),子类写 value = 3 会直接创建实例字段,不会调用继承的 setter;而构造器里写 this.value = 3 会走赋值流程,可能触发 setter。旧代码经转译后如果采用不同模式,行为也可能不同。
字段从上到下初始化。读取尚未初始化的公开字段通常得到 undefined;读取尚未安装的私有字段会抛错。计算字段名在类定义时计算,字段初始值则在每次实例化时计算。静态字段与静态初始化块按类定义中的顺序执行一次,适合初始化类级状态,不要把实例状态藏进去共享。
用连续日志看到字段的定义语义
const events = [];
class BaseMeter {
base = (events.push('基类字段'), 1);
constructor() { events.push('基类正文'); }
set total(value) { events.push(`setter:${value}`); }
}
class Meter extends BaseMeter {
total = (events.push('子类字段'), 2);
constructor() {
events.push('super之前');
super();
events.push('子类正文');
this.total = 3;
}
}
const meter = new Meter();
console.log(events.join(' → '));
// super之前 → 基类字段 → 基类正文 → 子类字段 → 子类正文
console.log(meter.total); // 3子类字段直接定义 total,没有触发父原型的 setter;后面普通赋值也先找到这个自有数据属性,所以仍不会触发 setter。删掉子类 total 字段,再保留 this.total = 3,才会调用父 setter。可见是否调用 setter 不只取决于“这行用了赋值”,还取决于此前是否已有同名自有属性。
私有字段检查接收者的身份
class Counter {
#value = 0;
increment() { return ++this.#value; }
static owns(obj) { return obj !== null && typeof obj === 'object' && #value in obj; }
}
const counter = new Counter();
console.log(counter.increment()); // 1
console.log(Object.keys(counter)); // []
console.log(Counter.owns(counter), Counter.owns({})); // true false
try { counter.increment.call({}); }
catch (error) { console.log(error.name); } // TypeError#value 是语言私有元素,不是字符串 '#value',不会出现在属性枚举或普通描述符中。类外直接写 counter.#value 是语法错误。子类不能在自己的代码里访问父类声明的私有名称,但父类方法仍能在完成父类初始化的子类实例上访问父类私有状态。
私有静态字段也检查访问目标;继承来的静态方法若使用 this.#x,通过子类调用时可能失败,因为子类构造器没有父类的私有静态品牌。需要固定访问父类状态时明确写父类名称。Object.freeze(instance) 只影响普通属性,不冻结私有字段,也不阻止上述 increment()。
静态继承不会复制私有品牌
class Catalog {
static #count = 6;
static dynamicCount() { return this.#count; }
static ownCount() { return Catalog.#count; }
}
class LocalCatalog extends Catalog {}
console.log(LocalCatalog.ownCount()); // 6
try { LocalCatalog.dynamicCount(); }
catch (error) { console.log(error.name); } // TypeErrorLocalCatalog 能沿构造器原型链找到两个静态方法,但 this.#count 要求 this 本身带着 Catalog 的私有静态元素;它不会沿原型寻找私有字段。若意图是所有子类共享 Catalog 的计数,固定类名清楚地表达所有权;若意图是每个子类各有计数,就需要明确建立按子类区分的状态,而不是期待私有静态字段自动复制。
什么时候用组合
继承表示“子类型应该满足父类型的使用约定”。如果只是想复用一个格式化函数,继承整套类型通常成本过高。
function makePrinter(format) {
return { print(value) { return format(value); } };
}
const printer = makePrinter(n => `编号 ${n}`);
console.log(printer.print(12)); // 编号 12这里通过参数组合能力,依赖清晰,没有父类初始化问题。选择 class、工厂函数或普通对象,要看状态寿命和接口需要。混入对象也能复用方法,但 Object.assign 不会完整保留访问器描述符,且带 super 的方法保留原始 home object;复制方法并不会重新绑定它的 super 查找起点。
练习
为什么父类构造器看到“基础”,构造结束以后却是“手册”?
父类有 setter 时,子类字段
value = 3和this.value = 3有什么差别?如果构造器先定义了自有 value,再普通赋值,还会调用父 setter 吗?冻结
counter后increment()是否还能运行?说明普通属性和私有状态的边界。实现独立的小型记录器 Recorder:构造时接收 write 函数,add(text) 调用它并保存本实例的记录,size 返回条数。用两个不同数组作为输出接收器验证实例互不影响,再传入一个抛错的 write,保证写入失败不增加 size。只需要同步写入接口。