前端向架构突围系列 - 框架设计(四):依赖倒置原则(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 的职责?欢迎在评论区博弈。

相关推荐
程序员爱钓鱼2 小时前
Node.js 编程实战:测试与调试 —— 日志与监控方案
前端·后端·node.js
Mapmost2 小时前
数字孪生项目效率翻倍!AI技术实测与场景验证实录
前端
lizhongxuan2 小时前
Manus: 上下文工程的最佳实践
算法·架构
小酒星小杜2 小时前
在AI时代,技术人应该每天都要花两小时来构建一个自身的构建系统-Input篇
前端·程序员·架构
Cache技术分享2 小时前
290. Java Stream API - 从文本文件的行创建 Stream
前端·后端
陈_杨2 小时前
前端成功转鸿蒙开发者真实案例,教大家如何开发鸿蒙APP--ArkTS 卡片开发完全指南
前端·harmonyos
小杨同学492 小时前
C 语言实战:枚举类型实现数字转星期(输入 1~7 对应星期几)
前端·后端
陈_杨2 小时前
前端成功转鸿蒙开发者真实案例,教大家如何开发鸿蒙APP--ArkTS 卡片刷新机制
前端·harmonyos
go_caipu3 小时前
Vben Admin管理系统集成qiankun微服务(二)
前端·javascript