← 返回文章列表

前端必懂设计模式:依赖倒置原则(DIP)实战——HTTP请求封装

在前端开发中,我们每天都会和 HTTP 请求打交道——调用接口、获取数据、提交表单,几乎所有业务功能都离不开它。但很多开发者写请求代码时,都会陷入一个“牵一发而动全身”的坑:直接在业务代码里写死 axios/fetch,等到需要换请求库、切换环境(开发/测试/生产),或者做单元测试时,才发现到处都是需要修改的代码。
设计模式 2025-12-03 21:11:57 评论 0

一、先搞懂:依赖倒置原则到底是什么?

DIP 的核心定义只有两句话,记住这两句,就能抓住精髓:

  1. 高层模块(业务逻辑)不依赖低层模块(工具/库/接口实现),两者都依赖抽象;

  2. 抽象不依赖细节,细节(具体实现)依赖抽象。

用前端开发者能秒懂的大白话翻译:

业务代码(比如“获取用户信息”“提交订单”)不要直接依赖某个具体的请求工具(比如 axios),而是依赖一个“请求规范”(抽象);axios、fetch 这些具体的请求工具,都去实现这个“请求规范”。这样一来,换请求工具、改请求逻辑,业务代码完全不用动。

举个最直观的类比:你去餐厅吃饭(业务),不需要直接依赖“厨师A”“厨师B”(具体实现),只需要依赖“菜单”(抽象)——不管换哪个厨师,只要按照菜单做,你就能吃到想吃的菜。这里的“菜单”,就是 DIP 里的“抽象”。

二、反例:违反依赖倒置的 HTTP 请求写法(日常最常见)

先看我们平时最常写的请求代码,这种写法看似简单,实则隐藏着巨大的维护隐患。

假设我们用 axios 做请求工具,直接在业务代码里调用 axios,实现“用户相关接口”和“商品相关接口”:

1. 安装 axios 依赖

npm install axios --save
# 或 yarn add axios

2. 业务代码直接依赖 axios(坏写法)

// api/userApi.js (用户相关接口,直接依赖 axios)
import axios from 'axios';

// 基础配置(写死在业务文件里)
axios.defaults.baseURL = 'https://api.dev.com';
axios.defaults.timeout = 5000;

// 获取用户信息
export const getUserInfo = async (userId) => {
  try {
    const res = await axios.get(`/user/${userId}`);
    return res.data;
  } catch (err) {
    console.error('获取用户信息失败:', err);
    throw err;
  }
};

// 提交用户信息
export const submitUserInfo = async (userData) => {
  try {
    const res = await axios.post('/user', userData);
    return res.data;
  } catch (err) {
    console.error('提交用户信息失败:', err);
    throw err;
  }
};

// api/goodsApi.js (商品相关接口,同样直接依赖 axios)
import axios from 'axios';

// 这里又要重复配置 baseURL,或者依赖上面的配置,耦合度极高
axios.defaults.baseURL = 'https://api.dev.com';

// 获取商品列表
export const getGoodsList = async (params) => {
  try {
    const res = await axios.get('/goods', { params });
    return res.data;
  } catch (err) {
    console.error('获取商品列表失败:', err);
    throw err;
  }
};

3. 这种写法的问题(痛点直击)

  • 耦合度极高:所有业务代码都直接依赖 axios,一旦需要替换请求工具(比如换成 fetch、umi-request),所有 api 文件都要逐一修改,工作量巨大;

  • 配置混乱:baseURL、超时时间等配置分散在各个文件,修改环境(比如从开发环境切换到测试环境)时,需要到处找配置;

  • 测试困难:单元测试时,无法模拟 axios 请求,只能真实调用接口,测试效率低、易出错;

  • 扩展性差:如果需要添加请求拦截器(比如统一加 token)、响应拦截器(比如统一处理错误),需要在所有 api 文件中修改,重复劳动。

这就是违反依赖倒置原则的后果——高层业务模块(userApi、goodsApi)直接依赖低层模块(axios),导致代码僵硬、难以维护。

三、正例:遵循依赖倒置原则,封装 HTTP 请求

按照 DIP 的核心思想,我们分 4 步改造代码,实现“业务与请求工具解耦”,让代码更灵活、更可维护。

核心思路:先定义“请求抽象接口”(规范),再实现具体的请求工具(axios/fetch),最后让业务代码依赖抽象接口,而非具体工具。

步骤 1:定义抽象接口(核心,即“请求规范”)

