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

写在前面

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

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


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

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

typescript 复制代码
// 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,我不在乎。"


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

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

1. 定义抽象(Interface)

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

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

2. 业务层依赖抽象

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

typescript 复制代码
// 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)只是契约的执行者。

typescript 复制代码
// 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)

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

arduino 复制代码
// 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,为了复用代码,很多团队会写出这种充斥着环境判断的"面条代码":

typescript 复制代码
// 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)来适配这套语言。

typescript 复制代码
// --- 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)。

typescript 复制代码
// --- 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 端适配器:
typescript 复制代码
// --- 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
}
小程序端适配器:
typescript 复制代码
// --- 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):
javascript 复制代码
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):
javascript 复制代码
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 的职责?欢迎在评论区博弈。

相关推荐
passerby60613 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅4 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅4 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment4 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅5 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊5 小时前
jwt介绍
前端
yunteng5215 小时前
通用架构(同城双活)(单点接入)
架构·同城双活·单点接入
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax