普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月12日首页

前端向架构突围系列 - 框架设计(四):依赖倒置原则(DIP)

2026年1月12日 11:08

写在前面

在前端圈子里,我们常说“这个组件太耦合了”或者“这段逻辑改不动”。大多数人习惯性地把锅甩给业务复杂或者前人留下的“屎山”。但如果我们冷静下来复盘,你会发现绝大多数的维护噩梦都指向同一个设计缺陷:高层逻辑被低层细节给绑架了。

今天我们要聊的依赖倒置原则(Dependency Inversion Principle, DIP) ,就是专门用来破解这种“死局”的架构利器。


image.png

一、 场景:一次痛苦的“技术升级”

想象一下,你负责一个复杂的 B 端系统。半年前,为了快速上线,你直接在业务 Hooks 里引入了 Axios,并散落到了项目的各个角落:

// useUser.ts - 典型的“自上而下”依赖
import axios from 'axios'; 

export const useUser = (id: string) => {
  const fetchUser = async () => {
    // 业务逻辑直接依赖了具体的实现:axios
    const res = await axios.get(`/api/user/${id}`);
    return res.data;
  };
  return { fetchUser };
};

噩梦开始了: 由于公司架构调整,所有请求必须从 Axios 切换到公司自研的 RPC SDK,或者需要统一接入一套极其复杂的签名加密逻辑。

你看着全局搜索出来的 200 多个 axios 引用,陷入了沉思。不仅要改代码,还要面对全量回归测试的风险。这时候你才会意识到:你的业务逻辑,已经和具体的网络库“殉情”了。


二、 什么是依赖倒置?(别背定义,看本质)

传统的开发思维是自顶向下的:页面依赖组件,组件依赖工具类。这就像在盖房子时,把电线直接浇筑在混凝土里,想换根线就得拆墙。

依赖倒置原则核心就两句话:

  1. 高层模块不应依赖低层模块,两者都应依赖抽象。
  2. 抽象不应依赖细节,细节应依赖抽象。

通俗点说:谁拥有接口(Interface),谁就是老大。

在架构突围中,我们要把“控制权”翻转过来。高层业务不应该问:“我该怎么去调用 Axios?”而是应该傲娇地声明:“我需要一个能发请求的东西,至于你是用 Axios 还是 Fetch,我不在乎。”

image.png


三、 代码案例:从“死耦合”到“神解耦”

让我们用 DIP 的思维重构上面的例子。

1. 定义抽象(Interface)

首先,在业务层定义我们要的“形状”,这叫建立契约

// domain/http.ts - 这是我们的“主权声明”
export interface IHttpClient {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, data: any): Promise<T>;
}

2. 业务层依赖抽象

现在的业务 Hook 不再关心具体的库。

// useUser.ts
import { IHttpClient } from '../domain/http';

export const useUser = (id: string, client: IHttpClient) => {
  const fetchUser = async () => {
    // 业务只对接口负责
    return await client.get(`/api/user/${id}`);
  };
  return { fetchUser };
};

3. 底层实现细节

具体的库(Axios/Fetch)只是契约的执行者。

// infra/AxiosClient.ts
import axios from 'axios';
import { IHttpClient } from '../domain/http';

export class AxiosClient implements IHttpClient {
  async get<T>(url: string): Promise<T> {
    const res = await axios.get(url);
    return res.data;
  }
  // ...实现其他方法
}

4. 依赖注入(DI)

在应用的最顶层(入口处),我们将具体的实现“注入”进去。

// App.tsx
const httpClient = new AxiosClient(); // 后续换成 RPCClient 只需要改这一行
const { fetchUser } = useUser('123', httpClient);

四、 深度思考:前端架构里的 DIP 怎么玩?

如果你觉得上面的代码只是多写了几行 Interface,那就小看 DIP 了。在复杂的前端工程中,DIP 是实现以下架构的关键:

1. 跨端架构的统一

如果你在做一套同时支持 Web 和 小程序的方案。业务逻辑应该是同一套,通过 DIP,你可以为 Web 注入 WebAdapter,为小程序注入 MiniProgramAdapter

想象一下,你的团队要开发一个电商应用的“用户模块”,包含登录、获取用户信息、修改收货地址等功能。这个模块需要同时跑在 Web 端(用 React/Vue)和 微信小程序端

业务逻辑(Business Logic)本身其实是一样的:

“用户点击保存 -> 校验表单 -> 发起网络请求保存数据 -> 更新本地状态 -> 提示成功”

但是,底层的技术实现(Infrastructure Layer)却说着完全不同的“方言”:

功能点 Web 端 "方言" 小程序端 "方言"
网络请求 fetchaxios wx.request
本地存储 localStorage.setItem wx.setStorage
路由跳转 history.push / router.push wx.navigateTo
交互反馈 Antd Message / ElementUI Notification wx.showToast
错误示范:传统的“自上而下”强耦合

如果不适用 DIP,为了复用代码,很多团队会写出这种充斥着环境判断的“面条代码”:

// user.service.ts (糟糕的设计)
import axios from 'axios';

// 假设通过某种方式注入了环境变量 IS_MINI_PROGRAM
export const getUserProfile = async (id: string) => {
  // 业务逻辑里夹杂着环境判断
  if (IS_MINI_PROGRAM) {
    // 小程序方言
    
  } else if (IS_WEB) {
    // Web 方言
    
  } else if ...
};

后果: 你的业务逻辑层被迫知道了太多它不该知道的“底层细节”。每增加一个端(比如又要支持阿里小程序、字节小程序),这里就要加一个 else if,代码迅速腐化,难以维护。

正确示范:DIP 主导的跨端统一架构

用 DIP 的思路,我们要反客为主。业务层不再去迁就各个端的方言,而是制定一套“官方语言”(Interface),要求各个端配备“翻译官”(Adapter)来适配这套语言。

// --- Core Business Layer (核心业务层,与平台无关) ---

// domain/interfaces/http.interface.ts
// 定义网络请求的契约
export interface IHttpClient {
  get<T>(url: string, params?: any): Promise<T>;
  post<T>(url: string, data?: any): Promise<T>;
}