抽象接口是 DIP 的核心,它定义了请求的“规范”——不管用什么工具,都必须实现这些方法,保证业务代码能“无缝调用”。

我们创建一个抽象类,定义 HTTP 请求的核心方法(get、post、put、delete),以及基础配置的规范:

// http/HttpClientInterface.js (抽象接口,定义规范)
class HttpClientInterface {
  // 构造函数:接收基础配置(baseURL、timeout 等)
  constructor(config = {}) {
    this.baseURL = config.baseURL || '';
    this.timeout = config.timeout || 5000;
  }

  // 抽象方法:get 请求(必须实现)
  async get(url, params = {}, headers = {}) {
    throw new Error('必须实现 get 方法');
  }

  // 抽象方法:post 请求(必须实现)
  async post(url, data = {}, headers = {}) {
    throw new Error('必须实现 post 方法');
  }

  // 抽象方法:put 请求(必须实现)
  async put(url, data = {}, headers = {}) {
    throw new Error('必须实现 put 方法');
  }

  // 抽象方法:delete 请求(必须实现)
  async delete(url, params = {}, headers = {}) {
    throw new Error('必须实现 delete 方法');
  }
}

export default HttpClientInterface;

说明:这个抽象类不包含任何具体实现,只定义“必须有什么方法”“方法的参数和返回值规范”。任何具体的请求工具(axios、fetch),都必须继承这个类,实现所有抽象方法——这就是“细节依赖抽象”。

步骤 2:实现具体的请求工具(axios 版)

我们基于抽象接口,实现一个 axios 版本的请求工具,让它符合“请求规范”。这里可以统一处理配置、拦截器、错误等,避免重复代码。

// http/AxiosClient.js (具体实现,依赖抽象接口)
import axios from 'axios';
import HttpClientInterface from './HttpClientInterface';

class AxiosClient extends HttpClientInterface {
  // 重写构造函数,初始化 axios 实例
  constructor(config = {}) {
    // 调用父类(抽象接口)的构造函数,继承基础配置
    super(config);
    // 初始化 axios 实例,应用基础配置
    this.instance = axios.create({
      baseURL: this.baseURL,
      timeout: this.timeout,
      headers: {
        'Content-Type': 'application/json',
        ...config.headers,
      },
    });

    // 统一请求拦截器(比如添加 token)
    this.instance.interceptors.request.use(
      (config) => {
        // 示例:从本地存储获取 token,添加到请求头
        const token = localStorage.getItem('token');
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (err) => Promise.reject(err)
    );

    // 统一响应拦截器(比如统一处理错误)
    this.instance.interceptors.response.use(
      (response) => response.data, // 直接返回响应体,简化业务代码
      (err) => {
        // 统一错误处理
        let errorMsg = '请求失败';
        if (err.response) {
          // 服务器返回错误
          switch (err.response.status) {
            case 401:
              errorMsg = '未授权,请登录';
              // 示例:跳转登录页
              window.location.href = '/login';
              break;
            case 403:
              errorMsg = '权限不足';
              break;
            case 404:
              errorMsg = '接口不存在';
              break;
            case 500:
              errorMsg = '服务器内部错误';
              break;
            default:
              errorMsg = err.response.data?.message || errorMsg;
          }
        } else if (err.request) {
          // 请求发送失败(比如网络错误)
          errorMsg = '网络异常,请检查网络连接';
        }
        console.error(errorMsg, err);
        return Promise.reject(new Error(errorMsg));
      }
    );
  }

  // 实现抽象方法:get 请求
  async get(url, params = {}, headers = {}) {
    return this.instance.get(url, { params, headers });
  }

  // 实现抽象方法:post 请求
  async post(url, data = {}, headers = {}) {
    return this.instance.post(url, data, { headers });
  }

  // 实现抽象方法:put 请求
  async put(url, data = {}, headers = {}) {
    return this.instance.put(url, data, { headers });
  }

  // 实现抽象方法:delete 请求
  async delete(url, params = {}, headers = {}) {
    return this.instance.delete(url, { params, headers });
  }
}

export default AxiosClient;

步骤 3:实现另一个具体请求工具(fetch 版,可选)

为了体现“换工具不影响业务”,我们再基于抽象接口,实现一个 fetch 版本的请求工具。这样一来,不管用 axios 还是 fetch,业务代码都不用改。

// http/FetchClient.js (另一个具体实现,同样依赖抽象接口)
import HttpClientInterface from './HttpClientInterface';

class FetchClient extends HttpClientInterface {
  constructor(config = {}) {
    super(config);
    // 继承父类的 baseURL、timeout 等配置
  }

