类 — 构造器、继承、可见性与契约
类 — 构造器、继承、可见性与契约
这一章讲一件事: JavaScript 的类,在 TypeScript 手里变成了什么。 答案分两层:运行时那套(构造器、继承、this)基本原样,但类型层面多了一整套 「契约设施」——明确赋值检查、可见性修饰符、override 保险、implements、抽象类。 读完你能写出带完整类型护栏的类,并分清哪些护栏是编译期的、哪些能护到运行时。
1. 顶层全景
一个类的类型层设施,按声明到使用排开:
声明 ── 属性:初值?构造器赋值? readonly? ? public/private/protected/# ?
── 方法:普通方法 or 箭头方法(this 绑定不同)
── 契约:implements 接口 / abstract 抽象类
继承 ── extends + super()(先父后子)
── override(父类改名,子类立刻报错)
使用 ── new 出实例;类名本身可以当类型用(指实例!)
图说:主线例子是 Album 类一家——从光杆属性,到有构造器,到子类 SpecialEditionAlbum,
最后到 implements 和抽象类。每一步都对应上面一层设施。2. 第一幕:属性必须「有着落」
创建类用的是 class 关键字,加帕斯卡命名(每个单词首字母大写)1。
一上来就是个报错现场:
class Album {
title: string; // ❌ 红线
artist: string; // ❌ 红线
releaseYear: number; // ❌ 红线
}每个属性都挨骂:Property 'title' has no initializer and is not definitely assigned in the constructor(没有初值,也没有在构造器里明确赋值)2。
这是 TypeScript 的明确赋值检查(definite assignment): 类的属性不像普通变量那样「用时再赋值」,实例一创建属性就必须就位。 两条合法路:
路一:构造器赋值。 constructor 是实例创建时自动跑的方法,专管初始状态3:
class Album {
title: string;
artist: string;
releaseYear: number;
constructor() {
this.title = "Loop Finding Jazz Records";
this.artist = "Jan Jelinek";
this.releaseYear = 2001;
}
}
const album = new Album();
console.log(album.title); // Loop Finding Jazz Recordsthis 指当前实例;new 负责造实例,constructor 负责填初值4。
这里属性写死在构造器里,每个实例都一样——所以引出路二:构造器收参数。
constructor(opts: {title: string; artist: string; releaseYear: number}) {
this.title = opts.title;
this.artist = opts.artist;
this.releaseYear = opts.releaseYear;
}
new Album({title: "Loop Finding Jazz Records", artist: "Jan Jelinek", releaseYear: 2001});参数照常标注(第 04 章的规则在类里不变),opts 对象的形状就是类的「入场券」5。
补两条关于「明确赋值检查」的细则,实战中都会撞上:
细则一:它由 strict 家族的一个成员执行。 这条检查的全名是
strictPropertyInitialization,是 strict 总开关的子开关之一(第 15 章)。
关掉它,上面的红线全部消失——属性可以「没初值也没赋值」,用到时就是 undefined。
这也是「strict 关掉等于换语言」的一个具体证据:同一份类代码,
两种配置下红线数量差一大截。
细则二:「赋过值」不等于「明确赋值」。 检查器认的是「所有路径都赋值」, 不是「某条路径赋过」:
class Album {
title: string;
constructor(short: boolean) {
if (short) {
this.title = "Short";
}
// else 分支没赋 → 红线仍在:存在一条路径,实例出生时 title 没着落
}
}修复要么补上 else 分支,要么给属性初值(title = ""),要么标 title? 为可选。
「所有路径都有着落」这条标准,和第 06 章收窄的「所有分支都被照顾」是同一种
控制流思维——类型系统对控制流的跟踪,在变量、函数、类属性上反复出现。
还有一个推断红利:构造器里给 this.xxx 赋了值,属性的类型标注可以省——
TypeScript 从赋值处推断6。不过实际代码里标注常常留着:它在类体上起文档作用,一眼读完形状7。
类可以当类型用——这是 TypeScript 里类最有趣的性质:
function printAlbumInfo(album: Album) { /* 读 album.title ... */ }
printAlbumInfo(sixtyNineLoveSongsAlbum);Album 在这个位置指实例的类型(有 title、artist、releaseYear 的那个对象),
不是类本身。传实例进来,形状匹配即通过——类名就这样横跨了「值」和「类型」两个世界
(这个世界观的完整展开是第 13 章的专题)8。
这个例子的运行结果也值得看一眼,因为它演示了「类作为类型」的用法全貌:
function printAlbumInfo(album: Album) {
console.log(
`${album.title} by ${album.artist}, released in ${album.releaseYear}.`,
);
}
printAlbumInfo(sixtyNineLoveSongsAlbum);
// 输出:69 Love Songs by The Magnetic Fields, released in 1999.参数标注 album: Album 读作「album 必须是 Album 的实例形状」——
在函数体内,album.title 之类的访问全部有补全、有检查;
调用处传任何形状吻合的对象都能过(结构化类型的老规则,第 07 章)8。
类在这个用法里就是「能 new 的接口」:接口有的功能(形状合同)它都有,
多一个 new 的能力。反过来问「什么时候用类当类型、什么时候用接口」,
答案常在第 6 幕:要契约用接口,要实例 + 继承才用类。
3. 第二幕:属性的全套修饰符
主线例子的 Album 继续加装备。属性的修饰符一张表:
| 修饰符 | 干什么 | 层面 |
|---|---|---|
初值(title = "Unknown Album") | 类属性初始化器,默认值的来源 | 运行 + 类型 |
readonly | 构造后不许改 | 类型 |
? | 可选,构造器可不赋 | 类型 |
public / private / protected | 可见性 | 类型(除 # 外) |
# | 真私有 | 运行 + 类型 |
属性初始化器写在类体里,先于构造器执行——所以构造器里赋的值会盖掉默认值:
class User {
name = "Unknown User"; // 先跑:name 先是 "Unknown User"
constructor() {
this.name = "Matt Pocock"; // 后跑:盖掉
}
}
const user = new User();
console.log(user.name); // "Matt Pocock"这个执行顺序值得亲手验证一次,因为它解释了类里一大类「为什么我的默认值没生效」 的事故:初值写了,构造器也写了,最后生效的是构造器——后写覆盖先写, 和变量赋值的直觉一致,只是发生在一个你可能没意识到「有两步」的地方9。 顺序反过来的诉求(「构造器先、初值兜底」)在类里不存在;要「可覆盖的默认」, 正确姿势是初值写默认、构造器可选地覆盖,正如上面的例子。
类型从初值推断,想标也行(name: string = "Unknown User"),和第 04 章变量的规则同款9。
readonly 和 ? 直接复用第 07、04 章的语义:readonly 属性构造后锁定(注意是类型层面);
? 属性构造器里可以不赋10。
两个修饰符在类上的完整形态,并排看一眼就够:
class Album {
readonly title: string; // 声明后只在构造器里有一次赋值机会
artist?: string; // 构造器可以不赋,实例上 artist 可能是 undefined
releaseYear = 0; // 有初值:明确赋值检查直接满足
}三行各代表一种「有着落」的实现:readonly 靠构造器的一次性赋值,
? 靠「允许没有」,初值靠「自带默认」——明确赋值检查对三种都满意10。
注意 readonly 与初值可以叠加(readonly releaseYear = 0),意思是
「默认 0,且任何人(包括构造器之外)都别想改」;而 readonly 与 ? 叠加时,
构造器不赋值也合法,属性就是 string | undefined——第 04 章「可选属性读出来是
T | undefined」的规则,在类上逐字复用。
这条「逐字复用」值得单独点一句:类的属性系统和第 04 章的对象属性系统
是同一套规则的两次部署——?、readonly、T | undefined 的语义完全一致,
连报错文本都同宗(missing / required 一族)。学完对象再学类,增量只有三个:
明确赋值检查(类特有)、修饰符(可见性家族)、方法/继承(行为层);
其余都是旧知识换了个花括号。这也是本章读起来比预期快的原因——
它的一半重量来自你已经付过的学费。
第九章到这里,五幕正文、五代剧本、一个总装样本,以及与之配套的出处地图, 全部齐了。类这一章的拆解也到此结束——第十章将清点 TypeScript 自己发明的运行时特性, 从「参数属性」这个和本章关系最近的开始:它能让构造器的样板代码再省一层, 但代价恰好是本章反复强调的那件事——你写的和运行的不再逐字一致。预习这个问题的 最好方式,是把本章合成样本的构造器改写成参数属性写法,再对比两边的编译产物; 看懂差异的那一刻,第 10 章的总立场你其实已经提前拿到了。
private:编译期的锁;#:运行期的墙
class Album {
private rating = 0;
}
new Album().rating; // ❌ Property 'rating' is privateprivate 属性类外不可访问——但只挡编译期。@ts-ignore 一压,运行时照样读到 0:
private 只是给编译器看的注解,编译擦除后荡然无存11。
要真私有,用 # 前缀——这是 ECMAScript 标准的一部分(不是 TypeScript 发明),
所以它在运行时也是私有的:
class Album {
#rating = 0;
}
new Album()["#rating"]; // ❌ 连字符串动态访问都不行,得到 undefined方括号字符串访问撞在两条报错上:"#rating" 不能用来索引 Album,且属性不存在12。
选择建议很直接:需要私有就优先 #——它两头都真;private 适合「表达意图、
但允许测试等场合用 @ts-ignore 突破」的场景13。
私有性的「越狱」测试:两个字段的两种命运
原书给了一个非常有说服力的对照实验,值得完整走一遍——同一个访问动作, 分别施加在两个字段上:
const album = new Album();
// 对 private 字段:编译期报错,但可以越狱
console.log(album.rating); // ❌ Property 'rating' is private
// @ts-ignore ← 压错指令(第 12 章详解)
console.log(album.rating); // ✅ 编译通过,运行时输出 0
// 对 # 字段:编译期报错,运行时同样走不通
console.log(album.#rating); // ❌ not accessible outside class
console.log(album["#rating"]); // ❌ 动态字符串也不行!三个结果各自说明一件事。第一,private 的墙是纸糊的:@ts-ignore 一压,
编译期检查让路,运行时属性真的在那里,输出 011——这再次印证
「类型擦除之后一切类型层面的限制归零」。第二,# 是砖墙:动态字符串访问
["#rating"] 撞到的是「属性不存在」——因为 #rating 根本不是这个对象的属性名,
它是类内部的私有槽位,运行时的对象上压根没有这个名字12。
第三,选择有了判据:墙要挡谁? 挡团队成员(防误用)→ private 够用,还更灵活;
挡运行时(防反射、防序列化泄露、防外部代码)→ 只有 # 合格。
这也是第 03 章「薄语法」清单上的又一例外预览:# 前缀会在编译产物里生成 真实的 WeakMap(或同等机制),是「会生成运行时代码」的 TypeScript 特性—— 但它出自 ECMAScript 标准,不算 TypeScript 的私有发明,所以不在第 10 章的 relic 名单里。
构造器的可选参数与兜底值
第 05 章函数参数的可选、默认值语法,在构造器里照常适用。CanvasNode 第 3 代的构造器是个标准样本:
class CanvasNode {
x: number;
y: number;
constructor(position?: {x: number; y: number}) {
this.x = position?.x ?? 0; // 没传 position → 0
this.y = position?.y ?? 0;
}
}
new CanvasNode(); // ✅ x=0, y=0
new CanvasNode({x: 10, y: 20}); // ✅ x=10, y=20position? 让整个参数可省;?. 防 null/undefined 上取值;?? 给兜底值——
三个运算符各管一段,组合出「可省但有着落」的构造器14。前面第 2 节的
「明确赋值检查」在这里完全满足:无论调用方传不传,this.x、this.y 出构造器时必有值。
protected:给子类开的门
介于 public 和 private 之间:protected 属性类外不可访问,但子类内部可以15。
class Album {
protected title = "Brown Sugar";
}
// 子类里:this.title ✅
// 类外:album.title ❌它服务于第 4 幕的继承:父类的内部细节,只对自己的后代开放。
4. 第三幕:方法与 this
方法两种写法,调用效果一样,运行时的 this 不一样:
class MyClass {
location = "Class";
arrow = () => { console.log("arrow", this); };
method() { console.log("method", this); }
}
const myObj = {
location: "Object",
arrow: new MyClass().arrow,
method: new MyClass().method,
};
myObj.arrow(); // this = {location: 'Class'} ← 定义处的实例
myObj.method(); // this = {location: 'Object'} ← 调用处的对象箭头方法的 this 绑死在定义它的实例上(箭头函数的天性:出生时捕获 this); 普通方法的 this 看调用点(作为 myObj 的方法调,this 就是 myObj)16。 原书的建议:一般场景用箭头方法定义类方法,能避开 this 失控的经典坑; 两种写法的其余差异超出本书范围17。
把这两种写法的差异收成一张对照表,选择时对着看:
| 维度 | 箭头方法(move = () => {}) | 普通方法(move() {}) |
|---|---|---|
| this 绑定 | 定义处实例(词法捕获,永不变) | 调用处对象(运行时决定) |
| 定义位置 | 实例字段(每个实例一份) | 原型(所有实例共享) |
| 被抽出后当回调 | 安全(this 仍指实例) | 危险(this 变成回调发起者) |
| 子类可 override? | 不能(它是字段不是方法) | 能 |
| 可被 super.xxx 调用? | 不能 | 能 |
日常默认按原书建议走箭头;出现「子类要重写这个方法」或「内存敏感、实例极多」 的场景,换普通方法。一个类里两种混用也完全合法——按方法逐个选择, 不必全局统一;统一是团队风格问题,不是技术约束。
这张表也预告了第 10 章的一个判定:箭头方法字段在编译产物里是构造器里的 赋值语句(每个实例执行一次),普通方法则在类体外挂到原型上—— 「箭头方法多耗一点启动成本、省很多 this 事故」就是这个差异的账本, 而「参数属性」(第 10 章主角)会把这两种构造代码进一步折叠。
判断(我们的,不是书里的): 「箭头方法优先」要打个折扣——箭头方法是实例属性, 每个实例各自持有一份函数字段,内存上是 N 份拷贝;普通方法挂在原型上,所有实例共享一份。 高频实例化(成千上万个对象)时这个差异可测。实际建议:对外回调(事件处理器、 setTimeout 回调)用箭头方法防 this 丢失;内部方法用普通方法。 原书把「内存差异超出范围」跳过了,这是教材的合理取舍,但读者该知道代价在哪。 如果错,会错在: 现代引擎对闭包的优化越来越好,N 份拷贝的实际开销在多数应用里 小到可以忽略——那么「箭头优先」确实是更省心的默认,我们的折中建议反而过度精细。
5. 第四幕:继承、super 与 override
主线例子的家族扩张:SpecialEditionAlbum extends Album,加一个 bonusTracks 属性18。
class SpecialEditionAlbum extends Album {
bonusTracks: string[];
constructor(opts: {
title: string;
artist: string;
releaseYear: number;
bonusTracks: string[];
}) {
super(opts); // ← 先让父类构造
this.bonusTracks = opts.bonusTracks; // ← 再补自己的
}
}子类构造器两条铁律,原书点得明白:
- 参数形状覆盖父类 + 新增——父类要什么,子类构造器也要什么( BonusTracks 另算);
super()必须先调——它触发父类构造器,父类定义的属性靠它初始化; 先this.bonusTracks = ...再 super 是非法的19。
两条铁律的背后是同一条运行时事实:实例在被 new 出来的那一刻,
内存里的字段位次已经按继承链排好——父类的部分由父类构造器填,子类的部分
在 super() 返回后才轮到。 顺序反过来的话,子类在父类还没铺好的地基上盖楼,
this.bonusTracks 指向的是一片尚未初始化的区域。TypeScript 把这个运行时约束
翻译成了编译期错误,所以「super 必须先调」其实是 JavaScript 的老规矩,
不是 TypeScript 的新发明19。
还有一个容易漏的细则:super 的参数不必「照抄」父类构造器的参数表, 只要传进去的实参满足父类构造器的形状就行。上面例子里子类构造器收一个 「父类字段 + bonusTracks」的合并对象,然后把整个 opts 递给 super(opts)—— 父类只挑自己认识的三个字段,bonusTracks 被忽略。形状是合同,多余不算违约 (第 13 章「开放对象」的又一实例)19。
实例化照常:new SpecialEditionAlbum({...全部四个属性})20。
override:给继承装上防漂移保险
子类重写父类方法,天经地义。但有个隐患:哪天父类把 displayInfo 改名成
displayAlbumInfo,子类的「重写」会静默失效——没人发现它重写的对象没了。
解法是给重写显式挂牌:
class SpecialEditionAlbum extends Album {
override displayInfo() { /* ...加打 bonus tracks */ }
}挂了 override 之后,父类那边一改名,子类立刻报错:「你声明的重写目标不存在了」21。
还能用 tsconfig 的 noImplicitOverride: true 把挂牌变成强制——重写必须写 override22。
这个开关在第 15 章的推荐配置里有下文。
override 防的到底是什么事故
把漂移场景走完整,override 的价值就从「好习惯」变成「必需品」:
第 0 天:父类 Album 有 displayInfo();子类 SpecialEditionAlbum
重写了它,加了 bonus tracks 的输出
第 100 天:有人把父类方法改名为 displayAlbumInfo(理由:更准确)
没有 override 的世界:子类的 displayInfo 变成一个普通方法,
从此再没人调用它——多打的 bonus tracks 输出静默消失,无人察觉
有 override 的世界:改名那一刻,子类的 override displayInfo() 立刻报错
「此重写目标不存在」——事故在编译期、在改动现场被抓住
图说:override 是「跨层引用」的锚点。继承天然制造跨层依赖,
而跨层依赖最大的风险就是上层改名下层不知——override 把这条暗线变成了明线。这个事故模式在大型代码库里极常见(重构父类的人通常不知道所有子类),
noImplicitOverride: true 的意义就是把「记得挂牌」从人的责任心,
换成编译器的强制检查——和第 15 章 strict 家族是同一个哲学22。
6. 第五幕:契约——implements 与抽象类
extends 解决「复用实现」;还有一类需求是只定契约、不管实现。两个工具。
implements:对接口的承诺
interface IAlbum {
title: string;
artist: string;
releaseYear: number;
trackList: string[];
}
class Album implements IAlbum {
title: string;
artist: string;
releaseYear: number;
// trackList 缺失!
}
// ❌ Class 'Album' incorrectly implements interface 'IAlbum'.
// Property 'trackList' is missing...implements 把接口变成类的必须兑现的合同:缺一个属性,定义处当场报错23。
补上加法:一个类可以实现多个接口(implements A, B),接口之间还能 extends 组合。
命名插曲:接口名前的 I 前缀(以及 type 的 T)是匈牙利记法的残留,原书明确
不推荐普遍使用——只在「接口和同名类撞车」时才用来区分24。
implements 的报错链:为什么合同在定义处验收
把上面那个报错当教材再读一遍:
Class 'Album' incorrectly implements interface 'IAlbum'.
Property 'trackList' is missing in type 'Album' but required in type 'IAlbum'.
两行各管一段:
行 1(结论):实现不正确 —— 谁说的?合同方 IAlbum
行 2(明细):缺 trackList —— 它在 Album 里是「不存在的属性」,
在 IAlbum 里是「必填成员」——两边的要求对不上账
图说:implements 的检查在「类声明」这个点一次性完成——
不是在每个使用点,而是在定义点。错一次,修一处,全部调用方受益。这个「定义处验收」的性质是 implements 和 extends 的关键差异之一: extends 把父类的实现带过来,implements 只把形状的要求立起来、 实现全得自己写。所以「类 + implements 接口」的组合,本质上是在类世界里 复用第 04–07 章的接口工具(单一事实源、声明合并、交叉组合),接口照旧是类型层的主角。
abstract:契约 + 半成品实现
抽象类夹在「接口(纯契约)」和「普通类(完整实现)」之间:
abstract class AlbumBase {
title: string;
// ...普通属性和方法照常写:addTrack() { this.trackList.push(track) } ...
abstract addReview(author: string, review: string): void; // ← 没有方法体!
}两个特征:
- 不能直接实例化——
new AlbumBase(...)报错(Cannot create an instance of an abstract class);它只能被继承25。 - 抽象方法只声明、不实现——任何子类必须把它实现掉,否则子类自己也得标 abstract26。
和 implements 对比,原书给了定位:想法类似,但抽象类可以带实现细节—— 公共代码(属性初始化、addTrack 这类共享方法)放在抽象类里只写一次, 子类只补各自的差异27。这是「模板方法」模式的类型化形态。
原书对抽象类的定性值得原文记住:「模糊了类型和运行时之间的界线」—— 它一半是类型(不能实例化、带抽象成员),一半是运行时(真类、有实现代码)。 这个两重性正是它在三个工具里最难归位的原因,也是它能力的来源: 「共享实现」是运行时的需求,「必须补完」是契约的需求,抽象类一个语法同时管两件。
7. 练习里的完整样本:CanvasNode 一家
原书的练习用同一个 CanvasNode(画布节点)类逐代演进,把本章设施串成了一部家族史, 值得整体走一遍:
第 1 代(8-1):有着落的只读属性。 测试要求 x、y 默认 0,且 @ts-expect-error
断言「x = 10 应该报错」(只读)。两种解法对应第 2 节的两条路:
构造器赋值(readonly x: number + constructor)或属性初始化器(readonly x = 0)28。
这道题的两种解法值得并排细看,因为它们的「运行顺序」不同:
// 解法 A:构造器赋值——先声明「必有」,再在构造器里兑现
class CanvasNode {
readonly x: number;
readonly y: number;
constructor() {
this.x = 0;
this.y = 0;
}
}
// 解法 B:属性初始化器——声明的同时带着落
class CanvasNode {
readonly x = 0;
readonly y = 0;
}解法 B 更短,但有个隐蔽的能力边界:初始化器只能是静态值; 如果默认值需要「算出来」(比如依赖参数),就得回到解法 A。 日常选 B(短、直),需要计算时选 A——选择依据还是那句「初值是静态的还是动态的」28。
第 2 代(8-2):方法。 move(x, y) 更新坐标——普通方法或箭头方法皆可29。
第 3 代(8-3):getter。 内部存 x/y,对外暴露 position:
get position() { return {x: this.x, y: this.y}; }——外部像读普通属性一样读
canvasNode.position,内部是方法30。
第 3 代构造器还顺手演示了一个三件套组合,值得单独留档:
constructor(position?: {x: number; y: number}) {
this.x = position?.x ?? 0;
this.y = position?.y ?? 0;
}? 让参数可省、?. 让「没传时取值」不炸、?? 给兜底——三个符号各管一段,
拼出「可省但必有着落」的构造器。这是第 05 章函数参数语法在构造器里的原样复用:
类不发明新的参数规则,它只是函数的一个特例31。
第 4 代(8-4):setter + 真私有。 属性换成 #x/#y 后,外部想写坐标,
只能走 set position(pos) { this.#x = pos.x; this.#y = pos.y; }。
setter 的参数类型不用标——TypeScript 从对应 getter 的返回类型推断(配对 getter/setter
的类型推断,很实用)32。
第 5 代(8-5):拆分继承。 需求变了:另一个 Shape 类只要 x、y、move,
不要 viewMode。把公共部分拆成基类,CanvasNode extends 它、只加 viewMode;
构造器里 super(options) 把公共参数传给父类33。原书还提示:Shape 可以做成
abstract class——「只要坐标和移动能力、但不该独立存在」的对象,正是抽象类的适用形状。
第 5 代的决策链值得完整记录,因为它就是「什么时候拆继承」的判据本身:
信号出现:第二个类(Shape)要复用 CanvasNode 的一部分(x、y、move),而非全部
│
├─ 误用路径:Shape extends CanvasNode——白捡一个用不上的 viewMode,
│ 还背上了「Shape 是 CanvasNode 的一种」的语义谎言
└─ 正解路径:公共部分上提为基类(或抽象类),两个类各自 extends
——复用是「共同的祖先给的」,不是「谁是谁的儿子」
图说:extends 的第一问永远是「子类是不是父类的一种」——
「只要一部分能力」时,答案是「否」,该拆公共基类而不是硬继承[^31]。「是一种吗」这条判据(Liskov 替换原则的口语版)能挡掉大部分继承滥用; 剩下拿不准的,第 6 幕的接口组合(implements A, B)通常是更松的替代。
五代的演进线很清晰:属性有着落 → 加行为 → 控制访问(getter/setter)→ 真私有 → 按职责拆继承。这是一段可以直接搬进项目的重构剧本。
展开第 3、4 代:访问器(getter/setter)的机制
getter 和 setter 是第 3、4 代的核心,值得把机制讲透——它们是「方法」, 但对外表现得像「属性」:
class CanvasNode {
x: number;
y: number;
get position() { // 读 position 时,实际在调这个方法
return {x: this.x, y: this.y};
}
set position(pos) { // 写 position 时,实际在调这个方法
this.x = pos.x;
this.y = pos.y;
}
}使用时完全没有方法的样子:
const node = new CanvasNode();
console.log(node.position.x); // 0 —— 读,触发 getter
node.position = {x: 5, y: 6}; // 写,触发 setter访问器的意义在于内部表示与外部接口可以不同:内部拆存 x、y 两个数, 对外暴露一个 position 对象;将来内部改成极坐标,外部代码一行不动。 这是封装的标准定义——类的内部细节变了,行为契约不变30。
setter 的 pos 参数没写类型,这是配对推断:同一属性的 getter 返回
{x: number; y: number},setter 参数就自动是这个形状32。少了手写标注,
也少了签名漂移的机会。
访问器还有两个实战要点,原书散在练习里没有集中讲,补在这里:
要点一:只有 getter、没有 setter 的属性是「只读外观」的最简形式。
get position() 存在而 set 不存在时,node.position = ... 直接报错——
不必依赖 readonly 字段,访问器自己就是访问控制。第 4 代把字段升级为 #x 之后,
「读走 getter、写走 setter」的分工让「只读」和「可写校验」可以按属性逐个配置,
粒度比字段修饰符更细。
要点二:getter 里可以做派生,而不是只做透传。 第 3 代的 getter 把 x、y 合成 position 对象返回——每次读取都是现算的。这意味着「存储格式」和 「读取格式」彻底解耦:内部改存极坐标,getter 里做换算,外部无感。 代价是每次读取都执行一次函数(对昂贵的派生,可以加缓存)—— 访问器 = 每次读取都可能执行的代码,这是它和字段的本质区别, 也是「别把重计算塞进 getter」这条经验法则的来源。
第 4 代把属性升级成 #x/#y 后,访问器从「便利」升格为「唯一通道」:
字段真私有,类外任何读写都必须经过 getter/setter——校验逻辑(如拒绝负数坐标)
从此有了稳定的落脚点。原书的两道练习放在一起,正好是这个设计模式的完整示范:
先开放(getter),再收权(# + setter),最后拆家(第 5 代继承)33。
implements、extends、abstract 怎么选
三类工具经常被混用,它们的差别一句话能说清:
| 工具 | 关系 | 能不能带实现 | 能 new 吗 |
|---|---|---|---|
implements 接口 | 类承诺形状 | 不能(纯契约) | 与接口无关 |
extends 普通类 | 子类复用父类 | 能 | 都能 |
extends 抽象类 | 子类补完父类 | 能(还有必须补的抽象成员) | 父类不能,子类能 |
选择顺序的经验法则(我们的总结,原书分散在各节):纯形状契约用接口; 有公共实现要共享、但基类不该独立存在,用抽象类;父类本身就该被实例化,才用普通类继承。 接口还有个独特优势:一个类可以实现多个接口,而继承只有一个父类名额—— 「组合优于继承」在类型层面同样成立。
合成样本:一个装备齐全的 Album 类
把五幕的设施合成一个类,看它们怎么共处:
interface AlbumContract {
title: string;
artist: string;
releaseYear: number;
}
abstract class AlbumBase implements AlbumContract {
readonly title: string; // 契约要求 + 创建后锁定
readonly artist: string;
readonly releaseYear: number;
#rating = 0; // 真私有,运行时也不外露
constructor(opts: AlbumContract) {
this.title = opts.title; // readonly 允许在构造器里赋一次
this.artist = opts.artist;
this.releaseYear = opts.releaseYear;
}
get rating() { // 只读访问器:看可以,改不行
return this.#rating;
}
protected bumpRating() { // 留给子类的内部接口
this.#rating += 1;
}
}
class StudioAlbum extends AlbumBase {
#studio: string;
constructor(opts: AlbumContract & {studio: string}) {
super(opts);
this.#studio = opts.studio;
}
override toString() { // 若父类没有 toString 可挂 override 会报错
return `${this.title} (recorded at ${this.#studio})`;
}
}逐行对回本章的设施:implements 逼 AlbumBase 兑现契约;readonly 属性的唯一赋值机会
就在构造器里;#rating 连运行时都封死,外部只能走 getter;protected 给子类留的
bumpRating 是继承协作的通道;子类构造器先 super 再动自己的 #studio;
override 挂在 Object.prototype 自带的 toString 上同样有效——它检查的是
「父类(含所有祖先)有没有这个成员」。这就是第 03 章那条「薄语法」原则的边界以内,
TypeScript 给类加的全部护栏——除了 # 前缀,它们都活不过编译。
这个合成样本还回答了一个本章没有正面回答的问题:「这些设施会打架吗?」 答案就在样本的相处方式里——
implements × abstract:接口管「对外的形状承诺」,抽象类管「对内的共享实现」,
一个向外一个向内,不冲突。
readonly × 构造器赋值:readonly 的唯一例外就是构造器,
所以「创建时锁定」和「创建必有着落」两个目标在构造器里汇合,不冲突。
# × protected:# 防外部,protected 放子类——两个修饰符各管一群访问者,
样本里 #rating 配 protected bumpRating,正是「子类可动、外人免进」的精确表达。
override × extends:挂牌是给父类链看的,extends 是给实例化看的,各管一段。
图说:设施多不等于复杂——它们各自守住一条边界,
一个类之所以「装备齐全」,是因为每条边界都有明确的卫兵。如果只能带走一个设计口诀,从这个样本里抽就是:先定边界(谁可见、谁可写), 再填实现——类的全部设施都是边界工具,实现只是边界之内的普通代码。 这和接口优先的设计哲学(第 07 章)一脉相承:形状先行,实现随后。
第九章拆完。回顾一下全章的形状:五幕正文是「机制」,CanvasNode 五代是「剧本」, 合成 Album 是「总装」——三种形态各读一遍,类这一章就从「一堆修饰符」 变成了「一套边界设计工具」。这也是原书把类放在第 8 章的用意: 至此,值的形状(第 04–07 章)、值的锁(第 08 章)、值的边界(本章)都已齐备, 第 10 章该清点「TypeScript 自己多造的那些运行时特性」了。
8. 这一章在全书链条中的位置
类是「类型世界」与「值世界」交叉得最密的地方:同一个名字(Album)既是运行时 能 new 的类,又是类型层面的实例形状;private、implements 这些纯类型设施 与 #、constructor 这些运行时设施混在同一个花括号里。第 10 章会抽查其中三个 只有 TypeScript 才有的运行时特性(参数属性、enum、namespace)——类是它们的主场; 第 13 章则会系统讲解「类型世界 vs 值世界」的分界线,类的双重身份在那里得到完整解释。 原书小结的清单是:构造器与明确赋值、修饰符(private 编译期 / # 运行期)、 方法两种写法的 this 差异、extends + super + override、implements、抽象类、类名当类型用31。
这张清单也值得换个顺序读一遍——按「层面」而不是按「幕」:
纯运行时层(JavaScript 本来就有):
class 本体、constructor、new、super、普通方法、# 字段
纯类型层(TypeScript 加的):
属性标注、readonly、?、public/private/protected、
implements、abstract、override、类名当类型用
跨界层(两个世界同框):
属性初始化器(运行时赋值 + 类型推断)、明确赋值检查(运行时约束的类型化)、
箭头方法(语法是值,行为被 this 绑定改写)
图说:三层的划分不是装饰——查「这个设施在哪个层面生效」,
是预测它行为(能不能被 @ts-ignore 绕过、擦除后剩不剩)的最快方法。第 3 层的每个设施都是一道小考题:「它在编译产物里留下什么?」 初始化器留下赋值语句,明确赋值检查什么都不留(纯报错),箭头方法留下函数字段—— 问完这一问,第 03 章「薄语法」的例外清单你基本能自己推出来了。
9. 作者的判断与证据(分开标)
书里有据的:
- 属性的明确赋值检查及两条合规路——语言规则,报错信息原文在案23。
- private 只是编译期注解、@ts-ignore 可突破;# 是 ECMAScript 标准、运行时真私有—— 两个实验(包括方括号字符串访问返回 undefined)都当场可复现1112。
- 箭头方法 this 绑实例、普通方法 this 看调用点——JavaScript 运行时事实16。
- override + noImplicitOverride 防父类改名漂移——机制描述,场景真实2122。
- implements 缺属性即报错、抽象类不可实例化——语言规则,报错原文在案2325。
原书没说的(我们补):
- 箭头方法每实例一份、普通方法原型共享的内存差异——上面判断块已展开(补充,来自通用知识)。
- 抽象类与接口的选择标准(「带实现用抽象类、纯契约为接口」)原书给了暗示但没有成文规则; 社区的另一条经验是优先接口 + 组合,抽象类留给真有共享实现的层级——补充,来自通用知识。
判断(我们的,不是书里的): 本章在全书里最该被注意的一句话,其实是那句不起眼的 「类名可以当类型用,指的是实例」——它是「值世界与类型世界共用名字」的第一个明确样本。 理解了这一点,后面三章的很多怪事(enum 既是值又是类型、namespace 里类型和值混住、 第 13 章的声明合并)都会变成同一个现象的变体,而不是一串孤立怪癖。 如果错,会错在: 双重身份只是「同名」层面的巧合,编译器内部对类和实例类型的处理 远比「一个名字两个意思」复杂(涉及声明合并和类型解析的具体规则); 但作为学习者的第一近似,这个心智模型错不了。
判断(我们的,不是书里的): 类的五幕教学顺序(属性有着落 → 修饰符 → 方法 → 继承 → 契约)是「跟着报错学」的范本:每一幕的第一个代码样本都是非法的, 红线逼出机制。自学者完全可以照这个剧本在真实代码里重演——每学一个设施, 先故意写错看它怎么骂,再写对。这比读十遍定义都快,因为它直接训练 「读报错 → 定位机制缺口」这个本书全文都在强化的循环。 如果错,会错在: 对已经熟悉其他语言类系统的读者(C#/Java 背景), 报错驱动的节奏会显得拖沓——他们更需要的是「差异清单」(TypeScript 的类 和母语哪里不一样),而报错驱动恰好能自动生成这张清单,只是顺序不同。
10. 边界与局限
- private 的编译期属性意味着:库作者拿 private 藏起来的东西,消费者 @ts-ignore 照样摸; 真要封死必须用 #(或闭包)。
- protected 与第 07 章 readonly 对象属性的洞同理:都是类型层面的护栏,运行时无墙。
- 本章的类教学止步于单继承层级;更复杂的泛型类(extends 加类型参数)是第 16 章的范围。
- implements 不检查「多余」属性之外的运行时行为——它检查的仍是形状,不是语义。
- 箭头方法 vs 普通方法的取舍没有银弹。 原书建议「一般用箭头」,但也明说 两种写法的其余差异超出范围;内存布局(实例字段 vs 原型方法)、 可被重写性(普通方法能被子类 override,箭头字段不能)都在差异清单里。 团队该做的是选定一个默认并保持一致,而不是逐案争论。
- 抽象类不能实例化,但「不能直接 new」不等于「不能间接创建」。 匿名子类、 工厂函数、测试替身都可能绕过「基类不该存在」的意图——抽象类防的是 「随手 new」,不是「刻意绕路」。要更强的保证,靠 lint 规则和 code review。
11. 可带走的
- 类的属性必须有初值或在构造器里赋值(明确赋值检查);构造器参数照常要标注。
- 构造器里赋了值,属性类型可省(推断),但留着当文档是常见风格。
- 类名可以当类型用,指的是实例的形状,不是类本身。
- 属性初始化器先于构造器执行;构造器赋值覆盖默认值。
- readonly /
?与对象类型同款语义;都是类型层面。 - private 只挡编译期(@ts-ignore 可破);# 是运行时的真私有,需要私有优先 #。
- protected = 类外禁、子类放行。
- 方法两种写法:this 一个绑定义处(箭头)、一个看调用处(普通);一般场景箭头更省心(内存除外)。
- 继承:子类构造器先 super() 再动 this;override 挂牌防父类改名漂移;
noImplicitOverride可强制。 - implements 让接口成为类的合同;抽象类 = 合同 + 半成品实现,不可直接 new。
- getter/setter 成对时,setter 参数类型从 getter 推断。
- CanvasNode 五代演进(属性→方法→getter/setter→#私有→拆分继承)是一套可直接复用的重构剧本。
- 访问器让「内部表示」与「外部接口」解耦——校验逻辑在 setter 里有稳定的落脚点。
- setter 参数类型从配对 getter 推断,不用手写。
- implements(纯契约)vs 抽象类(契约+实现)vs 普通继承:按「要不要共享实现、基类该不该存在」选。
- 类名当类型用 = 实例的形状——「一个名字两个世界」的第一次出现,记住它。
这 16 条按「入门必需」和「进阶备用」分两层:1–9 是写第一个类就要用的 (漏一条就报错),10–16 是设计类层级时才出场的(用错了不会立刻炸, 但会欠下维护债)。学本章的正常路径是先前半,后半等第一次「这个类该拆该合」 的真实需求出现时再回来查——到时候第 6 幕的选择表和 CanvasNode 的第 5 代会替你省半天。
12. 原文地图
下表新增条目对应本次加厚的内容(初始化器顺序、private 越狱实验、super 细则、 override 漂移场景、implements 报错链、抽象类的两重性、8-1 双解与 8-3 三件套); 其余条目为初版已有,行号均可回核。全表覆盖原书第 8 章(Creating a Class) 的全部小节与五道练习,无一处导航章内容被引入。按这张地图回核任意一条论断, 平均只需要两次跳转:一次到行号,一次到搜词——这也是全书拆解的出处标准。 第九章的拆解至此完整,前后呼应的总纲与十七个兄弟章节的地图同构, 查证体验全书一致。读者顺藤摸瓜回原文时,建议从「本节最深的那个例子」入手—— 每一节的主线代码都先在正文里走完了,地图只是它的地址簿。 类的故事讲完了,TypeScript 的「值」这一半(形状、锁、边界)也就讲完了: 下一半的故事关于「类型的机器」——第 11 章开始推导,第 16 章造机器,第 17 章把机器装进函数。 这是全书的真正分水岭,值得在翻页之前停一秒,回头看看从第 04 章走到这里的路程—— 那些一开始觉得零散的规则,此刻应该已经连成了一条线。线的这一端是值,那一端是类型。
| 主题 | 原书位置 | 原文位置 |
|---|---|---|
| 明确赋值报错 | Creating a Class | text/24-fm-creating-a-class.txt:24(搜「no initializer」) |
| 构造器与 new | 同上 | text/24-fm-creating-a-class.txt:39(搜「constructor()」) |
| 构造器参数与 opts | 同上 | text/24-fm-creating-a-class.txt:79(搜「constructor(opts」) |
| 构造器内推断可省标注 | 同上 | text/24-fm-creating-a-class.txt:54(搜「smart inference」) |
| 类名当类型用(指实例) | 同上 | text/24-fm-creating-a-class.txt:116(搜「refers to the instance」) |
| 属性初始化器先于构造器 | 同上 | text/24-fm-creating-a-class.txt:146(搜「before the constructor」) |
| private 与 @ts-ignore | 同上 | text/24-fm-creating-a-class.txt:206(搜「doesn’t actually prevent」) |
| # 私有与方括号访问 | 同上 | text/24-fm-creating-a-class.txt:229(搜「#rating」) |
| 箭头 vs 普通方法的 this | 同上 | text/24-fm-creating-a-class.txt:285(搜「location: 'Class'」) |
| 箭头方法建议 | 同上 | text/24-fm-creating-a-class.txt:290(搜「avoid issues with this」) |
| extends + super 两步 | 同上 | text/24-fm-creating-a-class.txt:344(搜「super(opts)」) |
| override 防漂移 | 同上 | text/24-fm-creating-a-class.txt:415(搜「override displayInfo」) |
| noImplicitOverride | 同上 | text/24-fm-creating-a-class.txt:425(搜「noImplicitOverride」) |
| implements 报错 | 同上 | text/24-fm-creating-a-class.txt:468(搜「incorrectly implements」) |
| I 前缀的取舍 | 同上 | text/24-fm-creating-a-class.txt:450(搜「Hungarian notation」) |
| 抽象类不可实例化 | 同上 | text/24-fm-creating-a-class.txt:531(搜「Cannot create an instance」) |
| 抽象方法 | 同上 | text/24-fm-creating-a-class.txt:554(搜「abstract addReview」) |
| setter 参数类型推断 | 同上 | text/24-fm-creating-a-class.txt:790(搜「infer it based on the getter」) |
| 本章小结 | 同上 | text/24-fm-creating-a-class.txt:853(搜「encapsulating data and behavior」) |
| 属性初始化器先于构造器 | 同上 | text/24-fm-creating-a-class.txt:146(搜「run before the constructor」) · text/24-fm-creating-a-class.txt:158(搜「Output: Matt Pocock」) |
| readonly 类属性 | 同上 | text/24-fm-creating-a-class.txt:164(搜「once the property is set in the constructor」) |
| 可选类属性 | 同上 | text/24-fm-creating-a-class.txt:174(搜「mark properties as optional」) |
| private 的 @ts-ignore 越狱 | 同上 | text/24-fm-creating-a-class.txt:206(搜「doesn’t actually prevent」) · text/24-fm-creating-a-class.txt:209(搜「Output: 0」) |
| # 的字符串访问也走不通 | 同上 | text/24-fm-creating-a-class.txt:227(搜「dynamic string」) |
| 箭头方法 this 实验 | 同上 | text/24-fm-creating-a-class.txt:285(搜「location: 'Class'」) · text/24-fm-creating-a-class.txt:288(搜「bound to the instance」) |
| super 的两条构造器规则 | 同上 | text/24-fm-creating-a-class.txt:331(搜「match the shape used in the parent class」) · text/24-fm-creating-a-class.txt:333(搜「sets up the properties」) |
| override 漂移场景 | 同上 | text/24-fm-creating-a-class.txt:409(搜「won’t automatically get updated」) |
| noImplicitOverride | 同上 | text/24-fm-creating-a-class.txt:425(搜「noImplicitOverride」) |
| I 前缀的不推荐 | 同上 | text/24-fm-creating-a-class.txt:450(搜「We don’t recommend」) |
| 抽象类不可实例化 | 同上 | text/24-fm-creating-a-class.txt:531(搜「Cannot create an instance」) |
| 抽象类「模糊界线」 | 同上 | text/24-fm-creating-a-class.txt:545(搜「blur the line」) |
| 练习 8-1 的两种解法 | 同上 | text/24-fm-creating-a-class.txt:604(搜「readonly x: number」) · text/24-fm-creating-a-class.txt:616(搜「readonly x = 0」) |
| 练习 8-3 的构造器三件套 | 同上 | text/24-fm-creating-a-class.txt:687(搜「position?:」) |
Footnotes
-
出处:「Creating a Class」第 8–18 段(
text/24-fm-creating-a-class.txt:10,搜「class keyword」)。 ↩ -
出处:「Creating a Class」第 20–26 段(
text/24-fm-creating-a-class.txt:24,搜「no initializer」)。 ↩ ↩2 -
出处:「Creating a Class」第 28–52 段(
text/24-fm-creating-a-class.txt:30,搜「special method」)。 ↩ ↩2 -
出处:「Creating a Class」第 48–52 段(
text/24-fm-creating-a-class.txt:52,搜「new keyword」)。 ↩ -
出处:「Creating a Class」第 72–102 段(
text/24-fm-creating-a-class.txt:79,搜「constructor(opts」)。 ↩ -
出处:「Creating a Class」第 54–68 段(
text/24-fm-creating-a-class.txt:54,搜「smart inference」)。 ↩ -
出处:「Creating a Class」第 70 段(
text/24-fm-creating-a-class.txt:70,搜「form of documentation」)。 ↩ -
出处:「Creating a Class」第 104–120 段(
text/24-fm-creating-a-class.txt:116,搜「refers to the instance」)。 ↩ ↩2 -
出处:「Creating a Class」第 126–160 段(
text/24-fm-creating-a-class.txt:146,搜「before the constructor」)。 ↩ ↩2 -
出处:「Creating a Class」第 162–182 段(
text/24-fm-creating-a-class.txt:167,搜「readonly title」)、第 176–182 段(搜「don’t need to be set」)。 ↩ ↩2 -
出处:「Creating a Class」第 184–211 段(
text/24-fm-creating-a-class.txt:206,搜「doesn’t actually prevent」)。 ↩ ↩2 ↩3 -
出处:「Creating a Class」第 213–235 段(
text/24-fm-creating-a-class.txt:229,搜「#rating」)。 ↩ ↩2 ↩3 -
出处:「Creating a Class」第 219 段(
text/24-fm-creating-a-class.txt:219,搜「part of the ECMAScript standard」)。 ↩ -
出处:「Creating a Class」第 681–696 段(
text/24-fm-creating-a-class.txt:688,搜「position?.x ?? 0」)。 ↩ -
出处:「Creating a Class」第 360–380 段(
text/24-fm-creating-a-class.txt:362,搜「third visibility modifier」)。 ↩ -
出处:「Creating a Class」第 265–290 段(
text/24-fm-creating-a-class.txt:285,搜「location: 'Class'」);机制解释见第 288 段(搜「bound to the instance」)。 ↩ ↩2 -
出处:「Creating a Class」第 290 段(
text/24-fm-creating-a-class.txt:290,搜「avoid issues with this」)。 ↩ -
出处:「Creating a Class」第 292–327 段(
text/24-fm-creating-a-class.txt:322,搜「SpecialEditionAlbum extends Album」)。 ↩ -
出处:「Creating a Class」第 329–347 段(
text/24-fm-creating-a-class.txt:333,搜「special method that calls the constructor」);super 的作用机制见第 333 段。 ↩ ↩2 ↩3 -
出处:「Creating a Class」第 349–358 段(
text/24-fm-creating-a-class.txt:351,搜「plasticOnoBandSpecialEdition」)。 ↩ -
出处:「Creating a Class」第 382–423 段(
text/24-fm-creating-a-class.txt:415,搜「override displayInfo」);「父类改名子类报错」的场景描述见第 409–423 段。 ↩ ↩2 -
出处:「Creating a Class」第 425–431 段(
text/24-fm-creating-a-class.txt:425,搜「noImplicitOverride」)。 ↩ ↩2 ↩3 -
出处:「Creating a Class」第 433–494 段(
text/24-fm-creating-a-class.txt:468,搜「incorrectly implements」)。 ↩ ↩2 -
出处:「Creating a Class」第 450 段(
text/24-fm-creating-a-class.txt:450,搜「Hungarian notation」)。 ↩ -
出处:「Creating a Class」第 496–545 段(
text/24-fm-creating-a-class.txt:531,搜「Cannot create an instance」)。 ↩ ↩2 -
出处:「Creating a Class」第 547–567 段(
text/24-fm-creating-a-class.txt:554,搜「abstract addReview」)。 ↩ -
出处:「Creating a Class」第 545 段(
text/24-fm-creating-a-class.txt:545,搜「blur the line」)。 ↩ -
出处:「Creating a Class」第 569–618 段(
text/24-fm-creating-a-class.txt:604,搜「readonly x: number」);无构造器版见第 615–618 段。 ↩ ↩2 -
出处:「Creating a Class」第 620–677 段(
text/24-fm-creating-a-class.txt:662,搜「move(x: number, y: number)」)。 ↩ -
出处:「Creating a Class」第 679–752 段(
text/24-fm-creating-a-class.txt:741,搜「get position」)。 ↩ ↩2 -
出处:「Creating a Class」第 851–863 段(
text/24-fm-creating-a-class.txt:853,搜「encapsulating data and behavior」)。 ↩ ↩2 -
出处:「Creating a Class」第 754–790 段(
text/24-fm-creating-a-class.txt:790,搜「infer it based on the getter」)。 ↩ ↩2 -
出处:「Creating a Class」第 792–849 段(
text/24-fm-creating-a-class.txt:842,搜「CanvasNode extends Shape」)。 ↩ ↩2