// domain/interfaces/storage.interface.ts
// 定义本地存储的契约
export interface IStorage {
  setItem(key: string, value: string): Promise<void> | void;
  getItem(key: string): Promise<string | null> | string | null;
}
编写纯净的业务逻辑(依赖抽象)

现在的业务 Service 代码非常干净,它只依赖上面的接口。它不知道自己运行在哪里,它只知道自己有一个能发请求的对象(http)和一个能存东西的对象(storage)。

// --- Core Business Layer ---

// services/userService.ts
import { IHttpClient } from '../domain/interfaces/http.interface';
import { IStorage } from '../domain/interfaces/storage.interface';

export class UserService {
  // 通过构造函数注入依赖 (DI)
  constructor(
    private http: IHttpClient,
    private storage: IStorage
  ) {}

  async login(username: string) {
    // 1. 调用 HTTP 接口
    const user = await this.http.post('/api/login', { username });
    // 2. 调用 Storage 接口
    await this.storage.setItem('user_token', user.token);
    return user;
  }
}
各端派遣“翻译官”(实现适配器)

现在轮到基础设施层(Infra Layer)干活了。我们需要为 Web 端和小程序端分别实现上述接口。这就是所谓的 Adapter(适配器)模式

Web 端适配器:
// --- Infra Layer (Web) ---

// infra/web/AxiosHttpClient.ts
import axios from 'axios';
import { IHttpClient } from '../../domain/interfaces/http.interface';

// 这就是 Web 端的翻译官,把标准语言翻译成 Axios 方言
export class AxiosHttpClient implements IHttpClient {
  async get<T>(url: string, params?: any): Promise<T> {
    const res = await axios.get(url, { params });
    return res.data;
  }
  // ... implement post
}

// infra/web/LocalStorageAdapter.ts
import { IStorage } from '../../domain/interfaces/storage.interface';

export class LocalStorageAdapter implements IStorage {
  setItem(key: string, value: string) {
    localStorage.setItem(key, value);
  }
  // ... implement getItem
}
小程序端适配器:
// --- Infra Layer (Mini Program) ---

// infra/mp/WechatHttpClient.ts
import { IHttpClient } from '../../domain/interfaces/http.interface';

// 这就是小程序端的翻译官,把标准语言翻译成 wx.request 方言
export class WechatHttpClient implements IHttpClient {
  async get<T>(url: string, params?: any): Promise<T> {
    // 将 callback 风格封装成 Promise 风格以符合接口要求
    return new Promise((resolve, reject) => {
      wx.request({
        url: `https://api.myapp.com${url}`, // 小程序需要完整 URL
        data: params,
        method: 'GET',
        success: (res) => resolve(res.data as T),
        fail: reject
      });
    });
  }
  // ... implement post
}
// 类似地实现 WechatStorageAdapter...
在入口处组装(依赖注入)

这是最后一步见证奇迹的时刻。在不同端的入口文件里,我们将对应的“翻译官”注入到业务逻辑中。

Web 端入口 (main.web.ts / App.tsx):
import { UserService } from './services/userService';
import { AxiosHttpClient } from './infra/web/AxiosHttpClient';
import { LocalStorageAdapter } from './infra/web/LocalStorageAdapter';

// 组装 Web 版的 User Service
const webUserService = new UserService(
  new AxiosHttpClient(),
  new LocalStorageAdapter()
);

// 现在 webUserService 可以直接在 React/Vue 组件中使用了
小程序端入口 (app.ts / main.mp.ts):
import { UserService } from './services/userService';
import { WechatHttpClient } from './infra/mp/WechatHttpClient';
import { WechatStorageAdapter } from './infra/mp/WechatStorageAdapter';

// 组装小程序版的 User Service
const mpUserService = new UserService(
  new WechatHttpClient(),
  new WechatStorageAdapter()
);

// mpUserService 可以在小程序的 Page 或 Component 中使用了
总结

通过 DIP,我们将跨端架构分成了清晰的三层:

  1. 核心业务层(稳定) :定义 Interface,编写业务逻辑。这一层代码在多端是完全共用的,一行都不用改。
  2. 接口契约层(抽象) :即 IHttpClient, IStorage 等 Interface 定义。
  3. 基础设施层(易变) :各个端的具体 Adapter 实现(WebAdapter, MiniProgramAdapter)。

2. 制定官方语言(定义抽象接口)

业务层声明它需要什么能力,而不关心这能力怎么实现。这些 Interface 定义在核心业务域中。

3. 无感知的 Mock 与测试

写单元测试最痛苦的是 Mock 全局库。如果你遵循了 DIP,你只需要给业务逻辑注入一个 MockClient,连 jest.mock('axios') 这种黑盒操作都不用了。

4. 插件化架构

像 VS Code 或大型低代码平台,其核心框架并不依赖具体的插件。它定义了一套规范(抽象),所有的插件必须实现这些规范,这正是 DIP 的高级应用。


五、 结语:突围的核心是“心智负担”的转移

很多前端同学抗拒 DIP,觉得“我就写个业务,有必要搞这么复杂吗?”

确实,对于三天就扔的小程序,DIP 属于过度设计。但如果你在构建一个长期迭代的工程,DIP 的本质是在隔离变化。它把最不稳定的部分(第三方库、API 协议、浏览器差异)挡在了抽象层之外。

架构突围,不是为了炫技,而是为了在下一次需求变动、技术迁移时,你能气定神闲地改一行代码,而不是通宵改两百个文件。

互动环节: 你在项目中遇到过“因为换个库导致全线崩溃”的经历吗?或者你觉得在开发中,Context API 是否已经足够支撑起 DIP 的职责?欢迎在评论区博弈。

昨天以前首页

前端向架构突围系列 - 框架设计(三):用开闭原则拯救你的组件库

2026年1月9日 11:42

写在前面

兄弟们,回想一下,你有没有接过这种需求:

产品经理跑来说:“咱们那个通用的表格组件,现在需要在某一列加个自定义的渲染逻辑,以前是纯文本,现在要变成个带图标的按钮,还能点击弹窗。”

你心想:“这还不简单?”

