前端向架构突围系列 - 框架设计(四):依赖倒置原则(DIP)
写在前面
在前端圈子里,我们常说“这个组件太耦合了”或者“这段逻辑改不动”。大多数人习惯性地把锅甩给业务复杂或者前人留下的“屎山”。但如果我们冷静下来复盘,你会发现绝大多数的维护噩梦都指向同一个设计缺陷:高层逻辑被低层细节给绑架了。
今天我们要聊的依赖倒置原则(Dependency Inversion Principle, DIP) ,就是专门用来破解这种“死局”的架构利器。
一、 场景:一次痛苦的“技术升级”
想象一下,你负责一个复杂的 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 引用,陷入了沉思。不仅要改代码,还要面对全量回归测试的风险。这时候你才会意识到:你的业务逻辑,已经和具体的网络库“殉情”了。
二、 什么是依赖倒置?(别背定义,看本质)
传统的开发思维是自顶向下的:页面依赖组件,组件依赖工具类。这就像在盖房子时,把电线直接浇筑在混凝土里,想换根线就得拆墙。
依赖倒置原则核心就两句话:
- 高层模块不应依赖低层模块,两者都应依赖抽象。
- 抽象不应依赖细节,细节应依赖抽象。
通俗点说:谁拥有接口(Interface),谁就是老大。
在架构突围中,我们要把“控制权”翻转过来。高层业务不应该问:“我该怎么去调用 Axios?”而是应该傲娇地声明:“我需要一个能发请求的东西,至于你是用 Axios 还是 Fetch,我不在乎。”
三、 代码案例:从“死耦合”到“神解耦”
让我们用 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 端 "方言" | 小程序端 "方言" |
|---|---|---|
| 网络请求 |
fetch 或 axios
|
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,我们将跨端架构分成了清晰的三层:
- 核心业务层(稳定) :定义 Interface,编写业务逻辑。这一层代码在多端是完全共用的,一行都不用改。
-
接口契约层(抽象) :即
IHttpClient,IStorage等 Interface 定义。 - 基础设施层(易变) :各个端的具体 Adapter 实现(WebAdapter, MiniProgramAdapter)。
2. 制定官方语言(定义抽象接口)
业务层声明它需要什么能力,而不关心这能力怎么实现。这些 Interface 定义在核心业务域中。
3. 无感知的 Mock 与测试
写单元测试最痛苦的是 Mock 全局库。如果你遵循了 DIP,你只需要给业务逻辑注入一个 MockClient,连 jest.mock('axios') 这种黑盒操作都不用了。
4. 插件化架构
像 VS Code 或大型低代码平台,其核心框架并不依赖具体的插件。它定义了一套规范(抽象),所有的插件必须实现这些规范,这正是 DIP 的高级应用。
五、 结语:突围的核心是“心智负担”的转移
很多前端同学抗拒 DIP,觉得“我就写个业务,有必要搞这么复杂吗?”
确实,对于三天就扔的小程序,DIP 属于过度设计。但如果你在构建一个长期迭代的工程,DIP 的本质是在隔离变化。它把最不稳定的部分(第三方库、API 协议、浏览器差异)挡在了抽象层之外。
架构突围,不是为了炫技,而是为了在下一次需求变动、技术迁移时,你能气定神闲地改一行代码,而不是通宵改两百个文件。
互动环节: 你在项目中遇到过“因为换个库导致全线崩溃”的经历吗?或者你觉得在开发中,Context API 是否已经足够支撑起 DIP 的职责?欢迎在评论区博弈。