
7 个 TypeScript 设计模式,治好你满屏的 if/else
去年我接手过一个电商商城的前端。
刚接手那会儿代码很干净。商品列表页 120 行,下单页 200 行,逻辑一眼能看完。
八个月后,商品列表页 640 行。里面有五个 useState、三段几乎一样的 fetch、一个 47 行的 switch——那个 switch 被三个人分别改过,每个人都只在末尾加了一个 case,谁也不敢动前面的分支。
没有人写烂过一行代码。 每一次改动单独看都很合理:产品说要加个会员价,那就加个 if;产品说筛选要支持时间范围,那就再加两个可选参数。
问题是这样的改动,八个月里发生了两百次。
下面我把这八个月里最典型的七次需求挑出来。每一次都讲三件事:产品说了什么、硬写会变成什么样、换个写法之后怎么样。
七次正好对应七个设计模式。但你不用记名字,记住场景就够了——下次产品跟你说类似的话,你自然会想起来。
code商城前端这八个月,痛点是这么长出来的
────────────────────────────────────
第 2 个月 加会员价 → 价格逻辑开始长 switch
第 3 个月 筛选条件变多 → 参数拼接变成三元表达式地狱
第 4 个月 下单后要发通知 → 三种通知的构造逻辑散落各处
第 5 个月 后端换接口 → 十几个组件同时要改
第 6 个月 接第二家支付 → 结算页出现两套 SDK 调用
第 7 个月 详情页要加缓存 → 业务函数里塞进重试和埋点
第 8 个月 购物车角标要联动 → props 穿过五层组件第 2 个月:加会员价
产品说:普通用户原价,会员打八五折。
硬写是这样的:
typescriptfunction getPrice(user: User, price: number): number {
if (user.isMember) {
return price * 0.85;
}
return price;
}这没问题。真的没问题,两个分支的时候这就是最好的写法。
但接下来半年里,这个函数被追加了三次:黑五全场五折、教育认证用户七折、新人首单再减 10 块。
于是它变成了那个 47 行的 switch。最要命的不是长,是每加一种优惠都得回到同一个函数里动手,而这个函数已经被商品列表、详情页、购物车、结算页四个地方调用了。改错一行,四个页面一起崩。
换个写法:别让收银员记住所有折扣规则,让每种规则自己是一张价格牌。
typescript// 一张"价格牌"长什么样
interface PricingRule {
calculate(price: number): number;
}
const regularPrice: PricingRule = {
calculate: (price) => price,
};
const memberPrice: PricingRule = {
calculate: (price) => price * 0.85,
};
const blackFridayPrice: PricingRule = {
calculate: (price) => price * 0.5,
};然后把所有价格牌挂在一张表上:
typescripttype UserLevel = 'regular' | 'member' | 'blackFriday';
const priceRules: Record<UserLevel, PricingRule> = {
regular: regularPrice,
member: memberPrice,
blackFriday: blackFridayPrice,
};
// 这个函数以后再也不用改了
function getPrice(level: UserLevel, price: number): number {
return priceRules[level].calculate(price);
}现在产品说要加教育优惠,你做两件事:在 UserLevel 里加上 'edu',在 priceRules 里加一行。getPrice 一个字不动。
这里有个 TypeScript 送的小福利:Record<UserLevel, PricingRule> 这个写法,会要求你这张表必须覆盖 UserLevel 里的每一种。你在 UserLevel 加了 'edu' 却忘了配规则,编译直接报错:
codeProperty 'edu' is missing in type '{ regular: ...; member: ...; blackFriday: ... }'
but required in type 'Record<UserLevel, PricingRule>'在 JavaScript 里这个错误你要等到线上有教育用户下单、价格算出 undefined 才会知道。
这套写法有个边界得说清楚:一个用户只能对应一种等级。如果哪天产品要"会员价基础上再叠加黑五",这张表就不够用了——那时候需要的是把多条规则串起来依次计算,是另一个话题。别指望一个模式包治百病。
这个模式的正式名字叫 Strategy(策略模式)。判断要不要用它,就问一句:这个分支以后还会不会继续加? 确定不会加的,
if/else挺好,别折腾。
第 3 个月:筛选条件变多了
产品说:列表页要支持按时间范围筛、按销量排序、还要能多选标签。
硬写是这样的:
typescriptconst params = new URLSearchParams({
page: String(page),
...(keyword ? { keyword } : {}),
...(sortBy ? { sortBy, order: order ?? 'desc' } : {}),
...(startDate && endDate ? { startDate, endDate } : {}),
...(tags.length ? { tags: tags.join(',') } : {}),
}).toString();这段代码还能读,但已经在临界点了。再加两个条件——比如"只看有货"和"价格区间"——就彻底不能看了。
而且这种代码有个隐蔽的坑:判空逻辑散在每一行里。keyword 判的是空字符串,tags 判的是数组长度,startDate 和 endDate 要一起判。哪天有人漏判一个,请求里就会多出个 keyword=undefined。
换个写法:像点奶茶一样,一句一句说。
你不会对着店员一口气报完"中杯三分糖去冰加珍珠加椰果不要吸管",你是一样一样说的。参数也可以这么拼:
typescriptclass QueryBuilder {
private params: Record<string, string> = {};
withPage(page: number): this {
this.params.page = String(page);
return this; // 返回自己,就能接着点下去
}
withKeyword(keyword: string): this {
if (keyword) this.params.keyword = keyword; // 判空收在这一处
return this;
}
withDateRange(start?: string, end?: string): this {
if (start && end) {
this.params.startDate = start;
this.params.endDate = end;
}
return this;
}
withTags(tags: string[]): this {
if (tags.length) this.params.tags = tags.join(',');
return this;
}
build(): string {
return new URLSearchParams(this.params).toString();
}
}用起来:
typescriptconst query = new QueryBuilder()
.withPage(1)
.withKeyword(keyword)
.withDateRange(startDate, endDate)
.withTags(selectedTags)
.build();读一遍就知道这个请求带了什么。每个方法末尾那句 return this 是关键——返回自己,才能一路点下去。
好处不只是好看。每个条件的判空规则被关进了它自己的方法里,以后 tags 的规则变了,你只需要改 withTags,不用担心碰到别的条件。
正式名字叫 Builder(建造者模式)。我个人的门槛是四个以上可选字段才用它,三个以内展开运算符就够了。
第 4 个月:下单后要发通知
产品说:下单成功后要发邮件,App 用户发推送,没装 App 的发短信。
这次的坑跟前两个不一样。前两个是"分支太多",这次是**"同一件事在好几个地方各写了一遍"**。
结算页要发通知,后台批量改状态要发通知,售后退款也要发通知。三个地方各自写了一遍构造逻辑。然后有一天产品说邮件底部要加退订链接——你得把这三个地方全找出来。
漏了一个,就是一个线上问题。
换个写法:设一个"前台",你只说要寄什么,前台决定怎么寄。
typescriptinterface Notification {
render(): string;
}
const notificationFactory = {
create(type: 'email' | 'push' | 'sms', message: string): Notification {
switch (type) {
case 'email':
return { render: () => `📧 ${message}\n\n退订请点击:/unsubscribe` };
case 'push':
return { render: () => `🔔 ${message}` };
case 'sms':
// 短信有长度限制,超了截断
return { render: () => `【商城】${message}`.slice(0, 70) };
}
},
};调用的地方一律变成一行:
typescriptconst notification = notificationFactory.create('email', '你的订单已发货');
send(notification.render());注意这里还是有 switch——我没把它消灭掉,我只是把它从三个地方收拢成了一个地方。
这一点值得说清楚,因为很多人对设计模式有个误解,以为目标是"干掉所有 if/else"。不是的。目标是让每个分支只在一个地方存在。产品要改邮件文案,你只改这一处,不用满项目搜。
正式名字叫 Factory(工厂模式)。什么时候需要它?当你发现同一个对象的构造代码在项目里出现了第三次的时候。
第 5 个月:后端换接口了
产品说:(没说什么,是后端说的)用户接口重构,路径和字段名都变了。
我在项目里搜 /api/user,搜出 17 个结果,分布在 11 个组件里。
这是最让人绝望的一次改动。不是难,是烦且容易漏。而且改完之后测试也很痛苦——每个组件的测试都要 mock 掉 fetch。
换个写法:点外卖的时候,你只管点菜,不用管这家店是自营还是加盟。
先声明"我需要什么数据":
typescriptinterface User {
id: string;
name: string;
email: string;
}
interface UserRepository {
getById(id: string): Promise<User>;
search(keyword: string): Promise<User[]>;
}再单独实现"数据怎么来":
typescriptclass ApiUserRepository implements UserRepository {
async getById(id: string): Promise<User> {
const res = await fetch(`/api/v2/users/${id}`);
if (!res.ok) throw new Error(`获取用户 ${id} 失败:${res.status}`);
return res.json();
}
async search(keyword: string): Promise<User[]> {
const res = await fetch(`/api/v2/users?q=${encodeURIComponent(keyword)}`);
if (!res.ok) throw new Error(`搜索用户失败:${res.status}`);
return res.json();
}
}在 React 里用 Context 发下去:
typescriptconst UserRepoContext = createContext<UserRepository>(new ApiUserRepository());
export const useUserRepo = (): UserRepository => useContext(UserRepoContext);组件里就只剩一行 const repo = useUserRepo(),它压根不知道数据是 fetch 来的还是从缓存里读的。
后端再改接口,你只改 ApiUserRepository 这一个文件。
写测试的时候更爽,一个对象字面量顶掉整个网络层:
typescriptconst mockRepo: UserRepository = {
getById: async (id) => ({ id, name: '测试用户', email: 'test@example.com' }),
search: async () => [],
};不用 mock fetch,不用起 MSW,不用管超时。而且因为 mockRepo 标了 UserRepository,以后接口加了新方法,所有测试文件会一起报错提醒你补上——这比跑起来才发现 mock 少了个方法强太多。
补一句实话,免得误导:res.json() 的返回类型其实是 any,我们写成 Promise<User> 是声明它是 User,不是运行时保证。后端真返回了脏数据,TypeScript 拦不住。要卡住得在这层加运行时校验(zod、valibot 都行)。
但即便如此这层还是值——脏数据只可能从这一个文件进来,而不是十一个组件。要加校验,也只有一个地方要加。
正式名字叫 Repository(仓储模式)。有个坑提醒一下:别在这层接口里返回
{ data, isLoading }。那一秒它就不是数据层了,是半个 hook,测试和复用的价值全没了。加载状态交给 React Query 或者 SWR,这层只管返回 Promise。
第 6 个月:接了第二家支付
产品说:微信支付费率太高,再接一家。
新的 SDK 长得跟老的完全不一样。老的方法叫 chargeInDollars(金额, 币种),单位是元;新的叫 createPayment({ amountCents }),单位是分。
结算页、订单详情页、会员续费弹窗,三个地方都要判断用哪家、然后按各自的格式调。单位换算的代码,写了三遍。
只要有一处漏乘 100,就是一笔金额错一百倍的线上事故。
换个写法:给它套一个转换插头。
你出国旅游不会给每台电器换插头,你带一个转换头,插上就能用。
typescript// 我们的应用只认这一个接口,单位统一用"分"
interface PaymentProcessor {
pay(amountInCents: number): Promise<void>;
}
// 第三方老 SDK,接口不由我们决定
class LegacyStripeSDK {
chargeInDollars(amount: number, currency: string): Promise<void> {
console.log(`Charged $${amount} ${currency}`);
return Promise.resolve();
}
}
// 转换插头
class StripeAdapter implements PaymentProcessor {
constructor(private readonly sdk: LegacyStripeSDK) {}
pay(amountInCents: number): Promise<void> {
return this.sdk.chargeInDollars(amountInCents / 100, 'USD');
}
}三个页面统统只认 PaymentProcessor。单位换算只写一遍,出事也只有一个地方要查。
再接第三家?新建一个 adapter 文件,结算逻辑一行不动。
constructor(private readonly sdk: LegacyStripeSDK) 这个写法叫参数属性,TypeScript 独有。它等于声明字段 + 构造函数里赋值 + 加只读,三步并一步。写多了以后回头看 JS 版本会觉得特别啰嗦。
正式名字叫 Adapter(适配器模式)。用它的前提是那个接口你改不了——第三方 SDK、老系统、别的团队的服务。如果那接口是你自己写的,直接改它,别包一层。
顺便说个更日常的用法:后端好几个服务返回的用户结构不一致,有的叫 user_name,有的叫 userName,头像还塞在 profile.avatar_url 里。与其在每个组件里判断,不如在数据入口处适配一次。
第 7 个月:详情页要加缓存
产品说:商品详情页太慢了,能不能快点。
于是 fetchProduct 这个函数开始长胖:先查缓存,没有再请求;请求失败重试两次;顺便打个埋点;哦对,还要判断登录态。
五件事挤在一个函数里,改任何一件都可能碰坏另外四件。
换个写法:像给手机贴膜、套壳一样,手机本身不用改。
typescripttype Fetcher<T> = (id: string) => Promise<T>;
// 壳一:加缓存
function withCache<T>(fetcher: Fetcher<T>): Fetcher<T> {
const cache = new Map<string, T>();
return async (id) => {
const cached = cache.get(id);
if (cached !== undefined) return cached;
const result = await fetcher(id);
cache.set(id, result);
return result;
};
}
// 壳二:加重试。times = 2 指"失败后再试 2 次",算上第一次总共 3 次
function withRetry<T>(fetcher: Fetcher<T>, times = 2): Fetcher<T> {
return async (id) => {
let lastError: unknown;
for (let i = 0; i <= times; i++) {
try {
return await fetcher(id);
} catch (err) {
lastError = err;
}
}
throw lastError;
};
}原来的函数一个字不改:
typescriptconst fetchProduct: Fetcher<Product> = (id) =>
fetch(`/api/products/${id}`).then((res) => res.json());
// 想要什么能力就套什么壳
const fetchProductSafely = withCache(withRetry(fetchProduct));套壳的顺序会改变行为,而且不报错——这是这个模式最容易翻车的地方。
先说个反直觉的:缓存和重试这两个壳,谁里谁外,结果其实是一样的。我特地跑了一遍验证(第一次请求失败、第二次成功,连着调三次):
codewithCache(withRetry(f)) → 底层实际请求 2 次
withRetry(withCache(f)) → 底层实际请求 2 次因为重试失败时缓存层压根没往里写,等某一次重试成功了,缓存照样写得进去。两种套法都能用。
真正会因为顺序而变的,是那些"每次调用都要做点什么"的壳。 最典型的就是日志和埋点:
typescriptfunction withLog<T>(fetcher: Fetcher<T>, tag: string): Fetcher<T> {
return async (id) => {
const start = Date.now();
const result = await fetcher(id);
console.log(`[${tag}] ${id} 耗时 ${Date.now() - start}ms`);
return result;
};
}同一个商品连着请求三次,两种套法记出来的日志条数完全不同:
codewithLog(withCache(f)) withCache(withLog(f))
日志在外,缓存在里 缓存在外,日志在里
───────────────────── ─────────────────────
第 1 次 缓存未命中 → 真请求 第 1 次 缓存未命中 → 真请求
记一条日志 记一条日志
第 2 次 缓存命中,0ms 第 2 次 缓存命中,直接返回
照样记一条日志 不记
第 3 次 缓存命中,0ms 第 3 次 缓存命中,直接返回
照样记一条日志 不记
共 3 条日志 共 1 条日志
回答的是"页面取了几次数据" 回答的是"实际打了几次后端"两种都不算错,但它们回答的是两个不同的问题。
我当初想统计"详情页到底打了多少次后端接口",顺手把 withLog 套在了最外面,结果埋点数字比后端网关的实际请求量高出一大截,对了半天才反应过来是套反了。这种 bug 不报错、不崩溃,代码看着还挺对——最难查的那一类。
最后交个底,上面那个 withCache 是最简版,生产上还差两件事:
一是没有过期时间,数据一旦缓存就永远是旧的。
二是没有并发去重。我实测过:同一个商品 id 并发调三次,因为第一次的结果还没写进缓存,三次全都会真打请求。想解决就把缓存里存的东西从"结果"换成"Promise"——第二、三次拿到的是同一个进行中的 Promise,等它就行。
正式名字叫 Decorator(装饰器模式)。缓存、重试、日志、埋点、权限校验,这些"跟业务本身没关系但到处都要"的事,都适合用它包在外面。
第 8 个月:购物车角标要联动
产品说:加购成功后,顶部购物车的数字要立刻变。
听起来是最简单的一个需求。实际上最烦——加购按钮在商品详情页深处,购物车角标在全局导航栏,中间隔着五层组件。
为这一件事引入 Redux,成本高得离谱。用 Context 又得把 Provider 提到很上面,顺带引发一片重渲染。
换个写法:装个小区喇叭。
谁想喊就喊一声,关心的人自己来听,喊的人不用知道谁在听。
typescript// 先把"能喊哪些事、每件事带什么信息"列清楚
type Events = {
'cart:updated': { count: number };
'toast:show': { message: string; type: 'success' | 'error' };
};
type Handler<E extends keyof Events> = (payload: Events[E]) => void;
class EventBus {
private listeners: { [E in keyof Events]?: Handler<E>[] } = {};
// 订阅,返回一个"取消订阅"的函数
on<E extends keyof Events>(event: E, handler: Handler<E>): () => void {
const handlers = (this.listeners[event] ??= []) as Handler<E>[];
handlers.push(handler);
return () => {
const index = handlers.indexOf(handler);
if (index > -1) handlers.splice(index, 1);
};
}
// 广播。注意这里的 [...]:遍历的是一份副本,原因见下文
emit<E extends keyof Events>(event: E, payload: Events[E]): void {
const handlers = this.listeners[event] as Handler<E>[] | undefined;
[...(handlers ?? [])].forEach((h) => h(payload));
}
}
export const eventBus = new EventBus();加购按钮里喊一声:
typescripteventBus.emit('cart:updated', { count: 3 });角标组件里听着:
typescriptuseEffect(() => eventBus.on('cart:updated', ({ count }) => setBadge(count)), []);中间五层组件完全不知道这件事发生过,一个 props 都不用加。
Events 那张表是整段代码的价值所在。 有了它,事件名有自动补全,参数写错编译就报:
typescripteventBus.emit('cart:updated', { total: 3 });
// ❌ 'total' does not exist in type '{ count: number; }'
eventBus.emit('cart:updated');
// ❌ Expected 2 arguments, but got 1再也没有"这个 'cart:updated' 到底带的什么参数?我全局搜一下"的时刻了。
还有两个细节别省。
一是 on 一定要返回取消订阅的函数。 不返回的话,组件卸载了监听还挂着,下次 emit 就是对着一个已经没了的组件 setState。这个内存泄漏非常常见,就三行代码的事。
写成 useEffect(() => eventBus.on(...), []) 是个小技巧——on 的返回值正好就是 useEffect 需要的清理函数,一行搞定。
二是 emit 里那个 [...],别省。 这个坑我是写这篇文章时实测出来的:如果某个监听器在自己的回调里取消订阅(比如实现"只响应一次",或者恰好这时组件卸载了),forEach 正在遍历的数组被 splice 掉一个元素,后面的下标全部前移,下一个监听器会被直接跳过。
三个监听器 A、B、C,A 在回调里取消自己,实测结果:
codeemit 里直接 forEach → A, C ← B 被吞了
emit 里遍历 [...] 副本 → A, B, C ← 正确一个方括号三个点的事,但漏了就是"我的监听怎么偶尔不触发"这种玄学 bug。
正式名字叫 Observer(观察者模式)。适合"一处发生、多处响应"且中间隔得很远的场景。但如果这个状态很多地方都要读要写,那还是老老实实上状态库,事件总线管不住复杂状态。
什么时候别用
写到这必须泼盆冷水。上面每个模式,用错地方都会让代码更难读。
只有两个分支、且明确不会再加的,别上策略模式。 isVip ? price * 0.85 : price 就挺好。为了两个分支拆出接口、两个实现、一张映射表,读代码的人得跳三个文件才知道打了几折。
三个以内可选参数,别上 Builder。 展开运算符够用。
只有一个数据源、这辈子不会换的内部小工具,Repository 是纯负担。 除非你要写测试——那还是加上,测试的收益永远够本。
Adapter 只在"外部接口你改不了"时才有意义。
我判断的时候一般就问两个问题:
code 这个分支还会继续加吗?
│
┌─────────┴─────────┐
会 不会
│ │
会有别人来改吗? 就写 if/else
│ (真的没问题)
┌───┴───┐
会 不会
│ │
上模式 先记个 TODO
等第三次改动
的时候再动手"第三次改动才重构"是我自己的土规矩。
第一次写、第二次复制,你都还看不清抽象边界应该划在哪。到第三次,重复的形状已经足够清楚了,这时候抽出来的接口才不会跑偏。太早抽象的代价,往往比重复三遍还大。
回到那个 640 行的文件
最后说说那个商品列表页后来怎么样了。
我们没有一次性重构。只做了一件事:把三处 fetch 挪进了一个 productRepository.ts。
640 行变成 480 行。那个 47 行的 switch 还原封不动躺在那儿——留给它长出第四个分支的那天。
设计模式最容易被误解成"一开始就要架构好"。恰恰相反,它是让你在已经痛了的地方动手的工具,而且一次只治一处。
挑你项目里此刻最难受的那一块,从最轻的那个开始。
你们项目里,哪段 if/else 最长?
我猜排前三的是:表单校验、权限判断、和各种"不同状态展示不同 UI"。
留言区聊聊你手上那段最长的 if/else 或 switch 吧——多少行、干什么用的、被几个人改过。我从里面挑最有代表性的几段,下期专门出一篇,把这七个模式真刀真枪套上去,看看能砍掉多少。
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