  // 工具方法:处理请求参数(get 请求拼接 params)
  _handleParams(url, params = {}) {
    const paramStr = Object.entries(params)
      .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
      .join('&');
    return paramStr ? `${url}?${paramStr}` : url;
  }

  // 工具方法:处理 fetch 错误
  _handleError(response) {
    if (!response.ok) {
      let errorMsg = '请求失败';
      switch (response.status) {
        case 401:
          errorMsg = '未授权,请登录';
          window.location.href = '/login';
          break;
        case 403:
          errorMsg = '权限不足';
          break;
        case 404:
          errorMsg = '接口不存在';
          break;
        case 500:
          errorMsg = '服务器内部错误';
          break;
        default:
          errorMsg = `请求错误:${response.status}`;
      }
      throw new Error(errorMsg);
    }
    return response.json();
  }

  // 实现抽象方法:get 请求
  async get(url, params = {}, headers = {}) {
    const fullUrl = this._handleParams(`${this.baseURL}${url}`, params);
    return fetch(fullUrl, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        ...headers,
        Authorization: `Bearer ${localStorage.getItem('token')}`,
      },
      timeout: this.timeout,
    }).then(this._handleError);
  }

  // 实现抽象方法:post 请求
  async post(url, data = {}, headers = {}) {
    return fetch(`${this.baseURL}${url}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        ...headers,
        Authorization: `Bearer ${localStorage.getItem('token')}`,
      },
      body: JSON.stringify(data),
      timeout: this.timeout,
    }).then(this._handleError);
  }

  // 实现抽象方法:put 请求
  async put(url, data = {}, headers = {}) {
    return fetch(`${this.baseURL}${url}`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        ...headers,
        Authorization: `Bearer ${localStorage.getItem('token')}`,
      },
      body: JSON.stringify(data),
      timeout: this.timeout,
    }).then(this._handleError);
  }

  // 实现抽象方法:delete 请求
  async delete(url, params = {}, headers = {}) {
    const fullUrl = this._handleParams(`${this.baseURL}${url}`, params);
    return fetch(fullUrl, {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json',
        ...headers,
        Authorization: `Bearer ${localStorage.getItem('token')}`,
      },
      timeout: this.timeout,
    }).then(this._handleError);
  }
}

export default FetchClient;

步骤 4:业务代码依赖抽象,而非具体实现

这是最关键的一步:业务代码(userApi、goodsApi)不再依赖 axios 或 fetch,而是依赖我们定义的抽象接口(HttpClientInterface)。通过“注入”具体的请求工具,实现业务与工具的解耦。

首先,创建一个请求工具的“工厂”,统一管理请求实例,方便切换工具和环境:

// http/httpClientFactory.js (请求工具工厂,统一创建实例)
import AxiosClient from './AxiosClient';
import FetchClient from './FetchClient';

// 环境配置(可根据 process.env.NODE_ENV 自动切换)
const envConfig = {
  development: {
    baseURL: 'https://api.dev.com',
    timeout: 5000,
  },
  test: {
    baseURL: 'https://api.test.com',
    timeout: 5000,
  },
  production: {
    baseURL: 'https://api.prod.com',
    timeout: 8000,
  },
};

// 获取当前环境配置
const currentConfig = envConfig[process.env.NODE_ENV || 'development'];

// 工厂函数:创建请求实例(可切换 axios/fetch)
export const createHttpClient = (type = 'axios') => {
  switch (type) {
    case 'axios':
      return new AxiosClient(currentConfig);
    case 'fetch':
      return new FetchClient(currentConfig);
    default:
      throw new Error('不支持的请求工具类型');
  }
};

// 导出默认请求实例(默认用 axios)
export default createHttpClient('axios');

然后,改造业务代码,让它依赖抽象接口,而非具体工具:

// api/userApi.js (业务代码,依赖抽象接口)
import httpClient from '../http/httpClientFactory';

// 获取用户信息(只依赖抽象接口的 get 方法,不管底层是 axios 还是 fetch)
export const getUserInfo = async (userId) => {
  return httpClient.get(`/user/${userId}`);
};

// 提交用户信息(只依赖抽象接口的 post 方法)
export const submitUserInfo = async (userData) => {
  return httpClient.post('/user', userData);
};

// api/goodsApi.js (业务代码,同样依赖抽象接口)
import httpClient from '../http/httpClientFactory';

// 获取商品列表
export const getGoodsList = async (params) => {
  return httpClient.get('/goods', params);
};

// 更新商品信息
export const updateGoods = async (goodsId, goodsData) => {
  return httpClient.put(`/goods/${goodsId}`, goodsData);
};

// 删除商品
export const deleteGoods = async (goodsId) => {
  return httpClient.delete(`/goods/${goodsId}`);
};

四、改造后的优势(肉眼可见的提升)

对比之前的坏写法,遵循 DIP 改造后的代码,有以下 4 个核心优势,也是我们学习 DIP 的意义所在:

1. 解耦:业务与请求工具完全分离

想把 axios 换成 fetch?只需要修改 `httpClientFactory.js` 里的工厂函数,把 `createHttpClient('axios')` 改成 `createHttpClient('fetch')`,所有业务代码(userApi、goodsApi)完全不用动——因为业务代码只依赖抽象接口,不关心底层用什么工具。

// 切换请求工具,只需改这一行
export default createHttpClient('fetch');

2. 可维护性提升:统一管理配置和拦截器

baseURL、超时时间、请求/响应拦截器、错误处理,都集中在具体的请求工具(AxiosClient、FetchClient)里,不用分散在各个业务文件。修改环境配置、添加拦截器,只需要改一个地方,避免重复劳动。

3. 可测试性提升:轻松模拟请求,单元测试更简单

单元测试时,我们可以创建一个“模拟请求工具”,继承抽象接口,模拟请求的返回结果,不用真实调用接口。比如:

// 测试用的模拟请求工具
class MockHttpClient extends HttpClientInterface {
  async get(url, params = {}) {
    // 模拟返回用户信息
    if (url.includes('/user/')) {
      return { code: 200, data: { id: 1, name: '测试用户' }, message: 'success' };
    }
    // 模拟返回商品列表
    if (url.includes('/goods')) {
      return { code: 200, data: [{ id: 1, name: '测试商品' }], message: 'success' };
    }
  }

  // 其他方法同理,模拟返回
  async post() { /* 模拟返回 */ }
}

// 测试时,注入模拟工具
const mockHttpClient = new MockHttpClient();
// 替换业务代码里的 httpClient,即可进行单元测试

4. 扩展性强:轻松添加新的请求工具

以后如果需要用新的请求工具(比如 umi-request、taro-request),只需要新建一个类,继承 `HttpClientInterface`,实现所有抽象方法,再在工厂函数里添加对应的类型,就能无缝接入业务,不用修改任何业务代码。

五、前端开发中,DIP 的其他应用场景

除了 HTTP 请求封装,DIP 在前端还有很多实用场景,比如:

  • 日志工具封装(比如切换 console 日志、服务器日志、第三方日志库);

  • 存储工具封装(切换 localStorage、sessionStorage、IndexedDB);

  • UI 组件封装(比如统一按钮、输入框的接口,切换不同 UI 库的组件);

  • 状态管理封装(比如切换 Vuex、Pinia、Zustand)。

核心思路都是一样的:先定义抽象接口,再实现具体细节,最后让高层模块依赖抽象。

六、总结:依赖倒置不是“炫技”,是实用的编码思维

很多前端开发者觉得“设计模式是后端的东西,前端用不上”,但实际上,DIP 这样的设计原则,能帮我们解决日常开发中“代码僵硬、难以维护”的痛点。

回到 HTTP 请求的案例,我们再梳理一遍 DIP 的落地流程:

  1. 定义抽象接口(HttpClientInterface):规范请求方法和配置;

  2. 实现具体细节(AxiosClient、FetchClient):遵循抽象接口,处理具体逻辑;

  3. 高层模块(业务代码)依赖抽象:通过工厂注入具体实现,业务与细节解耦。

这样写出来的代码,不仅灵活、可维护,还能应对需求的频繁变化——这就是设计模式的价值。

最后提醒一句:DIP 不是“过度设计”,而是“提前规避风险”。对于小型项目,可能直接写 axios 也能应付,但对于中大型项目、长期维护的项目,遵循 DIP 能让你少走很多弯路,让代码更有“生命力”。

七、其他

// httpClientInterface.js
class HttpClient {
  get() {}
}

// AxiosClient
class AxiosClient extends HttpClient {
  get(url) {
    return axios.get(url);
  }
}

// FetchClient
class FetchClient extends HttpClient {
  get(url) {
    return fetch(url).then(res => res.json());
  }
}

// 业务
class UserApi {
  constructor(httpClient) {
    this.http = httpClient;
  }
  getUser() {
    return this.http.get('/user');
  }
}
评论: