TypeScript 前端重构实战 告别超长 switch 与层层透传 Props 痛点

AI 概述
作者分享接手电商React前端后,持续迭代催生代码臃肿的真实经历,结合七个业务场景讲解七种设计模式实战用法:策略、建造者、工厂、仓储、适配器、装饰器、观察者,附带TypeScript示例与各类陷阱。文中提出“第三次改动再重构”的实践原则,强调不要盲目套用模式,需结合场景判断;设计模式用于解决已经出现的维护痛点,而非前期过度架构。
目录
文章目录隐藏
  1. 第 2 个月:加会员价
  2. 第 3 个月:筛选条件变多了
  3. 第 4 个月:下单后要发通知
  4. 第 5 个月:后端换接口了
  5. 第 6 个月:接了第二家支付
  6. 第 7 个月:详情页要加缓存
  7. 第 8 个月:购物车角标要联动
  8. 什么时候别用?
  9. 回到那个 640 行的文件
  10. 你们项目里,哪段 if/else 最长?

TypeScript 前端重构实战 告别超长 switch 与层层透传 Props 痛点

去年我接手过一个电商商城的前端。

刚接手那会儿代码很干净。商品列表页 120 行,下单页 200 行,逻辑一眼能看完。

八个月后,商品列表页 640 行。里面有五个 useState、三段几乎一样的 fetch、一个 47 行的 switch——那个 switch 被三个人分别改过,每个人都只在末尾加了一个 case,谁也不敢动前面的分支。

没有人写烂过一行代码。 每一次改动单独看都很合理:产品说要加个会员价,那就加个 if;产品说筛选要支持时间范围,那就再加两个可选参数。

问题是这样的改动,八个月里发生了两百次。

下面我把这八个月里最典型的七次需求挑出来。每一次都讲三件事:产品说了什么、硬写会变成什么样、换个写法之后怎么样。

七次正好对应七个设计模式。但你不用记名字,记住场景就够了——下次产品跟你说类似的话,你自然会想起来。

商城前端这八个月,痛点是这么长出来的
────────────────────────────────────

第 2 个月  加会员价          → 价格逻辑开始长 switch
第 3 个月  筛选条件变多      → 参数拼接变成三元表达式地狱
第 4 个月  下单后要发通知    → 三种通知的构造逻辑散落各处
第 5 个月  后端换接口        → 十几个组件同时要改
第 6 个月  接第二家支付      → 结算页出现两套 SDK 调用
第 7 个月  详情页要加缓存    → 业务函数里塞进重试和埋点
第 8 个月  购物车角标要联动  → props 穿过五层组件

第 2 个月:加会员价

产品说:普通用户原价,会员打八五折。

硬写是这样的:

function getPrice(user: User, price: number): number {
  if (user.isMember) {
    return price * 0.85;
  }
  return price;
}

这没问题。真的没问题,两个分支的时候这就是最好的写法。

但接下来半年里,这个函数被追加了三次:黑五全场五折、教育认证用户七折、新人首单再减 10 块。

于是它变成了那个 47 行的 switch。最要命的不是长,是每加一种优惠都得回到同一个函数里动手,而这个函数已经被商品列表、详情页、购物车、结算页四个地方调用了。改错一行,四个页面一起崩。

换个写法:别让收银员记住所有折扣规则,让每种规则自己是一张价格牌。

// 一张"价格牌"长什么样
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,
};

然后把所有价格牌挂在一张表上:

type 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’ 却忘了配规则,编译直接报错:

Property 'edu' is missing in type '{ regular: ...; member: ...; blackFriday: ... }'
but required in type 'Record<UserLevel, PricingRule>'

在 JavaScript 里这个错误你要等到线上有教育用户下单、价格算出 undefined 才会知道。

这套写法有个边界得说清楚:一个用户只能对应一种等级。如果哪天产品要”会员价基础上再叠加黑五”,这张表就不够用了——那时候需要的是把多条规则串起来依次计算,是另一个话题。别指望一个模式包治百病。

这个模式的正式名字叫 Strategy(策略模式)。判断要不要用它,就问一句:这个分支以后还会不会继续加? 确定不会加的,if/else 挺好,别折腾。

第 3 个月:筛选条件变多了

产品说:列表页要支持按时间范围筛、按销量排序、还要能多选标签。

硬写是这样的:

