根据所消除的复杂性选择 TypeScript 模式
深入探讨经典设计模式与 TypeScript 的类型级技术,明确指出何时该使用这些技巧,以及何时普通代码更为合适。
大多数团队会使用 TypeScript 来实现自动补全和拼写检测,随后才会发现它的真正价值:它能让您将设计决策编码下来,从而由编译器来强制执行这些决策。本指南将介绍在大型前端及全栈代码库中最为重要的经典面向对象模式与类型级技术,并说明如何判断某种模式是否值得采用。
可以把类型系统视为能够回答架构相关问题的工具:
- 这些状态值的组合真的可行吗?
- 这个 API 返回的格式会不会超出其他代码的预期?
ProductId会误传给需要UserId的函数吗?- 组件会不会被赋予相互矛盾的属性?
- 如果新增了某种状态,所有使用该状态的代码是否都必须处理它?
any的情况下重用抽象层?模式并非可以随意添加的功能,而是当重复出现的问题出现时,人们对解决方案的命名。
格局概览
传统分类涵盖了创建型、结构型和行为型模式;TypeScript则基于泛型、联合类型及安全工具,增加了第四类类型级技术。
TYPESCRIPT PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
CREATIONAL STRUCTURAL BEHAVIORAL
│ │ │
├─ Factory ├─ Adapter ├─ Strategy
├─ Builder ├─ Facade ├─ Observer
├─ Singleton ├─ Decorator ├─ Command
└─ Abstract Factory ├─ Repository └─ State
└─ Composition
+
TYPESCRIPT TYPE PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
Generics Unions Type Safety
│ │ │
├─ Constraints ├─ Discriminated ├─ Type Guards
├─ keyof Unions ├─ Branded Types
├─ typeof ├─ Result Types ├─ Exhaustiveness
├─ infer └─ State Modeling └─ satisfies
└─ Mapped Types
实际应用中很少只使用一种模式。典型的 React 数据流会组合多种模式,而且每个边界都可以指定精确的类型:
React Component
│
▼
Custom Hook
│
▼
Service
│
▼
Repository
│
▼
API Client
│
▼
Result<T, E>
│
▼
Discriminated Union
当类型沿着整个链条传递时,TypeScript 就成了描述系统行为规则的工具。
从问题出发,而非从模式入手
一个常见的陷阱是朝错误的方向进行推理:
"I know Factory Pattern.
Where can I use Factory?"
先选定某种模式会导致产生没人需要的抽象概念。应当让问题来驱动选择:
What problem do I have?
↓
Where is the complexity?
↓
What is changing frequently?
↓
What should remain stable?
↓
What abstraction reduces that complexity?
↓
Is a known pattern appropriate?
考虑一种支付流程,其中对支付方式进行了一系列检查:
if (paymentMethod === "card") {
// ...
}
if (paymentMethod === "paypal") {
// ...
}
if (paymentMethod === "upi") {
// ...
}
人们的本能反应往往是这样的:
Don’t immediately think:
“这需要策略模式。”在采用它之前,先问问这些分支是否真的可以视为同一契约下的可互换算法。如果是的话,策略模式就适用。但如果实际问题是某些字段仅对某些方法有意义,那么使用能避免无效组合的区分联合类型可能才是更好的选择。了解这些模式类别很容易,关键在于将具体场景与合适的模式匹配起来。
创建型模式
单例:一个共享实例
单例模式确保一个类只有一个实例。私有构造函数阻止了在 new 之外的实例创建,而静态方法则会在需要时才创建该实例并将其缓存:
class Logger {
private static instance: Logger;
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string) {
console.log(message);
}
}
调用方直接获取共享对象,而非自行创建实例:
const logger = Logger.getInstance();
logger.log("Application started");
所有使用该模式的对象最终都指向同一个实例:
Logger
│
getInstance()
│
▼
┌───────────┐
│ Logger │
│ Instance │
└───────────┘
▲ ▲
│ │
Service A Service B
合适的应用场景包括:
- 日志记录功能
- 分析管理器
- 配置管理器
- 某些连接管理器
- 其他跨领域的基础设施服务
问题在于,单例实际上是一种伪装成全局访问的方式:测试更难隔离,依赖关系从接口中消失,生命周期变得模糊不清,而且共享的可变状态会以无法追踪的方式发生变化。在现代前端中,从 ES 模块导出的实例本身就是共享的,而依赖注入、React Context 或状态管理库则通过清晰的连接方式提供同样的保障。
工厂模式:隐藏具体要创建的类
工厂模式将决定实例化哪个具体类的任务从使用者手中移开。对比直接构造的方式:
const payment = new StripePayment();
与委托构造的方式相比:
const payment = PaymentFactory.create("stripe");
这两种方式都实现了同一个接口,工厂模式则将字符串字面量联合类型映射到对应的类。由于参数类型为 "stripe" | "paypal",不支持的名称会导致编译失败:
interface PaymentProvider {
pay(amount: number): Promise<void>;
}
class StripePayment implements PaymentProvider {
async pay(amount: number) {
console.log("Stripe:", amount);
}
}
class PayPalPayment implements PaymentProvider {
async pay(amount: number) {
console.log("PayPal:", amount);
}
}
class PaymentFactory {
static create(
provider: "stripe" | "paypal"
): PaymentProvider {
switch (provider) {
case "stripe":
return new StripePayment();
case "paypal":
return new PayPalPayment();
}
}
}
从视觉上看,工厂模式是由提供者名称来标识的叉形结构:
PaymentFactory
│
┌────────────┴────────────┐
│ │
"stripe" "paypal"
│ │
▼ ▼
StripePayment PayPalPayment
以下情况适合使用工厂模式:
- 对象创建涉及实际逻辑
- 多个实现共享同一接口
- 使用者不应知晓具体类信息
- 各实现需独立于调用方发展
对于像下面这行代码这样简单的创建场景,使用工厂模式只会增加一层阅读负担:
new User();
抽象工厂:相关对象族
抽象工厂模式将这一理念扩展到必须保持一致的多个对象组。在面向多平台的UI组件库中,网页按钮绝不能与移动端模态框搭配使用,因此每个工厂都会生成一个风格统一的对象族:
interface Button {
render(): void;
}
interface Modal {
open(): void;
}
interface UIFactory {
createButton(): Button;
createModal(): Modal;
}
UIFactory
│
┌────────┴────────┐
▼ ▼
WebUIFactory MobileUIFactory
│ │
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
Button Modal Button Modal
它的功能强大,但容易过度设计。在大多数前端应用中,简单的组件组合就能以更简洁的方式实现目标。
构建器:可控的逐步构造
当一个对象具有许多可选设置或需要分阶段配置时,构建器能提供帮助。每个设置方法都会更新私有配置并返回 this,从而支持链式调用:
class RequestBuilder {
private config: RequestInit = {};
setMethod(method: string) {
this.config.method = method;
return this;
}
setHeaders(headers: HeadersInit) {
this.config.headers = headers;
return this;
}
setBody(body: BodyInit) {
this.config.body = body;
return this;
}
build() {
return this.config;
}
}
组装请求的过程表现为一系列明确的步骤:
const request = new RequestBuilder()
.setMethod("POST")
.setHeaders({
"Content-Type": "application/json"
})
.setBody(JSON.stringify(data))
.build();
流畅的语法只是附带效果,并非最终目标。关键在于将复杂的构造过程以明确的方式集中在一处,同时让 build() 方法能够进行验证,例如在 GET 请求中拒绝接收请求体。
结构模式
适配器:为他人 API 提供稳定的接口
适配器是应用程序代码中最常用的模式之一。假设你的代码期望遵循这样的内部契约:
interface PaymentGateway {
pay(amount: number): Promise<void>;
}
而第三方SDK提供的方法名称却不同:
class LegacyPaymentSDK {
makePayment(value: number) {
// third-party implementation
}
}
一个简单的适配器实现了你的接口并转换调用请求:
class PaymentAdapter implements PaymentGateway {
constructor(
private readonly sdk: LegacyPaymentSDK
) {}
async pay(amount: number) {
this.sdk.makePayment(amount);
}
}
Application
│
▼
PaymentGateway
▲
│
PaymentAdapter
│
▼
Third-party SDK
这样,应用程序就只依赖于你自己的接口:
PaymentGateway
而无需依赖背后的各个供应商提供的接口:
Stripe
PayPal
LegacySDK
SomeFutureProvider
要支持新的服务提供者,就需要编写另一个适配器。
门面模式:实现多步骤工作流的单一调用
门面模式在复杂的子系统前提供一个简单的入口点。例如用户登录可能会涉及多个服务:
Authentication
+
User Service
+
Permissions
+
Notification
+
Analytics
如果没有门面模式,每个负责用户登录的组件都需自行协调整个流程:
auth.login();
user.load();
permission.load();
analytics.track();
门面模式则将这一协调过程统一处理:
class AppFacade {
async login(username: string, password: string) {
const token = await auth.login(username, password);
const user = await userService.getUser(token);
await permissionService.load(user);
analytics.track("login");
return user;
}
}
此时该组件会简化为仅一次调用:
await appFacade.login(username, password);
Component
│
▼
AppFacade
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Auth User Permission
Service Service Service
当步骤顺序至关重要时,这种方法优势明显。
装饰器:通过包装添加功能
装饰器能够在不改变原有实现的情况下添加新功能,因为包装对象与被包装对象拥有相同的接口。其约定如下:
interface Logger {
log(message: string): void;
}
一个简单的实现方式:
class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}
一种能够持有任意Logger实例并在消息前添加时间戳的装饰器:
class TimestampLogger implements Logger {
constructor(
private readonly logger: Logger
) {}
log(message: string) {
this.logger.log(
`[${new Date().toISOString()}] ${message}`
);
}
}
包装实际上只是对象构造的过程:
const logger = new TimestampLogger(
new ConsoleLogger()
);
由于每个装饰器本身也是一个Logger,因此它们可以堆叠使用:
Logger
│
▼
ConsoleLogger
│
▼
TimestampLogger
│
▼
AdditionalDecorator
同样的概念还有其他名称:
- 中间件链
- 包装函数
- React高阶组件
- 日志记录
- 缓存层
- 授权检查
行为模式
策略模式:可互换的算法
策略模式可以消除分支式的业务规则。以客户等级类型为例:
type CustomerType =
| "regular"
| "premium"
| "enterprise";
条件实现方式会将所有规则放在同一个函数中:
function calculateDiscount(
type: CustomerType,
price: number
) {
if (type === "regular") {
return price;
}
if (type === "premium") {
return price * 0.9;
}
return price * 0.8;
}
采用策略模式后,每个规则都会成为共享接口背后的一个类:
interface DiscountStrategy {
calculate(price: number): number;
}
class RegularDiscount implements DiscountStrategy {
calculate(price: number) {
return price;
}
}
class PremiumDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.9;
}
}
class EnterpriseDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.8;
}
}
Order
│
▼
DiscountStrategy
│
┌───────────┼───────────┐
▼ ▼ ▼
Regular Premium Enterprise
Strategy Strategy Strategy
现在,添加新的等级通常意味着要新增一个策略类而非修改现有逻辑,这正是开闭原则的实践体现。对于仅有三条简单规则的场景,条件实现已经足够;而当规则开始产生自身依赖或测试需求时,策略模式的优势才会显现。
观察者模式:一对多通知
观察者模式允许某个主题在状态发生变化时向任意数量的监听者发送通知:
Subject
│
┌──────────┼──────────┐
▼ ▼ ▼
Observer A Observer B Observer C
一种小型类型化发射器将监听器存储在Set中,当有监听器被注册时会返回一个清理函数:
type Listener<T> = (value: T) => void;
class EventEmitter<T> {
private listeners = new Set<Listener<T>>();
subscribe(listener: Listener<T>) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
emit(value: T) {
this.listeners.forEach(listener => {
listener(value);
});
}
}
const emitter = new EventEmitter<string>();
const unsubscribe = emitter.subscribe(message => {
console.log(message);
});
emitter.emit("Hello");
unsubscribe();
该清理函数负责生命周期管理。若忘记调用它,将会导致:
- 监听器存活时间超过其所属对象,从而引发内存泄漏
- 同一个监听器被多次添加时出现重复处理
- 闭包使用过时的值
- 组件已销毁后仍触发副作用
在React中,这正是从useEffect回调函数中返回的内容。
命令:以对象形式表示动作
命令会将动作转换为具有统一接口的对象:
interface Command {
execute(): void;
}
具体的命令实现该功能:
class SaveCommand implements Command {
execute() {
console.log("Saving...");
}
}
class UndoCommand implements Command {
execute() {
console.log("Undo");
}
}
将动作视为值在以下场景非常有用:
- 记录发生过的操作历史
- 实现撤销和重做功能
真正的撤销操作通常需要每个命令都能自行反向执行,因此生产版本往往会在execute()方法之外添加一个undo()方法。
User Action
│
▼
Command
│
├── execute()
│
▼
Receiver
数据与组合模式
Repository:隔离数据访问
当数据访问变得复杂时,Repository会在业务逻辑与存储或获取数据的组件之间建立一种契约:
UI
│
▼
Hook / Controller
│
▼
Service
│
▼
Repository
│
├── REST
├── GraphQL
├── IndexedDB
└── Cache
该契约规定了应用程序可以请求什么内容:
interface UserRepository {
getUser(id: string): Promise<User>;
getUsers(): Promise<User[]>;
}
有一种实现方式是通过REST API进行通信:
class ApiUserRepository implements UserRepository {
async getUser(id: string) {
const response = await fetch(`/users/${id}`);
return response.json();
}
async getUsers() {
const response = await fetch("/users");
return response.json();
}
}
业务代码依赖于该接口:
UserRepository
而不依赖于传输层:
fetch()
axios()
graphqlClient()
当源代码发生变化时,这一点尤为重要:从 REST 更改为 GraphQL、添加 IndexedDB 缓存,或为测试创建内存中的模拟数据。需要注意的是,response.json() 返回的是未指定类型的值,因此也在仓库中验证响应是合适的选择。
组合优于继承
在前端开发中,组合模式比任何基于继承的模式都更为重要。不必让一个组件承担所有功能:
MegaComponent
├── Authentication
├── Table
├── Filters
├── Modal
├── Notifications
├── API calls
└── Business logic
应将职责拆分成独立的模块:
Dashboard
├── Header
├── Sidebar
├── FilterPanel
├── DataTable
└── NotificationPanel
在 React 中,这只需通过嵌套组件来实现:
<Dashboard>
<Header />
<Sidebar />
<MainContent />
</Dashboard>
每个模块都可以独立被理解、测试和替换。关于如何拆分过于庞大的组件,可参阅使用组合与插槽解决 React 属性过载问题。
利用类型系统建模状态
从现在开始,TypeScript 本身就成了架构工具。
用于请求状态的区分联合类型
一个请求会经历多个阶段,每个阶段携带不同的数据。区分联合类型为每个阶段定义独立的结构,通过 status 字段将它们关联起来:
type RequestState =
| {
status: "idle";
}
| {
status: "loading";
}
| {
status: "success";
data: User[];
}
| {
status: "error";
error: string;
};
利用区分符可以缩小每个分支中的类型范围:
function render(state: RequestState) {
switch (state.status) {
case "idle":
return "Nothing started";
case "loading":
return "Loading...";
case "success":
return state.data;
case "error":
return state.error;
}
}
编译器会记录每次检查后还存在哪些字段:
status = "success"
↓
data exists
status = "error"
↓
error exists
可参考常见的布尔值与可选值的模型:
interface State {
loading: boolean;
data?: User[];
error?: string;
}
没有任何限制阻止其描述那些本不应出现的状态:
{
loading: true,
data: [...],
error: "Something failed"
}
联合类型使得这类组合难以表达。应当对有效状态进行建模,而非随意散布可选属性并依赖他人正确组合它们。
明确失败时的结果类型
许多操作只有两种结果:
Success
OR
Failure
Result类型通过布尔值来明确表示这两种结果:
type Result<T, E> =
| {
success: true;
data: T;
}
| {
success: false;
error: E;
};
返回该类型的函数:
function getUser(): Result<User, string> {
return {
success: true,
data: user
};
}
调用者必须在访问data或error之前检查success:
const result = getUser();
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}
Service
│
▼
Result<T, E>
/ \
/ \
▼ ▼
Success Failure
│ │
data error
这适用于诸如“邮箱已注册”之类的预期业务故障。异常则更适合处理真正的错误和基础设施故障;Result类型只是让这些预期中的故障在接口中清晰可见。
泛型与类型级工具
泛型保持输入与输出之间的关联
使用any会丢失相关信息:
function identity(value: any): any {
return value;
}
类型参数则能保留这些信息:
function identity<T>(value: T): T {
return value;
}
推断出的结果与参数保持一致:
const a = identity("hello");
// string
const b = identity(100);
// number
输入与输出之间的关系依然存在:
Input T
│
▼
Function<T>
│
▼
Output T
泛型还能让共享的契约被重复使用。一个响应封装体:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
可描述多种数据内容:
type UserResponse =
ApiResponse<User>;
type ProductResponse =
ApiResponse<Product>;
约束:要求特定的结构
这样会导致编译失败:
function getId<T>(item: T) {
return item.id;
}
因为没有告诉 TypeScript T 具有 id 属性。约束可以提供这一保证:
function getId<T extends { id: string }>(
item: T
) {
return item.id;
}
任何包含字符串 id 的对象都会被接受,包括其额外的字段:
getId({
id: "123",
name: "Hareesh"
});
应这样理解该约束:
T can be anything
BUT
T must have id: string
keyof 与索引访问
给定一个接口:
interface User {
id: string;
name: string;
age: number;
}
keyof 会生成该接口所有属性名称的并集:
type UserKey = keyof User;
"id" | "name" | "age"
将keyof与第二个类型参数以及索引访问类型T[K]结合使用,可以生成一个返回类型与键相匹配的访问器:
function getProperty<T, K extends keyof T>(
object: T,
key: K
): T[K] {
return object[key];
}
const user = {
id: "1",
name: "Hareesh",
age: 30
};
getProperty(user, "name");
真实的键可以成功编译:
getProperty(user, "name");
缺失的键会导致编译错误:
getProperty(user, "salary");
其优势在于三种工具的协同作用:
Generics
+
keyof
+
Indexed Access
映射类型:转换现有类型
映射类型通过遍历类型的键来构建新类型。从以下方式开始:
interface User {
id: string;
name: string;
email: string;
}
可以派生出全部为可选属性的版本:
type OptionalUser = {
[K in keyof User]?: User[K];
};
在概念上等同于以下写法:
{
id?: string;
name?: string;
email?: string;
}
这就是诸如Partial这样的内置类型被定义的方式。
条件类型:在类型层面进行决策
条件类型根据属性的可赋值性在两种类型之间进行选择:
T extends U ? X : Y
这个功能会解析数组元素类型,而不对其他内容进行任何处理:
type Flatten<T> =
T extends Array<infer U>
? U
: T;
type A = Flatten<string[]>;
// string
type B = Flatten<number>;
// number
此时,类型系统表现得就像一种小型编译时语言,因此需要格外谨慎。
infer:提取类型的一部分
在条件类型中,infer会声明一个类型变量,TypeScript会通过匹配来为该变量赋值。这实际上是对内置的ReturnType功能的重新实现:
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never;
将其与typeof结合使用,可以从现有函数中推导出类型,从而确保其始终与实现保持一致:
function getUser() {
return {
id: "1",
name: "Hareesh"
};
}
type User = MyReturnType<typeof getUser>;
库中的类型定义在很大程度上依赖于此功能。
先了解内置的实用类型
在编写复杂的辅助函数之前,应先了解语言本身自带的功能:
Partial
Required
Readonly
Pick
Omit
Record
Exclude
Extract
NonNullable
ReturnType
Parameters
InstanceType
Awaited
例如,删除敏感字段只需一行代码,而无需使用会导致不同步的重复接口:
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser =
Omit<User, "password">;
TypeScript内置的实用类型指南对这些问题进行了深入讲解。
键集固定的Record
Record非常适合用于查找和配置操作,尤其是当键来自字面量联合类型时:
type Permission =
"read" |
"write" |
"delete";
type PermissionMap =
Record<Permission, boolean>;
const permissions: PermissionMap = {
read: true,
write: false,
delete: false
};
如果遗漏了某个权限,TypeScript会报告该缺失的键。而宽泛的键类型则无法提供这种保障:
const permissions: Record<string, boolean>
因为Record<string, boolean>几乎可以接受任何字符串键,所以遗漏或拼写错误往往不会被发现。
标识符与不可信数据的安全处理模式
领域标识符的专用类型
两个标识符都可以是字符串,但含义可能不同:
const userId: string;
const productId: string;
从结构上来看,TypeScript 无法区分它们。将 string 类型与虚拟属性结合会生成不同的类型:
type UserId =
string & {
readonly __brand: "UserId";
};
type ProductId =
string & {
readonly __brand: "ProductId";
};
此时函数就会要求使用正确类型的标识符:
function getUser(id: UserId) {}
function getProduct(id: ProductId) {}
在需要 UserId 的地方传入 ProductId 会导致编译错误。该品牌在运行时并不存在;你需要通过一个简单的构造函数或验证函数来统一处理类型转换,从而创建出对应的品牌值。从概念上讲:
string
│
├── UserId
├── ProductId
├── OrderId
└── TransactionId
在那些有数十个标识符共享同一原始类型的庞大系统中,这种方式尤为有用。
针对 unknown 输入的类型守卫
来自网络、存储或用户输入的数据应以 unknown 类型进入系统:
const data: unknown = await response.json();
类型转换似乎是一种便捷的捷径:
const user = data as User;
改用用户自定义的类型守卫来限定值的范围;其 value is User 的返回类型能告知编译器 true 结果意味着什么:
function isUser(
value: unknown
): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
if (isUser(data)) {
console.log(data.name);
}
请记住,类型在运行时会被消除。像这样的接口:
interface User {
id: string;
}
不会验证 API 发送的任何内容。上述守卫仅检查键是否存在,而不检查其类型,因此对于不可信的输入对,应结合 TypeScript 与运行时模式验证器使用。
利用 never 进行全面检查
这是该语言中最有效的组合之一:
Discriminated Union
+
never
+
switch
使用状态联合类型:
type Status =
| "loading"
| "success"
| "error";
以及一个只接受 never 的辅助函数:
function assertNever(
value: never
): never {
throw new Error(
`Unexpected value: ${value}`
);
}
当所有成员都处理完毕后,默认分支会遇到 never 类型,因此该调用可以成功编译:
function render(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
现在有同事扩展了这个联合类型:
Now imagine someone adds:"cancelled" to Status.
默认分支会收到“cancelled”这个值,它无法赋给“never”,因此除非处理该情况否则构建会失败。编译器就变成了一个需要记住所有使用者的设计审查者。
模板字面量类型
TypeScript可以从其他类型组合出字符串字面量类型:
type Entity =
"user" |
"order" |
"product";
type Event =
`${Entity}:created` |
`${Entity}:updated` |
`${Entity}:deleted`;
最终得到的联合类型包含了所有可能的组合:
user:created
user:updated
user:deleted
order:created
order:updated
order:deleted
product:created
product:updated
product:deleted
实际应用包括:
- 事件名称
- 分析事件键
- 权限字符串
- 路由模式
- 功能标志名称
- 设计系统令牌
当值是真实数据源时使用as const
字符串数组字面量会被展开:
const roles = [
"admin",
"editor",
"viewer"
];
其类型为 string[]。加上 as const 后会得到一个只读的字面量元组:
const roles = [
"admin",
"editor",
"viewer"
] as const;
由此可直接得出联合类型:
type Role =
typeof roles[number];
becomes:
"admin" |
"editor" |
"viewer"
只需定义一次值,无需手动维护并行联合类型。
用于检查配置的 satisfies
satisfies 会在不将表达式转换为目标类型的情况下对其进行类型验证:
type Config = {
retries: number;
environment:
| "development"
| "production";
};
const config = {
retries: 3,
environment: "production"
} satisfies Config;
该对象会按照 Config 的类型进行检查,而 config.environment 仍保持字面量类型 "production",这是普通注解所无法做到的。它适用于:
- 路由配置
- 功能标志
- 设计令牌
- 静态设置对象
- 权限映射
依赖注入
在类内部创建依赖关系会使其与这些依赖紧密绑定:
class UserService {
private api = new ApiClient();
}
通过构造函数接收依赖则能使类保持无关性:
class UserService {
constructor(
private readonly api: ApiClient
) {}
}
生产环境会传入真实的客户端:
const service =
new UserService(apiClient);
而测试时则使用模拟对象:
const service =
new UserService(mockApiClient);
UserService
▲
│
Dependency
│
┌────────┴────────┐
▼ ▼
ApiClient MockApiClient
Production Testing
这是实现可测试代码的最简单方法之一,仅需构造函数参数即可。
区分相似模式
状态模式还是判别式联合?
考虑到订单的生命周期:
Draft
Paid
Shipped
Cancelled
判别式联合可能就是你所需要的全部:
type Order =
| { status: "draft" }
| { status: "paid" }
| { status: "shipped" }
| { status: "cancelled" };
但当每个状态都包含大量行为时:
Draft
├── edit()
├── submit()
Paid
├── refund()
├── ship()
Shipped
├── track()
└── deliver()
状态模式可能更为合适,因为每个状态对象都可以实现允许的操作。应依据各状态的复杂程度来决定,而非术语本身。
策略模式还是状态模式?
这是常见的面试话题。通过策略模式,客户端可以选择算法:
Order
│
▼
Strategy
├── CreditCard
├── PayPal
└── UPI
通过状态模式,对象在生命周期中会改变自身的行为:
Order
│
├── Draft
├── Paid
└── Shipped
策略模式决定了任务的执行方式;状态模式则决定了对象在当前阶段的操作内容。
工厂模式还是策略模式?
工厂模式回答的是“应该创建哪个对象?”:
PaymentFactory.create("stripe");
策略模式回答的是“应该运行哪种行为?”:
new Order(discountStrategy);
二者可以自然结合,由工厂模式生成负责具体工作的策略:
Factory
↓
creates
↓
Strategy
↓
executes behavior
在 React 中应用这些模式
使用变体属性而非布尔标志
独立的布尔值会导致诸如按钮同时具有“主要”和“危险”属性之类的不合理情况:
interface ButtonProps {
primary?: boolean;
danger?: boolean;
loading?: boolean;
}
通过变体联合,每个变体都可以声明自己的要求:
type ButtonProps =
| {
variant: "primary";
loading?: boolean;
}
| {
variant: "danger";
confirmationRequired: boolean;
};
危险变体现在必须指定 confirmationRequired。
能检测矛盾的组件 API
那些渲染链接或按钮且允许可选 href 和 onClick 的组件,可能会允许两者都存在或两者都不存在。下面的代码片段展示了宽松版本、带区分的替代方案以及正确用法:
{
href?: string;
onClick?: () => void;
}
use:
type ActionProps =
| {
type: "link";
href: string;
}
| {
type: "button";
onClick: () => void;
};
Now:
<Action
type="link"
href="/users"
/>
如果为链接变体提供了点击处理函数而非 href,则会被拒绝:
<Action
type="link"
onClick={...}
/>
明确列出的支持模式:
Link
Button
TypeScript 现在已成为组件架构的一部分,而非会过时的文档。
类型安全的事务总线
首先创建一个从事件名称到负载类型的映射:
type Events = {
"user:created": User;
"user:deleted": UserId;
"order:created": Order;
};
基于该映射的通用事务总线通过 keyof 和 T[K] 将每个名称与其负载关联起来:
class EventBus<T extends Record<string, unknown>> {
on<K extends keyof T>(
event: K,
handler: (data: T[K]) => void
) {
// implementation
}
emit<K extends keyof T>(
event: K,
data: T[K]
) {
// implementation
}
}
正确的有效载荷可以成功编译:
bus.emit("user:created", user);
错误的有效载荷则无法编译:
bus.emit("user:created", order);
此处有几种工具可用:
Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking
整个API层中的类型
成熟的前端通常会这样分层处理数据访问:
Component
↓
Hook
↓
Service
↓
Repository
↓
API Client
↓
HTTP
在每一步都保留类型信息。数据存储层可以接受特定格式的ID,并返回一个Result对象:
type ApiResponse<T> = {
data: T;
status: number;
};
interface UserRepository {
getUser(id: UserId):
Promise<Result<User, ApiError>>;
}
组件无需在每一层都处理这些问题:
any
接口签名本身就能说明哪些操作会成功、哪些会失败以及对应的类型。
应避免的反模式
any滥用
function process(data: any) {}
使用any类型的参数会关闭对其涉及所有内容的检查机制。建议采用:
function process(data: unknown) {}
并在使用前对类型进行限定。
将断言用作验证手段
const user =
response.data as User;
as类型转换不会进行任何检查;它只是要求编译器信任你。应在运行时对不可信的数据进行验证。
为通用性而使用泛型
应避免使用如下形式的签名:
function transform<
T,
U,
V,
R
>(...) {}
除非每个类型参数都体现了真实的关联关系。仅使用一次的类型参数通常是没有必要的。
庞大的联合体
包含上百个成员的区分联合体难以理解和维护。此时可能需要采用其他抽象方式,比如嵌套联合体或将变化逻辑嵌入数据中。
类型层面的“巧妙设计”
当某个类型的结构比它所描述的业务逻辑更复杂时,应当退一步重新审视。
Type complexity
│
▼
Developer complexity
│
▼
Maintenance cost
类型安全是有代价的。应追求在单位复杂度下实现最有用的安全性,而非最复杂的类型结构。
选择时的决策树
此图表将常见需求与候选模式对应起来:
PROBLEM
│
├── Need to create objects?
│ ├── Simple creation → Constructor
│ ├── Complex creation → Builder
│ ├── Multiple implementations → Factory
│ └── Families of objects → Abstract Factory
│
├── Need to integrate another system?
│ └── Adapter
│
├── Complex subsystem?
│ └── Facade
│
├── Add behavior without modifying object?
│ └── Decorator
│
├── Multiple interchangeable algorithms?
│ └── Strategy
│
├── Subscribers react to changes?
│ └── Observer
│
├── Need actions/history/undo?
│ └── Command
│
├── Data-access abstraction?
│ └── Repository
│
├── Complex state lifecycle?
│ └── State / Discriminated Union
│
└── Type-level problem?
├── Reuse → Generics
├── Transform → Mapped Types
├── Decision → Conditional Types
├── Extract → infer
├── Property safety → keyof
├── Literal safety → as const
├── Contract validation → satisfies
└── Domain safety → Branded Types
将其视为讨论的起点;“从简单开始”的原则在每个层面都适用。
首先应学习什么
对于准备担任高级或领导职位的前端工程师而言,记住所有的“四人组”模式是时间上的浪费。合理的学习顺序为:
核心内容:
Discriminated Unions
Generics
Type Guards
keyof
Mapped Types
Utility Types
Composition
Strategy
Repository
Factory
强烈推荐的下一步学习内容:
Result Type
Branded Types
Conditional Types
infer
Exhaustive Checking
Dependency Injection
Adapter
Facade
Observer
Decorator
值得从概念层面理解的内容:
Builder
Command
State
Abstract Factory
Singleton
日常前端工作中更多依赖的是这类组合:
Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository
而非任何教科书中的抽象工厂模式。
经验积累如何改变判断标准
初期,工程师会问“这里应该使用哪种模式?”。随着在大型系统上的经验积累,问题则转变为“在不增加系统理解难度的情况下,最简化的抽象层是什么?”。以模式为先的工作流程如下:
Problem
↓
Pattern
↓
More classes
↓
More abstractions
以问题为先的工作流程如下:
Problem
↓
Understand volatility
↓
Identify boundary
↓
Start simple
↓
Introduce abstraction only where repetition/change justifies it
当架构面临的压力变得清晰时,优秀的模式自然会出现;它们并非事先强加的。
考察真正理解程度的面试问题
“什么是工厂模式?”这类问题测试的是记忆能力,而那些问题则考验判断力。
架构相关问题:
- 面对一个有2,000行代码的React组件,如何决定引入哪种抽象层?
- 在什么情况下会拒绝使用看似合适的模式?
- 如何判断某种抽象层为时过早?
TypeScript 基础:
- 如何用加载、成功、错误和重试状态来建模一个请求?
- 如何避免 React 属性出现无效组合?
unknown、any和never有什么区别?- 何时选择
type而非interface,反之亦然? keyof如何与泛型配合使用?
高级 TypeScript:
- 条件类型的具体应用场景是什么?
infer的作用是什么?- 什么是映射类型?
- 什么是分配式条件类型?
- 何时使用标记类型比较合适?
satisfies能解决什么问题?
as const如何改变推理过程?实际设计场景:
- 设计一个类型安全的事件总线。
- 设计一个类型安全的API客户端。
- 使用TypeScript设计权限系统。
- 设计一个支持Stripe、PayPal以及第三方支付服务的抽象层。
- 如何在不重写代码的情况下为现有的React应用添加Repository层?
- 如何为具有不同负载数据的WebSocket事件定义类型?
- 如何防止在需要
UserId的地方传入ProductId?
一个能展现深度的问题:“请描述一次你刻意选择不使用设计模式的情况。”一个出色的回答会解释其中的权衡:由于只有单一实现且无需防范任何变更,使用间接方式并不会降低复杂性,因此代码保持简洁,直到出现第二个适用场景才有必要改变。
最后的思维模型
从业务问题出发,找出复杂性的来源,将行为变化与结构变化区分开来,再借助类型安全来优化结果。
BUSINESS PROBLEM
│
▼
Identify Complexity
│
▼
What is likely to change?
│
┌──────────┴──────────┐
▼ ▼
Behavior Structure
│ │
Strategy/State Adapter/Facade
│ │
└──────────┬──────────┘
▼
Type Safety
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Generics Unions Utilities
│ │ │
▼ ▼ ▼
keyof Result Type Mapped Types
infer State Model Conditional
satisfies Exhaustive Record
│ │ │
└───────────────┼────────────────┘
▼
SIMPLEER CODE
关键要点
模式作为共享词汇使用效果最佳:例如“这是一个适配器”、“这些行为可以互相替换”、“这些状态属于同一个联合体”、“给这些ID加上标记”,而最关键的是“这种抽象尚未具备其应有的复杂性”。优秀的 TypeScript 应该依据系统是否具备这些特性来评判,而非其类型设计有多巧妙:
Invalid states
↓
become difficult to represent
Changing implementations
↓
don't break consumers
Business rules
↓
are visible in the types
Shared behavior
↓
is reusable without duplication
Complexity
↓
is isolated behind clear boundaries
- 选择模式时应依据其能消除的复杂性,而非熟悉程度。
- 优先使用联合体、
Result类型以及全面检查,而非依赖可选字段和侥幸心理。 - 在运行时边界处验证数据;仅靠类型无法提供保护。
- 选择最简单的抽象方式来处理你实际期望发生的变更。