1. 先把修饰符和抽象类的关系理顺:类设计其实是在定约束
接触 TypeScript 一段时间后你会发现,类的语法本身并不难:class、constructor、extends、super,翻来覆去就那几样。真正让代码变复杂的是约束。一个类里,哪些属性对外可见、哪些内部私有、哪些允许子类改写、哪些必须由子类实现——这些约束一旦定错,后面改起来就是牵一发动全身。抽象类和修饰符,恰好就是这套约束的两种主要形态。
用生活化的方式理解:普通类是一个完整产品,拿来就能用;抽象类是一个半成品模具,必须经过二次加工才能用;接口则是一份验收清单,只管你长什么样,不管你怎么实现。修饰符更像是产品的封装等级:全公开、仅内部、半公开。它们不是互相替代的关系,而是层层递进的约束体系,实际项目中几乎总是同时出现。
为什么我要把这两样东西放在一起讲?因为一个设计得好的基类,通常同时具备三个特征:用 protected 暴露必要的内部接口给子类、用 private 藏住无关实现、用 abstract 强制子类补齐缺失的行为。单独看任何一个知识点都简单,组合起来才是真正的难点。这也是面试官最爱从修饰符一路追问到抽象类、再追问到接口的原因。这篇文章就按这个节奏来:先修饰符,再抽象类,再接口,最后落到真实项目里的一个 playwright 页面对象设计案例上。
2. 访问修饰符:public、private、protected 的边界感
2.1 三兄弟的分工,以及“编译期检查”这个关键背景
先看一段最常见的演示代码:
class User { public name: string; private password: string; protected email: string; readonly id: number; static version: string = 'v1.0'; constructor(name: string, password: string, email: string, id: number) { this.name = name; this.password = password; this.email = email; this.id = id; } public checkPassword(input: string): boolean { return this.password === input; } } const user = new User('张三', 'pwd123', 'zhang@example.com', 1); user.name; // 可以,public 默认对外开放 user.password; // 报错:属性 password 是私有属性 user.email; // 报错:属性 email 受保护,只能在类及其子类中访问 user.id; // 可以读,但不可以赋值 User.version; // 可以,static 挂到类本身而不是实例 user.version; // 报错,实例上访问不到 static 属性三兄弟的分工,一句话就能概括:public 谁都能碰,private 只有本类能碰,protected 本类加上所有子类能碰。但这里有个非常容易被忽略的背景——TypeScript 的访问修饰符只在编译阶段生效,运行时根本没有这层限制。你把 private 的属性打印出来,照样能看到它的值。
这意味着什么?意味着这些修饰符是写给开发者和编译器看的“约定”,而不是真正意义上的安全机制。你用它来防止团队成员不小心乱改内部状态,防止你三个月后自己忘记某个字段的用途。真要做运行时级的硬隔离,得用 JavaScript 原生支持的#私有字段,比如#password。TypeScript 从 3.8 版本开始也支持这种写法,而且它是真正的强隔离,delete都删不掉。
我建议的实践是:如果只是团队协作层面的封装,用 private/protected 就够了,可读性好,错误提示清晰;如果是要把核心机密和外部彻底隔开,比如 SDK 内部的 token、密钥,那就用#私有字段。两条路线可以混用,不要因为它们长得像就觉得是一种东西。
2.2 readonly 和 static:两个很容易被讲漏的修饰符
访问修饰符之外,还有两个修饰符的出场率同样很高,但很多人对它们的理解停留在“见过、用过、没想过”。
readonly 的中文意思是“只读”,它约束的是赋值时机。一个 readonly 属性只能在声明时、或者在构造函数里被赋值,其他地方一律不能写。注意“构造函数里”这个表述要精确:不是说子类构造函数里也能随便赋值,而是说它只能在“自己声明所在的类”的构造函数里被赋值。一旦类实例化完成,不管是在外部还是在子类里,都不能再给它重新赋值了。这个特性特别适合那些业务上不允许变更的字段,比如用户 id、订单号、创建时间。
static 则是把属性或方法挂到类本身,而不是挂在实例上。它最常见的用途是定义常量、工具方法、单例入口。比如一个配置类:Config.BASE_URL、Config.get('timeout')。这里有个细节值得注意:static 成员也可以和访问修饰符组合,比如private static表示“类内部共享的私有工具”,protected static表示“子类也能访问的公共基类常量”。这种组合在抽象类的设计里非常有用,后面讲模板方法模式的时候你会看到它的实际价值。
还有一点,static 不具有多态性。子类可以定义同名的 static 成员,但它们各自独立,不存在“覆盖”的语义。如果你想要“不同子类返回不同常量”的效果,更好的选择是把方法定义为实例方法,或者用抽象属性让子类强制覆盖,而不是依赖 static 的覆盖。
2.3 参数属性:一行代码省掉五遍重复声明
接下来聊一个能立刻提升代码质感的语法:参数属性(Parameter Properties)。它解决的是 TypeScript 类里一个著名的痛点——属性声明的重复劳动。
传统写法是这样的:
class User { public name: string; private password: string; constructor(name: string, password: string) { this.name = name; this.password = password; } }一个属性,要在声明处写一遍类型,在参数处再写一遍类型,还要在构造函数里手动赋值一次。三处改动,任何一处漏了都会出问题。参数属性的写法直接把这些合并到构造函数参数里:
class User { constructor( public name: string, private password: string, protected email: string, readonly id: number, ) {} }只要在构造函数参数前面加上修饰符,TypeScript 就会自动帮你完成“声明属性 + 类型标注 + 赋值”三件事。编译后的代码等价于前面的传统写法,但源码简洁得多,也从根本上杜绝了“忘了在构造函数里赋值”这类低级错误。
不过要提醒一句:不要为了炫耀语法,把所有参数都改成参数属性。只有那些确实需要暴露为类属性的参数才值得这么写。如果某个参数只是构造时用一次、不需要存下来,那就老老实实当普通参数用。另外,参数属性和strictPropertyInitialization严格模式配合良好,因为 TS 能明确看到它已经被赋值了,不会报“属性没有初始化”的错。
3. 抽象类:真正的“半成品基类”
3.1 抽象类为什么不能 new?抽象方法又是什么?
访问修饰符管的是“谁能访问什么”,抽象类管的则是“哪些行为必须由子类来确定”。
抽象类用abstract关键字声明,它可以包含普通类的一切:属性、构造函数、具体方法。特殊之处在于它还可以包含抽象成员——只有方法签名和属性类型声明,没有具体实现。任何包含抽象成员的类,都必须在前面加上abstract,而且这样的类不能直接new。
为什么不能new?因为抽象类里还有没实现的成员。如果允许实例化,那调用到某个抽象方法时就只有一个空壳,谁也不知道该执行什么逻辑。TypeScript 选择在编译期就拦死这个问题:你的类里但凡有一个抽象成员,实例化就直接报错。
来看一个最经典的动物示例:
abstract class Animal { protected name: string; constructor(name: string) { this.name = name; } // 抽象方法:只有签名,没有实现 abstract makeSound(): void; // 具体方法:所有子类共享的实现 move(distance: number): void { console.log(`${this.name} moved ${distance}m`); } } class Dog extends Animal { constructor(name: string) { super(name); } // 子类必须实现抽象方法,否则子类也会变成抽象类 makeSound(): void { console.log('Woof!'); } } const dog = new Dog('旺财'); dog.makeSound(); // Woof! dog.move(10); // 旺财 moved 10m // const animal = new Animal('x'); // 报错:无法创建抽象类的实例这个例子里,makeSound被抽象出来了,因为不同动物的叫声完全不同,父类没法替子类决定。而move被实现出来了,因为移动的逻辑所有动物都一样。抽象类的本质就是“把共性实现掉,把差异留白”,让子类只关注自己真正需要决定的部分。
3.2 抽象类和普通类的四个核心区别
面试的时候,抽象类和普通类的区别几乎必考。光背结论没用,你要能从设计意图上讲清楚:
第一,实例化能力不同。普通类可以new,抽象类不能直接new,必须被子类继承后再实例化子类。抽象类的构造函数依然会被调用,但只发生在super()的时候,它是为子类服务而存在的。
第二,抽象方法的存在不同。普通类不可能有抽象方法,或者说普通类的每个方法都必须有实现。抽象类可以包含抽象方法,也可以包含具体方法,甚至可以全部都是具体方法——注意这一点,一个没有任何抽象成员的抽象类也是合法的,这种情况等于你主动“禁止实例化”,只允许继承。
第三,继承约束的强度不同。普通类的子类可以不重写任何方法,完全复用父类代码就能用。抽象类的子类则必须实现所有抽象成员,否则子类自己也得声明成抽象类。这就保证了“凡是某个业务规则的子类,就一定拥有对应的行为”。
第四,从设计语义上看,普通类描述的是一个“完整的东西”,抽象类描述的是一个“不完整但可扩展的骨架”。你可以在抽象类里写固定的流程、公共的工具方法、统一的属性,然后把变化的点设计成抽象成员,强制每个子类去填。
这四个区别背后是一条主线:抽象类牺牲了“直接使用”的便利性,换来了“子类质量”的保障。你想统一封装一套逻辑,又不想让团队里有人绕过约束乱造实例,抽象类就是最好的选择。
3.3 模板方法模式:抽象类最典型的用武之地
抽象类最常见的实际应用是模板方法模式。这个模式的思想很简单:把一套流程的骨架写好,流程中的某些步骤交给子类去实现。
举一个数据解析的例子。假设你要解析 CSV、JSON、XML 三种格式,它们的整体流程都是“读取 → 清理 → 校验 → 转换”,但校验规则和转换逻辑完全不同。这时抽象类可以这样设计:
interface ParsedResult { data: Record<string, unknown>[]; errors: string[]; } abstract class DataParser { // 模板方法:定义固定流程,子类不要重写 parse(raw: string): ParsedResult { const cleaned = this.clean(raw); const validated = this.validate(cleaned); return this.transform(validated); } // 共性实现:所有格式都需要的清理逻辑 protected clean(raw: string): string { return raw.trim(); } // 抽象步骤:校验规则由子类决定 protected abstract validate(cleaned: string): string; // 抽象步骤:转换逻辑由子类决定 protected abstract transform(validated: string): ParsedResult; } class CsvParser extends DataParser { protected validate(cleaned: string): string { // 校验 CSV 的行数和列数 return cleaned; } protected transform(validated: string): ParsedResult { // 把 CSV 字符串转换成对象数组 return { data: [], errors: [] }; } }这个设计的好处是:流程骨架被锁死在基类里,子类不可能把“校验”和“转换”的顺序颠倒,也不可能忘了实现某个步骤——因为忘了实现,类根本没法通过编译。这就是抽象类比“普通类 + 约定注释”更可靠的地方。同时clean用protected保护起来,外部不能随意调用中间步骤,只能调parse这一个入口。
你注意看,这里parse方法本身没有加abstract,它是完整实现;validate和transform加了abstract,是空实现。这种“完整骨架 + 留白节点”的组合,就是模板方法模式的核心,也是抽象类设计能力的直接体现。
4. 抽象类 vs 接口:不是二选一,而是两种约束层级
4.1 语义差异:是什么 vs 能做什么
网上关于“抽象类和接口的区别”的帖子已经多到看不过来,但大部分都在罗列语法差异,很少有人把语义讲透。
我的理解是:接口描述的是“一个对象应该长什么样”,它是一份契约;抽象类描述的是“一类对象应该是什么”,它是一具骨架。你用接口去约束“这个东西能不能调用 fetch 方法”,用抽象类去表达“这个东西是动物,动物都会发出声音,但怎么发声你子类决定”。
生活里类比:接口像招聘 JD,写明岗位要求——会沟通、会写日报、会开会;抽象类像入职后的师傅带教——公司流程、工具、团队规范都教给你了,但你具体怎么产出,得你自己摸索。JD 只关心你有没有这些能力,带教关心你作为一个“这个团队的人”具备哪些公共基础和内部默契。
这个差异带来一个实际影响:接口可以用来约束任何对象,不管它是类实例、普通对象字面量,还是函数返回值;而抽象类只能用来约束“类的继承体系”。如果你只是想声明一个函数的参数类型,用接口;如果你真的要抽公共逻辑、共享代码,用抽象类。
4.2 语法差异:implements、继承、多继承
语法上,两者的差异更明显。一个类可以实现(implements)多个接口,但只能继承(extends)一个抽象类。这个限制来自类的单继承模型——一个子类只能有一个父类,但可以实现无数个接口。
接口可以继承接口,而且可以多继承:
interface BasicUser { name: string; age: number; } interface AdminUser extends BasicUser { role: 'admin'; permissions: string[]; } interface SuperAdmin extends AdminUser, BasicUser { level: number; }抽象类继承抽象类也是合法的,父子抽象类之间可以逐层细化抽象程度。但抽象类只能继承一个,这个“单继承”的限制恰恰也是它设计价值的一部分——继承链越简单,体系越好维护。
还有一个语法细节:抽象类可以有 constructor、可以有实例属性、可以有方法的实际实现,这些接口统统做不到。接口里只能有成员的声明类型,不能有赋值逻辑,不能有构造函数。这也是为什么当你需要“公共属性和公共方法的状态”时,抽象类是唯一合适的选择。接口描述能力,抽象类提供基底能力,两者用途截然不同。
4.3 实战选择表与混合用法
我在实际项目里判断用哪个,通常会问三个问题:
- 我需要共享代码实现吗?需要就选抽象类。
- 我需要表达一个简单的形状契约吗?需要就选接口。
- 我又要共享编码又要约束形状?两个一起用。
混合用法很常见:用抽象类做基类,实现公共逻辑和状态;用接口去描述对外暴露的能力,让外部依赖结构而不是具体类。
interface Repository<T> { find(id: number): Promise<T | null>; save(item: T): Promise<void>; } abstract class BaseRepository<T> implements Repository<T> { protected abstract tableName: string; async find(id: number): Promise<T | null> { // 公共逻辑:基于 tableName 拼 SQL 查询 return null; } async save(item: T): Promise<void> { // 公共逻辑:基于 tableName 拼 SQL 写入 } } class UserRepository extends BaseRepository<User> { protected tableName = 'users'; }这个设计里,Repository<T>是契约,让调用方只依赖接口;BaseRepository<T>是骨架,把公共数据库逻辑收拢;UserRepository只负责提供表名。这里既有 interface 的类型约束,又有抽象类的实现共享,还有 protected 修饰符保护内部细节,四种知识点全部串起来了。
这里有一点值得记住:抽象类实现接口时,不需要实现所有的接口成员,可以把部分抽象成员继续留给子类。比如上面BaseRepository没有实现任何接口方法,它把find和save作为普通方法写了实现;如果我想让某些子类自己写find,我可以把find声明为abstract,让接口的契约转移到抽象类上,子类再实现。这种“接口定义能力,抽象类定义基座”的层级感,是理解 TS 类型系统的关键。
5. interface 的继承:面试追问率最高的问题
5.1 interface extends interface:单继承和多继承
“TypeScript interface 怎么继承”是近期搜得很多的问题,它背后其实是大家对“继承”这个概念在不同类型之间的混淆。桥段通常是这样的:你会用类继承,知道抽象类继承,但接口继承的更常用场景反而被忽略。
接口继承接口是最简单也最常用的一种:
interface HasName { name: string; } interface HasAge { age: number; } interface Person extends HasName, HasAge { email: string; } const p: Person = { name: '张三', age: 30, email: 'zhang@example.com', };这里的extends语法理解起来和类继承很像,但有一个关键不同:一个类只能继承一个类,一个接口却可以继承多个接口。这相当于是 TypeScript 在类型层面帮你绕过了类的单继承限制,达到了类似“多继承”的效果。
如果你想让接口继承后覆盖父接口的成员,需要满足一个规则:子接口中重新声明的成员类型必须是可以赋值给父接口成员类型的。比如父接口定义了id: string,子接口想改成id: number | string是可以的,因为你扩展了类型范围;但如果你改成id: number,类型范围收窄了,TS 会报错。这个规则很多人踩过坑,建议面试前专门记一下。
5.2 interface extends class:冷门但面试官喜欢
另一个容易让人愣住的点:interface 居然可以 extends 一个 class。这在语义上不是“接口继承类的实现”,而是“接口继承了类的结构和类型”。简单说,接口可以从一个类中提取成员结构,然后要求其他类或对象也满足这个结构。
class Point { x: number = 0; y: number = 0; } interface Point3D extends Point { z: number; } const p3d: Point3D = { x: 1, y: 2, z: 3, };这背后有个关键限制:如果被继承的类有 private 或 protected 成员,那么这个接口只能由该类的子类来实现,普通对象字面量都不行。因为 private/protected 成员是类私有的结构,外部对象没法满足。气人的是,接口本身并不要求你给出那些私有成员的定义,但实现它的类必须继承自那个类的子类才行。
这个冷门知识在面试里的价值在于:它能区分你是“背概念”还是“真正用过”。你不用在这里栽跟头——记住一个判断原则:interface extends class 最大的使用场景是“用接口去表达某个类实例的某一部分形状”,平时能不用就不用,普通对象用Pick或者结构化类型就行了。
5.3 接口继承与类继承的对比:一句话总结各自位置
把三种继承方式放在一张表里对比,思路立刻就清楚了:
| 方式 | 关键字 | 数量限制 | 是否共享实现 | 典型用途 |
|---|---|---|---|---|
| 类继承类 | extends | 只能一个 | 是 | 通用基类、公共逻辑复用 |
| 抽象类继承类 | extends | 只能一个 | 是 | 模板方法、定义骨架 |
| 接口继承接口 | extends | 可以多个 | 否 | 组合约束、扩展契约 |
| 接口继承类 | extends | 可以多个 | 否 | 提取类的形状作为契约 |
这个表最终落到一个核心认知:继承不只有“代码复用”这一个目的。类继承和抽象类继承主要解决“实现共享”,接口继承主要解决“类型组合”。面试的时候如果能把这个底层逻辑讲清楚,比单纯背“接口可以多继承,类不能”要有说服力得多。
6. 实操案例:用抽象类 + 修饰符设计一套 playwright 页面对象基类
6.1 需求背景与设计思路
聊了这么多理论,接下来上一个我实际做过的完整案例。最近在用 TypeScript + playwright 做一套端到端自动化测试,页面很多,每个页面都有不少重复操作:打开 URL、等页面加载完成、定位元素、填表单、断言。一开始我每个 spec 文件里都写这些重复代码,后来测试量上来后明显感觉到维护成本越来越高——一个公共等待策略的调整,所有文件都要改一遍。
重构的方向很明确:抽一个页面对象的基类。所有页面都有的能力放基类:打开页面、等待就绪、公共定位方法。每个页面各不相同的能力:页面 URL、关键操作流程,尽量用抽象成员约束住,强制每个子类自己定义。同时用 protected 控制中间方法不被外部测试用例误调用——测试代码只需要暴露goto、login这样的高级动词,不该看到底层定位细节。
这里我用到的设计规则就是前面讲过的组合拳:抽象类定义骨架、abstract 强制关键信息、protected 保护内部方法、readonly 固定依赖。
6.2 基类与子类的完整实现
先看基类:
import { Page, Locator } from 'playwright'; abstract class BasePage { // readonly + protected:依赖固定,只能内部和子类访问 protected readonly page: Page; constructor(page: Page) { this.page = page; } // 抽象属性:每个子类必须给出自己的 URL abstract get url(): string; // 公共方法:打开页面,延迟到子类 async goto(): Promise<void> { await this.page.goto(this.url); } // protected 方法封装底层定位,外部不暴露 protected locator(selector: string): Locator { return this.page.locator(selector); } async waitForReady(): Promise<void> { await this.page.waitForLoadState('networkidle'); } }再看两个子类:
class LoginPage extends BasePage { get url(): string { return 'https://example.com/login'; } async login(username: string, password: string): Promise<void> { await this.goto(); await this.locator('#username').fill(username); await this.locator('#password').fill(password); await this.locator('#login-btn').click(); await this.waitForReady(); } } class DashboardPage extends BasePage { get url(): string { return 'https://example.com/dashboard'; } async getWelcomeText(): Promise<string> { await this.goto(); return this.locator('.welcome').innerText(); } }在测试用例里,我只用了LoginPage和DashboardPage暴露出来的一层小 API:
const page = await browser.newPage(); const loginPage = new LoginPage(page); await loginPage.login('admin', '123456'); const dashboardPage = new DashboardPage(page); const welcome = await dashboardPage.getWelcomeText(); expect(welcome).toContain('欢迎回来');外部拿不到loginPage.locator('#password'),因为 locator 是 protected;外部也无法绕过url属性随意跳转到别的地址,因为url是抽象属性,每个页面实例天然绑定自己的路径。这些约束都不是文档里约定的,而是编译器强制执行的——团队里任何一个人假如在测试里写了dashboardPage.locator(...),TS 立刻报错,根本走不到运行阶段。这就是抽象类加修饰符组合之后带来的“系统性防呆”。
6.3 这套设计避开了哪些坑
做这个重构时我踩过几个坑,值得分享:
第一个坑:把constructor里的page声明成普通属性而不是 readonly。有个测试文件里有人意外给page重新赋值了,结果是整个测试会话的对象被替换,后面所有操作都失效,排查了快一小时。改成protected readonly page之后,编译器立刻把这个错误拦截掉。这个教训让我养成了一个习惯:依赖注入进来的核心对象,一律声明为 readonly。
第二个坑:过度使用抽象成员。第一版设计里,我几乎把所有方法都变成了 abstract,比如getUsernameInput、getPasswordInput等等。结果子类代码堆成山,每个页面都在重复实现相同的选择器逻辑。后来才明白,抽象方法只应该留给“不同子类真正有差异的行为”,相同的行为放基类实现就好。我把公共选择器逻辑放回基类,子类只保留 URL 和高级操作动词,代码量瞬间少了一半。
第三个坑:基类方法全部不设访问修饰符,默认为 public。这让测试用例可以直接调用waitForReady、locator这些内部方法,接口暴露面变大,改起来也容易破坏封装。后来把内部方法统一改成 protected,对外只留高级动词,测试代码变得非常整洁,可读性提升一个档次。
这套模式不仅适用于 playwright,凡是做页面对象模式(Page Object Model)的场景基本都能套用。它的核心收益是:测试代码越来越薄,页面结构变了只改页面类,基类的公共逻辑只维护一份。
7. 从“会用”到“讲清楚”:面试题、常见雷区与我的建议
7.1 高频面试题速查:这几个问题别答漏
基于近期搜得比较多的 typescript 面试关键词,我整理了一个速查表。面试官问来问去,核心就是这几道,答案背后的逻辑你都能在这篇文章里找到:
| 面试问题 | 关键回答要点 |
|---|---|
| 抽象类和普通类有什么区别 | 不能 new、可以有抽象成员、子类必须实现所有抽象成员、子类不实现就得继续是抽象类 |
| 抽象类和接口有什么区别 | 接口只约束形状,抽象类还能共享实现;接口可多实现,抽象类只能单继承;接口无构造函数无实现 |
| private 和 protected 区别 | private 仅本类,protected 本类和子类;运行时都不生效,仅编译期 |
| readonly 能不能在构造函数外赋值 | 不能,在构造函数里赋值是最后的机会 |
| interface 怎么继承 | extends 接口可以多继承;继承类时有私有成员的坑;覆盖属性时不能收窄类型 |
| 参数属性是什么 | 构造参数加修饰符自动声明属性,等于省略了声明和赋值两步 |
| 一个类能不能同时 implements 多个接口 | 能,接口可以多实现 |
| 抽象方法能不能有方法体 | 不能,只有签名;如果加了实现,它就不再是抽象方法了 |
这些问题单独看都简单,难的是面试官会连环追问。比如你先回答“抽象类不能 new”,他会追问“那构造函数还有用吗”;你先说“抽象方法没有实现”,他会问“那子类不实现会怎样”。我的建议是把每个答案背后的原理链都过一遍,而不是死记结论。
7.2 实操中容易踩的雷:四个高频报错场景
日常写代码的时候,我经常在同事的 PR 里看到下面这些典型错误。提前知道这些坑,能帮你省下不少排查时间。
第一个是“忘了实现抽象成员导致子类无法实例化”。TS 会报一条“Non-abstract class X does not implement inherited abstract member Y”的错误。解决办法有两个:要么在子类里补上实现,要么把子类也声明成 abstract。多数情况下你应该补实现,因为如果子类都不实现,那它大概率是不需要被实例化的中间层。
第二个是“继承抽象类时调用 super 报错”。只要抽象类有构造函数,子类构造函数就必须先调用super(...)。这个规则和普通类一样,但很多人以为“抽象类不能 new 那就不用 super 了”,这是误解。抽象类的构造函数由子类实例化时触发,它照样能完成公共字段的初始化。
第三个是“在外部访问 protected 成员被拦截”。这个问题最隐蔽:子类可以通过this访问继承来的 protected 成员,但不能用“父类实例”去访问。比如子类里写new Parent().protectedProp会报错,因为 protected 的“可见范围”是当前实例的继承链,而不是父类的所有实例。这是 TS 一个非常容易出现直觉偏差的规则。
第四个是“interface 继承带私有成员的类后无法被对象字面量实现”。前面已经提到过:如果类里有 private 成员,那么这个接口只能由该类的子类实现。很多人被这条规则卡住后,错误的解法是给接口也补一个私有成员——这是做不到的,接口里根本不允许声明 private。正确的做法是重新设计类型结构,别让接口直接继承带私有成员的类。
7.3 最后分享一点我自己的使用心得
写 TypeScript 这么多年,我对抽象类和修饰符的态度有过一次明显转变。早期写代码的时候,我恨不得把所有成员都标上 private,把所有需要扩展的基类都写成 abstract,恨不得让编译器替我约束一切。结果项目越写越累,团队成员纷纷表示“这个 base class 动都不敢动”。
后来我慢慢意识到,抽象类和修饰符都是工具,它们约束的是“设计边界”,而不是“自由度”。如果基类里抽象成员太多、protected 方法太细,子类实现的负担就会很重,反而抑制了扩展性。好的抽象类应该像一张高完成度的图纸:能画好的部分尽量画好,留给别人的空白尽量小而精。抽象成员的数量控制在个位数,每个抽象成员都有明确的语义,而不是把每一个变体都漏出来让子类自己折腾。访问修饰符的使用也是一样:不是越严越好,而是“该公开的公开,该保护的保护,该私有的私有”,边界清晰才是关键。
如果你现在正准备 TypeScript 面试,或者刚开始用 playwright 写自动化,我给你的建议是先拿一个小项目重构练练手:把你的公共基类抽出来,试着把能做好的部分写在基类里,把真正有差异的部分设计成抽象成员,再想想每个方法应该对外还是对内。亲手做完这一轮,你对抽象类和修饰符的理解会比背十篇面试题都扎实。