const 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。

换个写法:像点奶茶一样,一句一句说。

你不会对着店员一口气报完”中杯三分糖去冰加珍珠加椰果不要吸管”,你是一样一样说的。参数也可以这么拼:

class 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();
  }
}

用起来:

const query = new QueryBuilder()
  .withPage(1)
  .withKeyword(keyword)
  .withDateRange(startDate, endDate)
  .withTags(selectedTags)
  .build();

读一遍就知道这个请求带了什么。每个方法末尾那句return this是关键——返回自己,才能一路点下去。

好处不只是好看。每个条件的判空规则被关进了它自己的方法里,以后 tags 的规则变了,你只需要改 withTags,不用担心碰到别的条件。

正式名字叫 Builder(建造者模式)。我个人的门槛是四个以上可选字段才用它,三个以内展开运算符就够了。

第 4 个月:下单后要发通知

产品说:下单成功后要发邮件,App 用户发推送,没装 App 的发短信。

这次的坑跟前两个不一样。前两个是”分支太多”,这次是**”同一件事在好几个地方各写了一遍”**。

结算页要发通知,后台批量改状态要发通知,售后退款也要发通知。三个地方各自写了一遍构造逻辑。然后有一天产品说邮件底部要加退订链接——你得把这三个地方全找出来。

漏了一个,就是一个线上问题。

换个写法:设一个”前台”,你只说要寄什么,前台决定怎么寄。

interface 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) };
    }
  },
};

调用的地方一律变成一行:

const notification = notificationFactory.create('email', '你的订单已发货');
send(notification.render());

**注意这里还是有 switch**——我没把它消灭掉,我只是把它从三个地方收拢成了一个地方。

这一点值得说清楚,因为很多人对设计模式有个误解,以为目标是”干掉所有 if/else”。不是的。目标是让每个分支只在一个地方存在。产品要改邮件文案,你只改这一处,不用满项目搜。

正式名字叫 Factory(工厂模式)。什么时候需要它?当你发现同一个对象的构造代码在项目里出现了第三次的时候。

第 5 个月:后端换接口了

产品说:(没说什么,是后端说的)用户接口重构,路径和字段名都变了。

我在项目里搜 /api/user,搜出 17 个结果,分布在 11 个组件里。

这是最让人绝望的一次改动。不是难,是烦且容易漏。而且改完之后测试也很痛苦——每个组件的测试都要 mock 掉 fetch。

换个写法:点外卖的时候,你只管点菜,不用管这家店是自营还是加盟。

先声明”我需要什么数据”:

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserRepository {
  getById(id: string): Promise<User>;
  search(keyword: string): Promise<User[]>;
}

再单独实现”数据怎么来”:

class 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 发下去:

const UserRepoContext = createContext<UserRepository>(new ApiUserRepository());

export const useUserRepo = (): UserRepository => useContext(UserRepoContext);

组件里就只剩一行const repo = useUserRepo(),它压根不知道数据是 fetch 来的还是从缓存里读的。

后端再改接口,你只改 ApiUserRepository 这一个文件。

写测试的时候更爽,一个对象字面量顶掉整个网络层:

const 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,就是一笔金额错一百倍的线上事故。

换个写法:给它套一个转换插头。

你出国旅游不会给每台电器换插头,你带一个转换头,插上就能用。

// 我们的应用只认这一个接口,单位统一用"分"
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 这个函数开始长胖:先查缓存,没有再请求;请求失败重试两次;顺便打个埋点;哦对,还要判断登录态。

五件事挤在一个函数里,改任何一件都可能碰坏另外四件。

换个写法:像给手机贴膜、套壳一样,手机本身不用改。

type 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;
  };
}

原来的函数一个字不改:

const fetchProduct: Fetcher<Product> = (id) =>
  fetch(`/api/products/${id}`).then((res) => res.json());

// 想要什么能力就套什么壳
const fetchProductSafely = withCache(withRetry(fetchProduct));

套壳的顺序会改变行为,而且不报错——这是这个模式最容易翻车的地方。

先说个反直觉的:缓存和重试这两个壳,谁里谁外,结果其实是一样的。我特地跑了一遍验证(第一次请求失败、第二次成功,连着调三次):

withCache(withRetry(f))   →  底层实际请求 2 次
withRetry(withCache(f))   →  底层实际请求 2 次

