ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

TypeScript抽象类与修饰符:从原理到Playwright实战

2026/10/5 14:12:16 拓冰建站 浏览量
TypeScript抽象类与修饰符:从原理到Playwright实战 写代码这些年TypeScript 的抽象类和类的修饰符绝对是被问得最多、也最容易一带而过的基础点。面过 TypeScript 的人都知道抽象类和普通类的区别、抽象类和接口的区别几乎成了必考题而实际项目里真正会点抽象类的却不算多更不用说把 public、private、protected 组合出合理类结构的人了。这篇文章我就从自己写过的代码讲起把抽象类的定位、各类修饰符的实际作用、跟接口的比较全部捋一遍最后用我是怎么在 Playwright 测试框架里用抽象类搭 Page Object 的例子收尾。1. 抽象类到底是什么1.1 抽象类不是“不能实例化的普通类”那么简单很多人一听到抽象类就只知道一句话“不能 new”。但抽象类的价值不在“不能 new”而在于它定义了一套子类必须遵守的契约同时又把一些公共实现直接放在父类里避免每个子类重复造轮子。我举个例子。假设你在写一个数据导入系统有 Excel 导入和 CSV 导入两种方式。它们都要做文件读取、内容解析、数据清洗、入库这几步。其中“文件读取”和“入库”的逻辑基本一样“内容解析”却完全不同。这时候最偷懒也最容易出问题的写法是写两个互不相干的类class ExcelImporter { load() { /* 读取文件 */ } parse() { /* 解析 Excel */ } save() { /* 入库 */ } } class CsvImporter { load() { /* 读取文件代码跟上面几乎一样 */ } parse() { /* 解析 CSV */ } save() { /* 入库 */ } }代码重复得肉眼可见。但假如你强行用一个基类去继承又会碰到一个问题“解析”这一步没法统一写强行在基类里写实现子类再覆盖等于把契约变成“可选的约定”。抽象类就是专门解决这个矛盾的公共逻辑放基类差异逻辑定义为抽象方法子类不实现直接编译报错。abstract class DataImporter { load() { console.log(读取文件...); } save() { console.log(写入数据库...); } // 抽象方法子类必须实现解析逻辑 abstract parse(): void; import() { this.load(); this.parse(); this.save(); } } class ExcelImporter extends DataImporter { parse() { console.log(解析 Excel 格式...); } } class CsvImporter extends DataImporter { parse() { console.log(解析 CSV 格式...); } }这样后面的 import() 方法在基类里直接被复用子类只需要关心自己最核心的差异逻辑。把“必须有这一步”写进类型系统比任何团队文档都管用。谁要是 new ExcelImporter 的时候忘了写 parse编辑器直接飘红想提交都提交不上去。抽象类允许有具体方法也允许有属性、构造函数甚至是访问器。换句话说它是一张“半成品图纸”画好了公共部分留了几个关键坑位让你必须填。这个“半成品”设计几乎就是模板方法模式的核心基础。1.2 抽象类和普通类的边界在哪里单纯从语法上说普通类加一个 abstract 关键字就成了抽象类。但从语义和使用场景上看两者有非常清晰的差异对比项普通类抽象类实例化可以直接 new不能直接 new抽象方法不允许存在可以声明子类必须实现具体方法可以有可以有用途造具体对象定义骨架、约定契约子类约束无强制要求抽象成员必须全部实现否则子类也要声明为抽象类你可能会想那我的普通类加一个抛异常的占位方法子类不覆盖就报错不就模拟出抽象方法了吗技术上确实可以但问题在于这个约束是运行时的不是编译时的。忘掉覆盖代码能编译、能提交等跑到那一步才开始报错。抽象类把这个问题提前到了编译阶段这种“提前失败”的能力是普通类给不了的。还有一点很多人没意识到抽象类甚至可以不包含任何抽象方法。你完全可以写一个 abstract class BaseRepository {}里面全是具体实现只是出于某种设计意图不允许别人直接实例化它。这种情况在写基类、工具类时很常见比如说我要写一个只做继承不单独使用的 HttpClient 封装基类就可以这么干。2. 类的修饰符public、private、protected、readonly 的使用边界2.1 三个访问修饰符的权限对照TypeScript 的类修饰符里用得最多的就是 public、private、protected。很多人会背“public 谁都行private 只有自己protected 自己和子类”但一到实际设计就乱用特别是 protected经常被误用成“方便子类随便改”的借口。先看一个基准例子class Animal { public name: string; private age: number; protected id: string; constructor(name: string, age: number, id: string) { this.name name; this.age age; this.id id; } private secret() { console.log(内部秘密); } } class Dog extends Animal { test() { console.log(this.name); // OKpublic // console.log(this.age); // 报错private 只能在 Animal 内部访问 console.log(this.id); // OKprotected 允许子类访问 // this.secret(); // 报错private 方法子类不可见 } } const d new Dog(旺财, 3, A001); console.log(d.name); // OK // console.log(d.age); // 报错 // console.log(d.id); // 报错外部访问不到 protected这个例子里隐藏着一条很容易被忽略的规则private 只限制类内访问但不限制“通过实例方法内部逻辑”暴露出去。你可以在 Animal 里加一个 getAge() 方法外部虽然拿不到 age 字段但完全可以通过 getAge() 间接读取。所以 private 的核心价值是“禁止外部和子类直接触碰内部状态”而不是“数据绝对机密”。再看 protected它最容易让人产生错觉。protected 成员在子类里可以访问但它依然是“实例不可见”的。也就是说外部代码用实例访问 protected 属性会被编译拦截。很多新手把 protected 理解成“给子类用的 public”这没错但要记住它出现在子类内部是自由的跨出类外面就没门了。2.2 readonly 和 static 的搭配技巧修饰符不止访问控制那三个。readonly 用来做只读属性static 用来声明静态成员。两者单独用都不难难在组合使用的时候。readonly 最大的特点是只能在声明时或者构造函数里赋值其他地方一律改不了。这个特性和 class 里的初始化逻辑非常适合做配置项注入class Config { public readonly env: string; private static instance?: Config; constructor(env: string) { this.env env; } static create(env: string): Config { if (!this.instance) { this.instance new Config(env); } return this.instance; } }static 修饰符则和普通成员活在完全不同的空间里。静态成员属于类本身不属于实例。你访问一个 static 属性必须用类名比如 Config.create()而不是 this.create()除非是在静态方法内部。在实际代码里我最推荐的组合是 protected static readonly 一起用专门做“子类可访问、外部不可改、全局唯一”的常量定义。举个例子abstract class BaseApi { protected static readonly BASE_URL: string https://api.example.com; protected static readonly TIMEOUT: number 3000; }这样所有子类都能直接拿 BASE_URL 用外部代码想改也改不了既隔离又统一。你会觉得这有点像“全局变量”的优雅版本全局变量是人人能写这里只有你的类体系内部能读。2.3 TypeScript 的 private 和 JavaScript 私有字段 # 有区别吗这个点面试的时候经常有人踩坑。TypeScript 的 private 是编译期约束它只在类型检查的时候生效运行时的 JavaScript 代码里这个属性照样能通过 obj[age] 访问到。而 JavaScript 自带的私有字段语法 #age 是运行时真私有ES2022 之后原生支持没有类型系统也一样能拦住外部访问。写代码时你会遇到一个很实际的问题TypeScript 编译器对 private 的“同类型实例可见性”是放行的。什么叫同类型实例可见一个类内部可以直接访问另一个同类实例的 private 字段。比如class User { private password: string; constructor(password: string) { this.password password; } equals(other: User): boolean { return this.password other.password; } }这个写法在 TypeScript 里合法因为同属 User 的内部方法可以互访私有字段。这在写比较器、拷贝方法时很方便但也意味着 private 并不是“只能自己访问自己那一个对象”而是“只能这个类的内部代码访问”。至于该用 TS private 还是 JS 的 #我的建议是如果你的项目偏浏览器兼容、编译目标较老就老老实实用 TS 的 private如果 Node 版本已经支持 ES2022又需要真正的运行时隔离那 # 更保险。3. 抽象类 vs 接口3.1 两者的核心差异TypeScript 面试里十有八九会问到抽象类和接口的区别很多人背了一堆“接口是抽象的抽象”“接口全部方法都是抽象的”之类的话但一到实际场景就不知道怎么选。我从实用角度给你捋清楚。抽象类可以带实现接口约等于纯类型契约。这是两者最本质的区别。接口里描述的方法都没有函数体属性也都只有类型声明抽象类里既可以写抽象方法又可以写带逻辑的具体方法。所以你需要“把公共逻辑沉淀下来”的时候用抽象类你只是想让两个类“长得像”让编译器帮你验证结构用接口。此外TypeScript 的类只能继承一个抽象类但可以实现多个接口。这是语言层面的硬限制。在设计上“is-a”关系用抽象类“has-a”或“具备能力”用接口。比如汽车继承自“交通工具”抽象类但可以实现“可充电”接口、“可自动驾驶”接口。还有个隐蔽差异接口天然是隐式结构兼容的。这意味着一个对象只要结构满足了接口要求就算没写 implements 关键字也能直接赋给接口类型。抽象类没有这种待遇必须显式继承。这个特性让接口在写配置对象、传参约束时格外好用。下面给个对照表适合你随时翻对比项抽象类接口成员实现可以有具体实现只有类型声明继承数量只能继承一个可以实现多个构造函数可以有不可有访问修饰符支持 private/protected 等全部隐式 public运行时存在编译产物里存在编译后一般被擦除适用场景is-a、共享逻辑has-a、能力约束、结构类型3.2 接口怎么继承接口和类热词里有一条“typescript interface 怎么继承”这里我详细说。interface 用 extends 关键字可以继承另一个接口也可以继承多个接口只要结构兼容就行。interface HasName { name: string; } interface HasAge { age: number; } interface Person extends HasName, HasAge { email: string; }这种继承多接口的写法很常见本质上是在做组合把多个小契约拼成一个大契约。比在单个接口里堆一大堆字段可维护得多因为你可以在不同场景里只依赖小接口。更有意思的是interface 还能继承 class而且继承的是类的结构类型。它可以从普通类继承所有成员包括 private 和 protected 成员。但这样继承之后这个接口就只能被这个类本身或者其子类实现了。举个典型场景BasePage 里有个 protected 的 idPage 接口继承了 BasePage其他不相干的类就实现不了这个接口等于在类型层面保证了“拥有该能力的类才配实现这个接口”。class BasePage { protected id: string; constructor(id: string) { this.id id; } } interface ILoginPage extends BasePage { login(): void; } class LoginPage extends BasePage implements ILoginPage { constructor() { super(login); } login() { console.log(this.id); } }这里要提醒一句接口继承类的时候继承的是私有成员的类型约束不是运行时的逻辑。如果你用了一个不相干的类去 implements 这个接口会因为缺 private/protected 成员而报错。这种“舍不得又碰不到”的设计用途不算广但偶尔写框架代码时会救你一命。3.3 什么时候必须选抽象类而不是接口接口很好用但有些场景接口是接不住的。你需要在构造函数里注入依赖、定义初始状态时接口没法写构造函数。比如interface IRepository { apiClient: ApiClient; getAll(): Promiseunknown[]; } abstract class BaseRepository implements IRepository { constructor(protected readonly apiClient: ApiClient) { // 一次性初始化团队公用的请求逻辑 } abstract getAll(): Promiseunknown[]; }这里 BaseRepository 可以用 private/protected 修饰成员、统一构造函数参数、初始化基础字段接口 IRepository 只能描述“有哪些成员”。所以当你的基类需要“做事”而不只是“约束形状”抽象类就赢了。另外如果你想要默认的成员访问控制抽象类比接口强太多。接口里所有成员都是 public你没法要求子类的某个属性是 protected。抽象类可以用 protected 把子类专属的辅助方法藏起来只暴露该暴露的。反过来如果你只是要约束“某个参数必须有哪些字段”别犹豫直接用接口。type 也行但接口更语义化。抽象类在这种场景下就是杀鸡用牛刀还会硬生生拉出一条继承链增加耦合。4. 在实际项目里怎么用Playwright 与抽象类的组合4.1 为什么 Page Object 适合用抽象类热词里有一条“typescript playwright”这其实代表了现在前端自动化测试里很主流的组合。写 Playwright 测试的时候最经典的设计模式就是 Page Object Model核心思想是把页面上的元素定位和操作封装成类测试用例只跟页面类的方法打交道不去直接碰 selector。问题来了很多项目的页面之间有大量公共操作比如登录页、列表页、详情页都可能要等页面加载完成、处理弹窗、切换 iframe。把这些逻辑在每个页面类里复制粘贴维护起来就是灾难。用抽象类搭一个 BasePage再合适不过。import { Page } from playwright/test; export abstract class BasePage { protected readonly page: Page; protected constructor(page: Page) { this.page page; } // 每个页面必须实现前往该页面 abstract goto(): Promisevoid; // 公共方法等待网络空闲 protected async waitForNetworkIdle(): Promisevoid { await this.page.waitForLoadState(networkidle); } // 公共方法截图保存 async takeScreenshot(name: string): Promisevoid { await this.page.screenshot({ path: ${name}.png }); } // 公共方法统一的弹窗关闭逻辑 async closeDialogIfVisible(selector [data-testiddialog]) { const dialog this.page.locator(selector); if (await dialog.isVisible()) { await dialog.click(); } } }在这个例子里abstract goto() 强制每个页面类回答一个问题“你的入口 URL 是什么”。waitForNetworkIdle 和 closeDialogIfVisible 是所有页面通用的直接沉在基类里。takeScreenshot 则是半通用方法默认实现所有页面都能用子类如果你想要特殊逻辑也可以覆盖。这个设计把“必须部分”goto和“复用部分”截图、弹窗清清楚楚分开了。4.2 子类如何实现抽象方法假设你现在要写登录页的 Page Objectexport class LoginPage extends BasePage { constructor(page: Page) { super(page); } async goto(): Promisevoid { await this.page.goto(https://example.com/login); } async login(username: string, password: string): Promisevoid { await this.page.fill(#username, username); await this.page.fill(#password, password); await this.page.click(#submit); await this.waitForNetworkIdle(); } async assertLoginErrorVisible(): Promisevoid { await this.page.locator(.error-message).isVisible(); } }LoginPage 的 goto() 如果漏写TypeScript 会直接崩红。也就是说抽象类把“一个测试页面必须有入口方法”这件事固化在类型系统里了。团队里有新人只管照着报错补实现就行少一步都跑不掉。waitForNetworkIdle 被 protected 修饰这正好体现了 protected 的价值测试用例文件里不能直接调用 waitForNetworkIdle它只能是页面类内部或者子类内部的控制逻辑。你想在测试文件里等网络空闲得通过 LoginPage 里提供的 login() 方法去间接实现这个行为。这种封装让外部接口更干净改动内部实现时不会牵连测试用例。4.3 修饰符在测试框架里的常见设计除了 Page Object抽象类和修饰符在测试基建里还有很多组合玩法。比如你可以做一个抽象类 TestReporter里面有抽象方法 startTest、endTest再有一个具体方法 logError所有 reporter 子类继承后自动复用 logError只需要实现自己的开始和结束逻辑。static 修饰符也常用来管理测试环境的全局状态。比如abstract class TestConfig { protected static baseURL: string; static init(baseURL: string) { this.baseURL baseURL; } static get url(): string { return this.baseURL; } } TestConfig.init(https://test.example.com);这里的静态属性 baseURL 是 protected 的只允许子类和自己的静态方法用它。init 方法是 static 的全局只需要初始化一次。这个方法在 Playwright 的 config 文件里很实用你在 global setup 阶段从环境变量读配置然后所有 Page Object 都从 TestConfig 里拿 baseURL。我实际写过一段时间这类测试框架最大的体会是抽象类适合用来搭骨架修饰符用来给骨架里的成员分级授权两者配合起来你能把“必须实现的”“可以覆盖的”“不能动的”“外部看不到的”这几类成员分得清清楚楚。等到测试规模上来多页面、多角色、多环境时这套设计带来的维护优势会非常明显至少你不会因为改了某个公共方法的实现导致几十个测试用例同时崩。5. 用抽象类和修饰符时的常见坑5.1 构造函数的陷阱抽象类可以有构造函数但这里有个坑抽象类的构造函数只能被 super() 调用而且如果你在子类构造函数里忘了 super()编译会直接报错。看起来简单但遇到构造参数传递时就容易乱。比如 BasePage 需要注入一个 page 对象所有子类都要在自己的构造函数里接住 page 再传给 super(page)要是子类还想加自己的依赖一不小心就传错参数。建议的做法是把构造参数集中放到抽象基类里子类只透传。例如abstract class BasePage { constructor(protected readonly page: Page, protected readonly userId?: string) {} } class HomePage extends BasePage { constructor(page: Page) { super(page); // userId 可选不传也可以 } }readonly 在这里还有一个额外好处page 被安全地标记为“只读”你后续不会因为粗心重新给 this.page 赋值减少了测试代码里常见的偶发 bug。5.2 抽象方法不能有实现很多新手会把抽象方法写成带空函数体的形式比如abstract class Base { abstract getData(): unknown[] { return []; } }这是直接编译报错的。抽象方法只能有签名和返回值类型不能在基类里给默认实现。你要是想要“默认实现但允许覆盖”就写普通方法不要加 abstract 关键字。这个点面试时经常被拿来考人实际写代码时也特别容易写错。还有一点抽象方法的参数个数和默认值要非常小心。子类实现抽象方法时参数数量可以少于抽象方法的参数数量但不能多于而且参数类型必须是兼容的逆变关系。简单说如果你在抽象类里写 abstract load(path: string): void子类写 load(path: string, extra?: boolean) 是可以的但写 load(path: string, extra: boolean) 强制必填就不行因为调用者的使用方式是基于抽象签名的。5.3 protected 成员在静态端的坑protected 修饰符在实例成员上很好理解但一旦放到静态成员上就很容易出问题。看这个例子abstract class Base { protected static readonly CONFIG { retries: 3 }; } class Child extends Base { test() { console.log(Base.CONFIG); // 报错只允许通过子类访问不能通过基类直接访问 console.log(Child.CONFIG); } }你没看错子类的静态方法里用 Base.CONFIG 直接访问基类的 protected 静态成员是编译不通过的必须用子类自己的名字去访问。这个规则的用意是强制子类“把这个静态成员当作自己的继承成员”但实际写代码时我见过不少人在团队项目里踩这个坑。如果你想让子类能通过基类的名字读到常量就得把 protected 改成 public或者提供 public 的静态 getter。5.4 接口继承类时private 成员是个地雷前面讲了接口可以继承类但这里有一个特别容易把新手绊倒的细节。当你写 interface X extends SomeClass 的时候如果 SomeClass 里有 private 或 protected 成员那么这个接口 X 就只能被 SomeClass 及其子类实现别的类就算结构完全一致也会因为缺了私有成员而报错。这在类型层面是合理的因为你不能在外面自己声明一个私有字段。但很多人写代码时会惊讶地发现明明“看起来”实现了所有公开成员为什么编译器还在报错答案就是私有成员参与了类型结构虽然你看不见它但它就是决定接口可实现性的关键。遇到这种报错排查思路很简单要么类私有成员改成非私有要么别用接口继承类改成直接约束类型为这个类。6. 修饰符和抽象类在类型体操里的扩展玩法6.1 keyof 和修饰符的配合TypeScript 的类型系统会根据修饰符影响 keyof 的结果。public 和 private/protected 的区别直接关系到你能否遍历到某些属性。abstract class Base { public name: string ; protected id: string ; private secret: string ; } type PublicKeys keyof Base; // name这里 keyof Base 拿到的只有 public 成员。如果你想拿到 protected 或 private 成员的名字keyof 直接没戏。这种特性在做表单系统、动态表单字段映射时很有用你用 keyof 遍历一个类的可编辑字段私有字段会自然被排除在外。6.2 抽象类配合泛型做可复用基类抽象类不一定非要写得特别具体它跟泛型搭配起来能产生更强的约束力。比如你要封装不同数据库对应的仓储基类abstract class BaseRepositoryT, CreateDTO { protected abstract modelName: string; abstract create(dto: CreateDTO): PromiseT; async findById(id: string): PromiseT | null { // 这里可以作为公共逻辑 return null; } } class UserRepository extends BaseRepositoryUser, CreateUserDTO { protected modelName User; async create(dto: CreateUserDTO): PromiseUser { return { id: 1, ...dto }; } }泛型 T 让抽象方法返回具体类型子类实现时不仅落实了逻辑还落实了类型。CreateDTO 参数约束了入参。这套组合在写后端服务、状态管理 Store、前端仓储层时都非常实用。你等于在基类里定义了一条流水线但流水线里的每个环节类型由泛型参数决定。6.3 用修饰符实现真正的“可读不可写”readonly 在编译期很好用但它只限制 TypeScript 层面的赋值。如果你想在运行时也保持对外部修改的封闭需要结合 Object.freeze 或者 getter 只公开读取。一个比较稳的封装方式是class SafeConfig { private _retries: number 3; get retries(): number { return this._retries; } set retries(value: number) { if (value 0) { throw new Error(重试次数不能为负数); } this._retries value; } }这虽然不是抽象类的直接应用但它展示了修饰符和访问器结合后能做到的精细控制。私有字段 _retries 不对外暴露外界通过 getter/setter 间接读写setter 里还能加校验逻辑。配合抽象基类使用时你可以留抽象的配置校验方法让不同的子类实现不同的校验规则。7. 个人实操中的一点经验TypeScript 的抽象类和修饰符说到底是设计能力的体现。我刚开始写接口还是抽象类的时候也纠结过很久总想找到一个万能公式。后来踩了几次坑慢慢摸出一条经验先问自己“这个基类要不要共享代码逻辑”要就抽象类不要就接口。修饰符的选择也遵循“最小可见范围”原则能 private 就不要 protected能 protected 就不要 public。前期多花几分钟把可见范围收窄后期改代码的时候就会少很多不必要的牵挂。另一个很实用的小技巧是把抽象类里的“模板方法”设计好之后用关键字 search 一下子类实现里有没有遗漏的抽象方法。虽然编译器会报错但团队人数多、分支合并频繁时这类错误不一定每次都第一时间出现在你屏幕上。自己主动检查一遍比等 CI 报错更省心。另外多留意 Playwright 这类工具和抽象类结合的设计很多“复杂得没法维护”的测试代码其实只需要一个抽象基类加几个 protected 方法就能清爽很多。