于是你打开了那个祖传的 CommonTable.vueTable.tsx,找到了渲染单元格的地方,熟练地写下了一个 if-else

过了两天,产品又来了:“那啥,另一列也要改,这次要加个进度条。”

你又熟练地加了一个 else-if

几个月后,这个组件的源码已经突破了 2000 行,光那个 if-else 的判断逻辑就占了半屏。后来的同事接手时,看着这坨代码,只想把你拉黑。

这种“改哪哪疼,牵一发而动全身”的代码,就是典型的违反了开闭原则 (Open/Closed Principle, OCP) 。今天咱们就来聊聊,怎么用 OCP 把这坨代码重构成“人话”。


39072abf-1240-4203-a664-62f3074c67cd.png

什么是开闭原则 (OCP)?

开闭原则,听起来很高大上,其实说人话就是八个字:

对扩展开放,对修改关闭。

  • 对扩展开放 (Open for extension) :当有新需求来了,你应该能通过“增加新代码”的方式来满足,而不是去改旧代码。
  • 对修改关闭 (Closed for modification) :那个已经写好、测试过、稳定运行的核心代码,你尽量别去动它。

想象一下你的电脑主机。你想加个显卡,是直接把主板焊开接线(修改),还是找个 PCI-E 插槽插上去(扩展)?显然后者更靠谱。

在前端领域,OCP 最典型的应用场景就是组件设计插件系统


案例分析:一个“违反 OCP”的糟糕组件

咱们就拿最常见的通用列表项组件来举例。假设我们有一个 ListItem 组件,用来展示用户信息。

原始需求

需求很简单:展示用户的头像和名字。

// ListItem.tsx (V1)
interface User {
  id: string;
  name: string;
  avatar: string;
}

const ListItem = ({ user }: { user: User }) => {
  return (
    <div className="list-item">
      <img src={user.avatar} alt={user.name} />
      <span>{user.name}</span>
    </div>
  );
};

这代码看起来没毛病,清爽、简单。

需求变更 1:加个 VIP 标志

产品说:“有些用户是 VIP,名字后面得加个金灿灿的皇冠图标。”

你心想,小case,一把梭:

// ListItem.tsx (V2 - 开始变味了)
interface User {
  id: string;
  name: string;
  avatar: string;
  isVip?: boolean; // 新增字段
}

const ListItem = ({ user }: { user: User }) => {
  return (
    <div className="list-item">
      <img src={user.avatar} alt={user.name} />
      <span>{user.name}</span>
      {/* 修改点:硬编码逻辑 */}
      {user.isVip && <span className="vip-icon"></span>}
    </div>
  );
};

你为了这个新需求,修改ListItem 组件的内部实现。虽然只加了一行,但坏头已经开了。

需求变更 2:再加个在线状态

产品又来了:“得显示用户在不在线,在线的头像旁边亮个绿灯。”

你叹了口气,继续梭:

// ListItem.tsx (V3 - 味道越来越冲)
interface User {
  id: string;
  name: string;
  avatar: string;
  isVip?: boolean;
  isOnline?: boolean; // 又新增字段
}

const ListItem = ({ user }: { user: User }) => {
  return (
    <div className="list-item">
      <div className="avatar-wrapper">
        <img src={user.avatar} alt={user.name} />
        {/* 修改点:又硬编码逻辑 */}
        {user.isOnline && <span className="online-dot"></span>}
      </div>
      <span>{user.name}</span>
      {user.isVip && <span className="vip-icon"></span>}
    </div>
  );
};

问题来了:

  1. 组件越来越臃肿:每次新需求都要改这个文件,代码量蹭蹭涨。
  2. 耦合度极高ListItem 竟然要知道什么是 VIP,什么是在线状态。如果明天要加个“等级勋章”、“活动挂件”呢?
  3. 测试困难:每次改动都得把以前的 VIP、在线状态全测一遍,生怕改坏了。

这就是典型的违反了对修改关闭。核心组件被迫了解太多它不该知道的业务逻辑。


重构:用 OCP 把“屎山”铲平

怎么让 ListItem 既能支持各种花里胡哨的展示,又不用每次都改它呢?

答案就是:把变化的部分抽离出去,留下不变的骨架。

  • 不变的部分:列表项的基本结构(左边是图,右边是文字)。
  • 变化的部分:头像旁边要加什么装饰?文字后面要挂什么配件?

我们可以利用 React 的 组合 (Composition) 特性,比如 children 或者 Render Props(插槽槽位)。

重构 V1:使用插槽 (Slots / Render Props)

我们改造一下 ListItem,让它别管那么多闲事,只负责提供“坑位”。

// ListItem.tsx (OCP版本)
interface ListItemProps {
  avatar: React.ReactNode; // 不再只传字符串,直接传节点
  title: React.ReactNode;  // 同上
  // 预留两个扩展槽位
  avatarAddon?: React.ReactNode;
  titleAddon?: React.ReactNode;
}

// 这个组件现在稳定得一批,几乎不需要再修改了
const ListItem = ({ avatar, title, avatarAddon, titleAddon }: ListItemProps) => {
  return (
    <div className="list-item">
      <div className="avatar-wrapper">
        {avatar}
        {/* 扩展点:头像装饰 */}
        {avatarAddon}
      </div>
      <div className="title-wrapper">
        {title}
        {/* 扩展点:标题装饰 */}
        {titleAddon}
      </div>
    </div>
  );
};

现在,核心组件 ListItem 对修改是关闭的。那怎么扩展新需求呢?

在使用它的地方进行扩展(对扩展开放):

// UserList.tsx (业务层)
import ListItem from './ListItem';

const UserList = ({ users }) => {
  return (
    <div>
      {users.map(user => (
        <ListItem
          key={user.id}
          // 基础信息
          avatar={<img src={user.avatar} />}
          title={<span>{user.name}</span>}
          // 扩展需求1:在线状态
          avatarAddon={user.isOnline ? <OnlineDot /> : null}
          // 扩展需求2:VIP标识
          titleAddon={user.isVip ? <VipCrown /> : null}
        />
      ))}
    </div>
  );
};

看!世界清静了。

  • ListItem 组件不知道也不关心什么是 VIP。它只知道:“如果有人给了我 titleAddon,那我就把它渲染在标题后面。”
  • 如果明天产品要加个“等级勋章”,你只需要写个 <LevelBadge /> 组件,然后传给 titleAddon 即可。ListItem.tsx 文件一个字都不用改。

这就是 OCP 的魅力。


进阶:策略模式与配置化

在更复杂的场景下,比如我们开头提到的通用表格组件,每一列的渲染逻辑可能千奇百怪。这时候光用插槽可能还不够灵活。

我们可以借鉴策略模式的思想,结合配置化来实现 OCP。

假设我们有一个复杂的后台管理表格。

糟糕的设计 (违反 OCP)

// BadTableColumn.tsx
const renderCell = (value, columnType) => {
  // 地狱 if-else 
  if (columnType === 'text') {
    return <span>{value}</span>;
  } else if (columnType === 'image') {
    return <img src={value} />;
  } else if (columnType === 'link') {
    // ...要加新类型就得改这里
  } else if (columnType === 'status') {
     // ...越来越长
  }
  // ...
};

符合 OCP 的设计

我们定义一个策略注册表,把每种类型的渲染逻辑注册进去。

// renderStrategies.tsx (策略定义)
const strategies = {
  text: (value) => <span>{value}</span>,
  image: (value) => <img src={value} className="table-img" />,
  // 新需求:状态标签
  status: (value) => <Tag color={value === 'active' ? 'green' : 'red'}>{value}</Tag>,
};

// 提供注册入口(对扩展开放)
export const registerStrategy = (type, renderer) => {
  strategies[type] = renderer;
};

// 提供获取入口
export const getStrategy = (type) => {
  return strategies[type] || strategies['text'];
};

然后,表格组件只负责调用策略:

// GoodTableColumn.tsx
import { getStrategy } from './renderStrategies';

const TableCell = ({ value, columnType }) => {
  // 核心组件对修改关闭:它不需要知道具体怎么渲染
  const renderer = getStrategy(columnType);
  return <td>{renderer(value)}</td>;
};

当你要新增一种“进度条”类型的列时,你根本不需要碰 TableCell 组件,只需要在项目的入口文件里注册一个新的策略:

// main.js (应用入口)
import { registerStrategy } from './renderStrategies';
import ProgressBar from './components/ProgressBar';

// 扩展新能力
registerStrategy('progress', (value) => <ProgressBar percent={value} />);

这就实现了一个简易的插件化系统。核心库稳定不变,业务方通过注册机制无限扩展能力。


总结:别让自己成为“改Bug机器”

开闭原则不是什么高深的理论,它就是为了让你少加班、少背锅而生的。

记住这几个实战要点:

  1. 识别变化点:做组件之前先想想,哪些是铁打不动的骨架,哪些是流水易变的皮肉。
  2. 多用组合/插槽:React 的 children 和 Render Props,Vue 的 slot,都是实现 OCP 的利器。把决定权交给使用者,而不是自己大包大揽。
  3. 善用策略/配置:遇到复杂的 if-else 逻辑判断渲染类型时,考虑用映射表(Map 对象)代替硬编码,把逻辑抽离出去。

下次再遇到产品经理不断提新需求,希望你能自信地打开代码,优雅地新增一个文件,而不是痛苦地在那坨几千行的祖传代码里加 if-else

Keep coding, keep open!


互动话题

你的项目里有没有那种因为违反 OCP 而变得维护困难的“超级组件”?你又是怎么重构它的?欢迎在评论区吐槽交流!

前端向架构突围系列 - 框架设计(二):糟糕的代码有哪些特点?

2026年1月9日 09:38

前言 你有没有过这种经历:新接手了一个项目,产品经理让你把一个按钮往左移 5 像素。你心想:“这不有手就行?” 结果你改了 CSS,保存,刷新。 按钮是移过去了,但登录弹窗打不开了,控制台红了一片,甚至 CI/CD 流程都挂了。

这一刻,你面对的不是代码,而是一座摇摇欲坠的屎山。 在框架设计和组件库开发中,这种现象尤为致命。业务代码写烂了,坑的是一个页面;框架设计写烂了,坑的是整个团队。今天我们要聊的不是具体的变量命名或缩进,而是架构层面的“设计臭味”

image.png

什么是“设计臭味”?

“代码臭味”(Code Smell)这个词不是说代码真的有味儿(虽然有时候看代码确实想吐),而是指代码结构中某些特征暗示了深层次的设计问题

它就像煤气泄漏的味道,本身不一定会炸,但只要有一点火星(新的需求变更),整个系统就会原地升天。

作为前端架构师或核心开发者,如果你在 Code Review 时闻到了以下这 5 种味道,请务必警惕。


1. 僵化性 (Rigidity):牵一发而动全身

症状: 你想复用一个通用的 Header 组件,结果发现它里面硬编码了 useRouter() 的跳转逻辑,甚至还直接 importRedux/Pinia 的 store。 你想在另一个项目用它?没门。除非你把那边的路由和状态管理全套搬过来。

前端实战翻译: 这就是典型的高耦合。组件不再是一个独立的乐高积木,而是一块焊死在主板上的芯片。

反面教材 (React):

// 这是一个充满僵化味道的组件
const UserProfile = () => {
  // 致命伤1:直接依赖具体的全局状态
  const user = useSelector(state => state.user.info);
  // 致命伤2:直接依赖具体的路由实现
  const history = useHistory();

  const handleLogout = () => {
     // 业务逻辑耦合在UI里
     api.logout().then(() => history.push('/login'));
  }

  return <div>{user.name} <button onClick={handleLogout}>退出</button></div>;
};

指南:

  • 控制反转 (IoC) :组件只管展示,逻辑通过 Props 传进来。
  • Presentational vs Container:把“展示组件”和“容器组件”拆开。展示组件要像“傻瓜”一样,给什么吃什么,不要自己去冰箱(Store)里拿。

2. 脆弱性 (Fragility):改东崩西的蝴蝶效应

症状: 这比僵化性更搞心态。僵化性是你改不动,脆弱性是你改了,但崩在了你完全想不到的地方。 比如:你为了优化首页加载速度,调整了一个公共 utils 函数,结果结算页面的金额计算错了,多给了用户 100 块钱。

前端实战翻译: 通常源于隐式依赖全局变量污染或者CSS 样式穿透

反面教材 (CSS/Vue):

/* 这种写法在全局样式里简直是灾难 */
.title {
  font-size: 20px;
  color: red;
}

/* 或者在组件里滥用 !important */
.btn {
  background: blue !important; /* 你的同事想覆盖这个样式时,必须写得比你更恶心 */
}

指南:

  • CSS Modules / Scoped CSS / Tailwind:坚决消灭全局样式冲突。
  • 纯函数 (Pure Functions) :工具类函数坚决不能有副作用,输入相同,输出必须相同。
  • 依赖显式化:别在组件里偷偷摸摸读 window.xxx 或者 localStorage,把它们封装成 Hooks 或服务。

3. 顽固性 (Immobility):无法拆分的连体婴

症状: 你写了一个非常炫酷的 DataGrid 表格,支持排序、筛选、分页。隔壁组看到了说:“哇,这个好,我也要用。” 你自信满满地把代码发给他。 五分钟后他跑来说:“哥,我只要个表格UI,你为什么把 Axios 拦截器、ElementUI 的弹窗组件、甚至你们公司的埋点 SDK 都打包进来了?”

前端实战翻译: 这是内聚性低的表现。业务逻辑和基础设施、UI 逻辑混在一起,导致根本无法拆分复用。

反面教材:

JavaScript

// 一个原本想做通用组件的 hook,却混入了业务
function useTableData(url) {
  const [data, setData] = useState([]);

  useEffect(() => {
    // 错误:这里耦合了特定的 HTTP 库和业务上的 token 逻辑
    axios.get(url, { headers: { 'X-Auth': localStorage.getItem('token') } })
      .then(res => setData(res.data.list)); // 错误:硬编码了数据结构 res.data.list
  }, [url]);

  return data;
}

指南:

  • Headless UI:这是现在的设计趋势(如 React Table, TanStack Query)。只提供逻辑钩子,不提供 UI。
  • 依赖倒置:网络请求层应该作为参数传入,而不是在组件内部直接实例化。

4. 粘滞性 (Viscosity):做错误的事更容易

症状: 这是一个人性问题。 假设你的框架支持 TypeScript。

  • 正确的做法:定义 Interface,继承 Props,处理泛型,写 Mock 数据,跑单元测试。需要 10 分钟。
  • 错误的做法any 一把梭。需要 10 秒钟。

做正确的事做错误的事阻力大得多时,开发者就会倾向于破坏架构。这就是粘滞性。

前端实战翻译: 环境配置太复杂、类型定义太反人类、测试难写。

反面教材: 如果你的组件库要求使用者必须写 5 层嵌套的配置对象才能跑起来,那使用者一定会想办法绕过配置,直接去改源码。

指南:

  • 约定优于配置:像 Next.js 或 Nuxt.js 那样,文件放对位置路由就自动生成了。
  • 提供开箱即用的类型:别让用户自己去猜泛型填什么。
  • 路径依赖设计:让最简单的写法,就是最佳实践。

5. 晦涩性 (Opacity) & 过度设计 (Needless Complexity)

症状: 这两个往往相伴而生。 你打开一个同事的代码,看到了一堆 AbstractFactoryProviderHighOrderComponentWrapper。 你只是想渲染一个输入框,结果你需要先创建一个 FormConfig,再实例化一个 FieldBuilder,最后通过 RenderProp 传进去。

开发者看着你的代码会感叹:“虽然看不懂,但感觉很厉害的样子。” —— 别傻了,他们心里在骂娘。

前端实战翻译: 为了封装而封装。比如把简单的 if-else 逻辑抽象成极其复杂的策略模式,或者写了无比抽象的 Hooks,结果参数传了 8 个,返回值 12 个。

指南:

  • YAGNI 原则 (You Ain't Gonna Need It):不要为你臆想的未来需求写代码。
  • 代码如文章:好的代码应该像大白话一样。如果一段代码需要你写 10 行注释来解释“我为什么要绕这么大弯子”,那通常意味着设计失败。
  • 组合优于继承,简单优于抽象:在前端,特别是 React Hooks 中,平铺直叙的逻辑往往比层层嵌套的高阶组件要好维护得多。

总结:如何避免成为“制造臭味”的人?

设计框架就像盖楼。

  • 僵化性是钢筋没绑好,想改户型得拆承重墙。
  • 脆弱性是地基没打牢,楼上装修楼下漏水。
  • 顽固性是水电管线混在一起,修电线得砸水管。
  • 粘滞性是垃圾道设计不合理,大家只好往楼下扔垃圾。

要去除这些“味道”,最核心的心法只有一句话:

保持代码的“软”度 (Software)。

软件之所以叫软件,是因为它应该是易于改变的。当我们写下一行代码时,多问自己一句: “如果明天这个需求变了,我今天写的这行代码是资产,还是债务?”


互动话题

你的项目里有没有那种“甚至不敢看它一眼,怕看一眼它就崩了”的代码?或者你见过最离谱的“过度设计”是什么样的?欢迎在评论区晒出你的“受苦”经历,让大家开心一下(划掉)避避坑。

前端向架构突围系列 - 架构方法(三):前端设计文档的写作模式

2026年1月8日 10:35

Gemini_Generated_Image_awm4poawm4poawm4.png

引言:架构师的“身后事”

  • 痛点直击:你是否遇到过这种情况?新项目启动时写了几十页 文档 (打个比方) ,三个月后代码改得面目全非,文档却再也没人打开过。
  • 核心冲突:代码是写给机器看的(追求准确),文档是写给未来的团队看的(追求理解)。
  • 观点抛出:一份好的架构文档(ADD - Architecture Design Document)不是繁文缛节,它是团队协作的“契约”和技术决策的“黑匣子”。

1. 法则一:升维思考 —— 引入 C4 模型

上一篇我们讲了 UML(微观的画笔),这一篇我们要讲宏观的地图。

  • 为什么要引入 C4?

    • 传统的 4+1 视图对敏捷开发来说有时过于厚重。
    • C4 模型 (Context, Containers, Components, Code) 像 Google Maps 一样,提供了从“全球”到“街道”的 4 个缩放级别,更适合现代 Web 应用。
  • 前端视角的 C4 拆解(图文结合):

    • Level 1: System Context (系统上下文)
    • 画什么:你的 Web App 和外部系统(支付网关、老后端、CDN)的关系。
    • 给谁看:非技术人员、产品经理。
    graph TD
      A[电商前端] -->|HTTPS| B[支付网关]
      A -->|WebSocket| C[实时推荐服务]
      D[老后端] -->|REST| A
      E[CDN] -->|静态资源| A
    
    • Level 2: Containers (容器)

      • 画什么这是前端架构师的主战场。SPA 客户端、BFF 层 (Node.js)、微前端子应用、移动端 WebView。
      • 给谁看:开发团队、运维。
    graph TB
      subgraph 前端架构
        A[SPA客户端] -->|GraphQL| B[BFF层]
        B -->|RPC| C[微前端子应用]
        D[移动端WebView] -->|REST| B
      end
    
    • Level 3: Components (组件)

      • 画什么:不是 React UI 组件,是业务功能模块(如:购物车控制器、身份认证服务、日志模块)。
    graph LR
      A[购物车控制器] -->|事件总线| B[库存校验服务]
      A -->|LocalStorage| C[本地缓存]
      D[身份认证模块] -->|JWT| E[API网关]
    
    • Level 4: Code (代码)

      • 观点不要画! 代码变化太快,图通过 IDE 自动生成即可,画了必死。

2. 法则二:决策留痕 —— 架构决策记录 (ADR)

这是本文最“硬核”、最能体现架构师价值的部分。

  • 什么是 ADR?

    • 文档不仅要记录“由于什么(Result)”,更要记录“为什么(Why)”。
    • 很多时候,新同事骂代码烂,是因为他们不知道当年的约束条件
  • ADR 标准模板(直接给干货):

    • 标题:ADR-001 采用 Tailwind CSS 替代 Styled-components

    • 状态:已通过 / 已废弃 / 提议中

    • 背景 (Context) :现有 CSS-in-JS 方案导致 Bundle 体积过大,且团队命名困难。

    • 决策 (Decision) :全线引入 Tailwind CSS。

    • 后果 (Consequences)

      • (+) 这里的 HTML 会变丑。
      • (+) 样式文件体积减少 40%。
      • (-) 需要统一配置 ESLint 插件进行类名排序。
  • 建议

    • 不要把 ADR 写在 Word 里,要放在代码仓库的 /docs/adr 目录下。

3. 法则三:文档即代码 (Docs as Code)

如何保证文档“活着”?让它和代码住在一起。

  • 工具链推荐

    • 存储:Git 仓库(与 package.json 同级)。
    • 编写:Markdown。
    • 画图:Mermaid(直接在 Markdown 里写代码生成图,便于 Git Diff 对比修改)。
    • 发布:VitePress / Docusaurus(自动构建成静态站点)。
  • 目录结构示例

4. 落地模板:一份“不被嫌弃”的架构文档骨架

  1. 背景与目标 (1句话说明项目价值)

    • [痛点/现状] + [解决方案] + [量化价值]
    • 示例 : “打造企业级前端微服务基座,通过微前端架构解耦巨石应用,实现多团队独立部署,并统一全线产品的 UI 交互体验与鉴权逻辑。”

    image.png

  2. 约束条件 (必须兼容 IE?必须 2 周上线?)

    • 根据内部自定义
  3. 系统架构图 (示例)

    graph LR
    %% 重构类项目
    P1["老旧系统"] --> 
    S1["技术升级<br/>React 18 + Vite"] --> 
    V1["性能提升<br/>维护成本降低"]
    
    %% 高并发项目
    P2["CSR瓶颈"] --> 
    S2["SSR渲染<br/>CDN分发"] --> 
    V2["SEO优化<br/>转化率提升"]
    
    %% 中台项目
    P3["巨石应用"] --> 
    S3["微前端<br/>模块解耦"] --> 
    V3["独立部署<br/>团队协作优化"]
    
    %% 样式
    classDef pStyle fill:#ffebee,stroke:#f44336,color:#b71c1c
    classDef sStyle fill:#e3f2fd,stroke:#2196f3,color:#0d47a1
    classDef vStyle fill:#e8f5e9,stroke:#4caf50,color:#1b5e20
    
    class P1,P2,P3 pStyle
    class S1,S2,S3 sStyle
    class V1,V2,V3 vStyle
    
  4. 关键技术选型 (链接到具体的 ADR 文件)

    graph LR
    %% 技术分类
    框架选型 --> React
    构建工具 --> Vite
    渲染模式 --> NextJS
    架构方案 --> Qiankun
    
    %% 具体技术
    React["React 18"] --> ADR1[" ADR-001"]
    Vite["Vite 5.x"] --> ADR2["ADR-002"]
    NextJS[" Next.js SSR"] --> ADR3[" ADR-003"]
    Qiankun[" qiankun"] --> ADR4[" ADR-004"]
    
    %% 替代说明
    React -.->|替代 jQuery| Legacy1
    Vite -.->|替代 Webpack| Legacy2
    NextJS -.->|优化首屏| Target1["FCP < 1.5s"]
    Qiankun -.->|微前端基座| Target2["模块解耦"]
    
    %% 样式
    classDef cat fill:#f5f5f5,stroke:#616161,font-weight:bold
    classDef tech fill:#bbdefb,stroke:#1976d2,color:#0d47a1
    classDef adr fill:#c8e6c9,stroke:#388e3c,color:#1b5e20
    
    class 框架选型,构建工具,渲染模式,架构方案 cat
    class React,Vite,NextJS,Qiankun tech
    class ADR1,ADR2,ADR3,ADR4 adr
    
  5. 非功能性需求 (NFRs)

    • 性能:FCP < 1.5s
    • 安全:XSS 防护策略
    • 监控:Sentry 报警规则
    graph LR
        %% 根节点
        NFRs["非功能性需求"] --> Perf
        NFRs --> Sec
        NFRs --> Mon
    
        %% 性能需求
        Perf["性能需求<br/>Performance"] --> P1["首屏<1.5s"]
        Perf --> P2["交互<100ms"]
        Perf --> P3["资源优化"]
    
        %% 安全需求
        Sec["安全需求<br/>Security"] --> S1["XSS防护"]
        Sec --> S2["CSRF防护"]
        Sec --> S3["数据加密"]
    
        %% 监控需求
        Mon["监控需求<br/>Monitoring"] --> M1["错误率<0.1%"]
        Mon --> M2["性能告警"]
        Mon --> M3["用户追踪"]
    
        %% 样式
        classDef rootStyle fill:#34495e,stroke:#2c3e50,color:white,font-size:16px
        classDef perfStyle fill:#3498db,stroke:#2980b9,color:white
        classDef secStyle fill:#e74c3c,stroke:#c0392b,color:white
        classDef monStyle fill:#2ecc71,stroke:#27ae60,color:white
    
        class NFRs rootStyle
        class Perf,P1,P2,P3 perfStyle
        class Sec,S1,S2,S3 secStyle
        class Mon,M1,M2,M3 monStyle
    

概括总结

“架构师的产出不是文档,而是共识。”

好的架构文档,不是为了证明你通过了答辩,而是为了让新入职的同学在一年后看到这行代码时,能通过文档里的 ADR 明白: “哦,原来当初是为了性能才写得这么‘奇怪’的。”

这就是文档的价值——穿越时间的沟通。

前端向架构突围系列 - 架构方法(一):概述 4+1 视图模型

2026年1月7日 11:32

Gemini_Generated_Image_wtlcmdwtlcmdwtlc.png

这个模型由 Philippe Kruchten 在 1995 年提出。它的本质含义是:没有一种单一的视图能够涵盖系统的方方面面。不同的利益相关者(Stakeholders)关心的是不同的东西。

  • 业务方关心功能(能不能用?)。
  • 开发关心代码结构(好不好写?)。
  • 运维关心部署和硬件(稳不稳定?)。
  • 用户关心操作流程(顺不顺畅?)。

架构师的职责,就是通过这 5 个视角,把这些“鸡同鸭讲”的需求统一成一个完整的系统设计。

为了让你更好理解,我将这个经典的后端/通用架构概念,完整“翻译”成前端架构师的视角


1. 场景视图 (Scenarios / Use Cases View) —— “+1” 的那个核心

本质:系统的灵魂,它驱动了其他 4 个视图。 这是架构设计的起点。如果不知道系统要干什么,设计就无从谈起。

  • 关注点:用户怎么用这个系统?核心业务流程是什么?

  • 谁看:所有利益相关者(产品经理、测试、开发、用户)。

  • 前端架构视角

    • 这不是指某个具体的 Button 点击事件,而是关键链路 (Critical User Journeys)
    • 例子:用户进入首页 -> 登录 -> 浏览商品 -> 加入购物车 -> 结算。
    • 架构决策:如果“秒杀”是核心场景,那么你在后续的“处理视图”中就必须设计高并发的防抖策略;在“物理视图”中就要考虑 CDN 缓存。
graph LR
    %% 样式定义
    classDef icon fill:#fff9c4,stroke:#fbc02d,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef step fill:#fff,stroke:#fbc02d,stroke-width:2px,color:#333,rx:10,ry:10;
    classDef note fill:#fffde7,stroke:none,color:#666;

    %% 左侧:核心概念
    User((用户<br/>User)):::icon

    %% 右侧:关键链路 (Critical Journey)
    subgraph Journey [关键链路: 秒杀场景]
        direction LR
        Step1[进入详情页]:::step --> Step2[抢购点击]:::step
        Step2 --> Step3[排队等待]:::step
        Step3 --> Step4[创建订单]:::step
        Step4 --> Step5[支付成功]:::step
    end

    User ==> Step1

    %% 架构决策点
    Note1(架构决策点:<br/>CDN缓存, 骨架屏):::note -.-> Step1
    Note2(架构决策点:<br/>高并发防抖, 乐观UI):::note -.-> Step2

2. 逻辑视图 (Logical View) —— “功能是怎么组织的?”

本质:系统的抽象模型。 这是最接近业务逻辑的一层,忽略具体的代码文件,只看概念

  • 关注点:系统有哪些“部件”?它们之间是什么关系?

  • 谁看:开发人员、业务分析师。

  • 前端架构视角

    • 组件模型:原子组件 vs 业务组件。
    • 领域模型:User, Product, Order 等实体定义(TypeScript Interface 定义)。
    • 状态管理设计:全局状态(Redux/Pinia)存什么?局部状态存什么?模块间如何通信?
    • 例子:画一张图,展示 OrderList 组件依赖 UserStoreAPI Service,而不关心它们具体写在哪个文件里。
graph LR
    %% 样式定义
    classDef icon fill:#e1f5fe,stroke:#039be5,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef layer fill:#fff,stroke:#039be5,stroke-width:2px,color:#333,rx:5,ry:5;
    classDef rel stroke:#90caf9,stroke-width:2px,stroke-dasharray: 5 5;

    %% 左侧
    Logic((抽象<br/>逻辑)):::icon

    %% 右侧:分层架构
    subgraph LayerSystem [前端逻辑分层]
        direction TB
        UI[<b>表现层 UI Layer</b><br/>Button, Layout, Page]:::layer
        Adapter[<b>适配层 Adapter</b><br/>Hooks, Presenters]:::layer
        Domain[<b>领域层 Domain</b><br/>UserEntity, CartModel]:::layer
        Infra[<b>基础层 Infra</b><br/>Axios, Storage, Logger]:::layer
    end

    Logic ==> UI

    %% 依赖关系 (单向依赖是架构的关键)
    UI --> Adapter
    Adapter --> Domain
    Adapter --> Infra

3. 开发视图 (Development / Implementation View) —— “代码是怎么写的?”

本质:系统的静态组织结构。 这是程序员每天面对的 IDE 里的样子。

  • 关注点:文件目录怎么分?用什么框架?依赖怎么管?

  • 谁看:开发人员、构建工程师。

  • 前端架构视角

    • 工程化结构:Monorepo (Nx/Turborepo) 还是 Multirepo?
    • 目录规范src/components, src/hooks, src/utils 怎么归类?
    • 依赖管理package.json 里的依赖,公共库(Shared Library)如何抽取?
    • 构建工具:Vite/Webpack 配置,分包策略(Chunking)。
    • 例子:决定把所有的 API 请求封装在 @api 目录下,并禁止组件直接调用 axios,这就是开发视图的约束。
graph LR
    %% 样式定义
    classDef icon fill:#f3e5f5,stroke:#8e24aa,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef file fill:#fff,stroke:#8e24aa,stroke-width:2px,color:#333,rx:2,ry:2;
    classDef tool fill:#f3e5f5,stroke:#8e24aa,stroke-width:1px,color:#333,stroke-dasharray: 5 5;

    %% 左侧
    Dev((工程<br/>代码)):::icon

    %% 右侧:目录与工具
    subgraph ProjectStructure [工程化与目录规范]
        direction TB
        
        subgraph Mono [Monorepo 仓库]
            Pkg1[packages/ui-lib]:::file
            Pkg2[apps/web-client]:::file
            Config[tsconfig.json]:::file
        end

        subgraph Toolchain [构建工具链]
            Vite(Vite / Webpack):::tool
            Lint(ESLint / Prettier):::tool
        end
    end

    Dev ==> Mono
    Mono -.-> Toolchain

4. 处理视图 (Process View) —— “系统是怎么运行的?”

本质:系统的动态行为、并发与性能。 对于前端来说,这是最容易被忽视,但最考验功底的一层。

  • 关注点:性能、并发、同步/异步、时序。

  • 谁看:系统集成人员、高级开发。

  • 前端架构视角

    • 异步流控:接口竞态问题(Race Condition)怎么处理?Promise 并发限制。
    • 生命周期:SSR(服务端渲染)的数据注水(Hydration)流程是怎样的?
    • 性能优化:Web Worker 处理复杂计算,避免阻塞主线程(UI 线程)。
    • 通信机制:WebSocket 怎么保持心跳?跨 Tab 通信(SharedWorker/LocalStorage)怎么做?
    • 例子:设计一个“大文件分片上传”的功能,你需要画出切片、上传、暂停、续传的时序图,这属于处理视图。

graph LR
    %% 样式定义
    classDef icon fill:#e8f5e9,stroke:#43a047,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef action fill:#fff,stroke:#43a047,stroke-width:2px,color:#333,rx:10,ry:10;
    classDef async fill:#c8e6c9,stroke:none,color:#333,rx:5,ry:5;

    %% 左侧
    Run((运行<br/>时序)):::icon

    %% 右侧:大文件上传时序
    subgraph AsyncProcess [大文件分片上传流程]
        direction TB
        Start[开始上传]:::action --> Check{检查文件}:::action
        Check -->|太大| Slice[Web Worker<br/>进行切片计算]:::async
        Slice --> Upload[并发上传切片<br/>Promise.all]:::action
        Upload --> Pause{网络中断?}:::action
        Pause -->|是| Wait[暂停 & 记录断点]:::async
        Pause -->|否| Finish[合并请求]:::action
    end

    Run ==> Start

5. 物理视图 (Physical / Deployment View) —— “代码跑在哪里?”

本质:软件到硬件的映射。 前端代码最终是要通过网络传输并运行在用户设备上的。

  • 关注点:部署、网络拓扑、硬件限制。

  • 谁看:运维工程师 (DevOps)、系统管理员。

  • 前端架构视角

    • 部署策略:静态资源上 CDN,Nginx 反向代理配置。
    • 运行环境:BFF 层运行在 Docker 容器里;前端代码运行在用户的 Chrome/Safari 里(考虑兼容性)。
    • 网络环境:弱网情况下如何降级?离线包(PWA)策略。
    • 多端适配:同一套代码是跑在 PC 浏览器,还是内嵌在 App 的 WebView 里?
    • 例子:决定使用“灰度发布”系统,将新版本的 JS 文件只推给 10% 的用户,这属于物理视图的范畴。
graph LR
    %% 样式定义
    classDef icon fill:#fff3e0,stroke:#fb8c00,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef device fill:#fff,stroke:#fb8c00,stroke-width:2px,color:#333,rx:5,ry:5;
    classDef net fill:#ffe0b2,stroke:none,color:#333,rx:20,ry:20;

    %% 左侧
    Deploy((部署<br/>环境)):::icon

    %% 右侧:部署拓扑
    subgraph NetworkTopology [资源分发与运行环境]
        direction LR
        
        subgraph Cloud [云端设施]
            CICD[CI/CD 构建产物]:::device --> OSS[对象存储]:::device
            OSS --> CDN((CDN 边缘节点)):::net
        end

        subgraph Client [用户终端]
            CDN --> Browser[PC 浏览器]:::device
            CDN --> Mobile[手机 WebView]:::device
            CDN --> Hybrid[小程序]:::device
        end
    end

    Deploy ==> Cloud

总结:软件开发的本质是什么?

通过 4+1 视图,我们可以得出软件开发的本质:

  1. 控制复杂度 (Managing Complexity) : 如果没有分层和视图,系统就是一团乱麻。4+1 试图把复杂问题拆解成 5 个维度分别解决。

  2. 沟通与妥协 (Communication & Trade-offs) : 架构不是追求“完美的代码”,而是平衡各方利益。

    • 为了物理视图的加载速度(上 CDN),可能要牺牲开发视图的便利性(复杂的构建流程)。
    • 为了处理视图的流畅度(虚拟列表),可能要增加逻辑视图的复杂度。

此时此刻,你可以做的 Next Step

作为想转型的架构师,不要只写代码,开始写文档

你可以挑选你当前负责的一个复杂模块,尝试写一份简易版的技术设计文档 (TDD) ,强制自己包含以下三点:

  1. 逻辑图:用方块图画出组件和数据流。
  2. 处理图:用时序图画出关键的用户交互流程。
  3. 部署说明:说明代码构建后怎么发布,有没有缓存策略。
❌
❌