因为重试失败时缓存层压根没往里写,等某一次重试成功了,缓存照样写得进去。两种套法都能用。

真正会因为顺序而变的,是那些”每次调用都要做点什么”的壳。 最典型的就是日志和埋点:

function 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;
  };
}

同一个商品连着请求三次,两种套法记出来的日志条数完全不同:

withLog(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 提到很上面,顺带引发一片重渲染。

换个写法:装个小区喇叭。

谁想喊就喊一声,关心的人自己来听,喊的人不用知道谁在听。

// 先把"能喊哪些事、每件事带什么信息"列清楚
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();

加购按钮里喊一声:

eventBus.emit('cart:updated', { count: 3 });

角标组件里听着:

useEffect(() => eventBus.on('cart:updated', ({ count }) => setBadge(count)), []);

中间五层组件完全不知道这件事发生过,一个 props 都不用加。

Events 那张表是整段代码的价值所在。 有了它,事件名有自动补全,参数写错编译就报:

eventBus.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 在回调里取消自己,实测结果:

emit 里直接 forEach       →  A, C      ← B 被吞了
emit 里遍历 [...] 副本    →  A, B, C   ← 正确

一个方括号三个点的事,但漏了就是”我的监听怎么偶尔不触发”这种玄学 bug。

正式名字叫 Observer(观察者模式)。适合”一处发生、多处响应”且中间隔得很远的场景。但如果这个状态很多地方都要读要写,那还是老老实实上状态库,事件总线管不住复杂状态。

什么时候别用?

写到这必须泼盆冷水。上面每个模式,用错地方都会让代码更难读。

只有两个分支、且明确不会再加的,别上策略模式。isVip ? price * 0.85 : price 就挺好。为了两个分支拆出接口、两个实现、一张映射表,读代码的人得跳三个文件才知道打了几折。

三个以内可选参数,别上 Builder。 展开运算符够用。

只有一个数据源、这辈子不会换的内部小工具,Repository 是纯负担。 除非你要写测试——那还是加上,测试的收益永远够本。

Adapter 只在”外部接口你改不了”时才有意义。

我判断的时候一般就问两个问题:

        这个分支还会继续加吗?
                │
      ┌─────────┴─────────┐
     会                   不会
      │                     │
 会有别人来改吗?        就写 if/else
      │                (真的没问题)
  ┌───┴───┐
 会       不会
  │         │
上模式   先记个 TODO
        等第三次改动
        的时候再动手

“第三次改动才重构”是我自己的土规矩。

第一次写、第二次复制,你都还看不清抽象边界应该划在哪。到第三次,重复的形状已经足够清楚了,这时候抽出来的接口才不会跑偏。太早抽象的代价,往往比重复三遍还大。

回到那个 640 行的文件

最后说说那个商品列表页后来怎么样了。

我们没有一次性重构。只做了一件事:把三处 fetch 挪进了一个 productRepository.ts。

640 行变成 480 行。那个 47 行的 switch 还原封不动躺在那儿——留给它长出第四个分支的那天。

设计模式最容易被误解成”一开始就要架构好”。恰恰相反,它是让你在已经痛了的地方动手的工具,而且一次只治一处。

挑你项目里此刻最难受的那一块,从最轻的那个开始。

你们项目里,哪段 if/else 最长?

我猜排前三的是:表单校验、权限判断、和各种”不同状态展示不同 UI”。

留言区聊聊你手上那段最长的 if/else 或 switch 吧——多少行、干什么用的、被几个人改过。

以上关于TypeScript 前端重构实战 告别超长 switch 与层层透传 Props 痛点的文章就介绍到这了,更多相关内容请搜索码云笔记以前的文章或继续浏览下面的相关文章,希望大家以后多多支持码云笔记。

「点点赞赏,手留余香」

22

给作者打赏,鼓励TA抓紧创作!

微信微信 支付宝支付宝

还没有人赞赏,快来当第一个赞赏的人吧!

声明:本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若内容造成侵权/违法违规/事实不符,请将相关资料发送至 admin@mybj123.com 进行投诉反馈,一经查实,立即处理!
重要:如软件存在付费、会员、充值等,均属软件开发者或所属公司行为,与本站无关,网友需自行判断
码云笔记 » TypeScript 前端重构实战 告别超长 switch 与层层透传 Props 痛点

发表回复