如何设计一个架构良好的前端请求库?

一、写在前面(抛出思考题)

过去的时间,你刷遍了面试题,在公司中工作了很多年,基于axios二次封装单项目级别的请求文件手到擒来。

你有没有想过?你是一个前端团队的资深老人,随着公司业务不断发展,各种各样的前端项目用着不同的请求技术方案。

A项目比较老,用的xhr

B项目用了axios,拥抱开源;

C项目因为小王拥抱原生的理念,使用了fetch

现在团队需要技术标准化,统一前端请求方案,将所有的请求集成到一个包里,如何设计技术方案,可以让这个包很健壮,未来很好维护?

这其实和大公司业务面广的背景很相似,比如美团外卖业务,在微信小程序端,你需要使用wx.request;在其他小程序,你需要使用不同的DSL去请求;在H5,你需要用到fetch/xhr/axios;在APP,你需要用到端上bridge能力,发出请求是计算机网络统一标准成熟的事情,如何基于不同的客户端环境来解耦并集成统一的前端解决方案?

二、方案设计

比较快速地实现是拆两层,直接在请求库中判断传入的配置,如axiosfetchxhr,去执行不同的函数。

这样很简单,也能实现,但是会有很严重的问题:

"请求"这件事不变的点和变的点,耦合在了一起,比如埋点、异常上报、拦截器等共性的功能全部都耦合在了请求库本身中,随着接入能力越来越多,代码会变得更加混乱。

那不耦合在一起呢?放在对应的执行请求文件里?重复代码会很多,相同的"事情"会在多个文件中重复多次。

那有什么比较好的方案呢?

有,开源项目中比较多的技术方案,如axios、umi、openai,通常会引入client请求器+adapter适配器概念,什么意思呢?

  1. 首先整个请求库需要设定标准统一的入参和出参,如入参就是url、options.....,出参就是success、data、code......
  2. client负责处理所有的通用逻辑,如埋点、异常上报、拦截器;
  3. adapter负责保存特定的请求能力,如wx.request、fetch、xhr,并且基于透传过来的标准化入参先做一层transform,比如微信小程序的入参命名叫params,那这里就需要将标准化的入参转成这个命名,响应也同理,将不同请求方案返回的结构体标准化处理再回传给client,最后client进行埋点、响应拦截、异常上报等处理,再透传给业务侧;

用图来说即为三层架构:

一句话总结:

在用户和发出请求之间增加一层适配层,负责对用户定制一套标准统一的入参类型、一套统一标准的出参类型,但只需要这一套参数类型标准即可满足所有请求方案。

而前面提到的"耦合"问题,直接在client层执行即可,因此client层做的是 执行请求 + 执行通用事件;adapter层做的是参数转换 + 实际请求逻辑处理。

说到这里光看两张图的前后对比,可能感知没那么明显,接下来我基于xhr + fetch适配器+client来手写一个请求库感受一下这个架构设计吧。

三、代码实现

3.1、类型设计

入参标准化:

typescript 复制代码
{
  url: string,
  method: 'GET' | 'POST' | ...,
  headers: Record<string,string>,
  body?: any, // 自动 JSON.stringify 或 FormData 等
}

出参标准化:

typescript 复制代码
{
  status: number,
  statusText: string,
  headers: Record<string,string>,
  data: any, // 已解析完成的响应数据
  rawResponse: any // 原始 Response 或 XHR 对象
}

这样在用户调用层 -> 请求库Client层核心的类型就设计好了。

3.2、核心代码设计

typescript 复制代码
// Adapter 抽象基类
class Adapter {
  request(config) {
    throw new Error("Adapter.request must be implemented");
  }

  // 入参标准化(可在基类内定义通用处理)
  normalizeConfig(config) {
    return {
      url: config.url,
      method: (config.method || 'GET').toUpperCase(),
      headers: config.headers || {},
      body: config.body || null
    };
  }

  // 出参标准化
  normalizeResponse({ status, statusText, headers, data, rawResponse }) {
    return {
      status,
      statusText,
      headers,
      data,
      rawResponse
    };
  }
}

// Fetch Adapter
class FetchAdapter extends Adapter {
  async request(config) {
    const cfg = this.normalizeConfig(config);

    const response = await fetch(cfg.url, {
      method: cfg.method,
      headers: cfg.headers,
      body: cfg.body ? JSON.stringify(cfg.body) : null
    });

    // 解析 headers
    const headersObj = {};
    response.headers.forEach((v, k) => { headersObj[k] = v; });

    // 自动解析 json / text
    let data;
    const contentType = response.headers.get('content-type') || '';
    if (contentType.includes('application/json')) {
      data = await response.json();
    } else {
      data = await response.text();
    }

    return this.normalizeResponse({
      status: response.status,
      statusText: response.statusText,
      headers: headersObj,
      data,
      rawResponse: response
    });
  }
}

// XHR Adapter
class XHRAdapter extends Adapter {
  request(config) {
    const cfg = this.normalizeConfig(config);

    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open(cfg.method, cfg.url, true);

      // 设置 headers
      Object.entries(cfg.headers).forEach(([key, value]) => {
        xhr.setRequestHeader(key, value);
      });

      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4) {
          const headersObj = this.parseHeaders(xhr.getAllResponseHeaders());
          let data;
          try {
            const contentType = headersObj['content-type'] || '';
            if (contentType.includes('application/json')) {
              data = JSON.parse(xhr.responseText);
            } else {
              data = xhr.responseText;
            }
          } catch (err) {
            reject(err);
            return;
          }

          resolve(this.normalizeResponse({
            status: xhr.status,
            statusText: xhr.statusText,
            headers: headersObj,
            data,
            rawResponse: xhr
          }));
        }
      };

      xhr.onerror = () => {
        reject(new Error('XHR network error'));
      };

      xhr.send(cfg.body ? JSON.stringify(cfg.body) : null);
    });
  }

  // 辅助方法解析 headers 字符串
  parseHeaders(rawHeaders) {
    const headers = {};
    rawHeaders.trim().split(/[\r\n]+/).forEach(line => {
      const parts = line.split(': ');
      const header = parts.shift();
      const value = parts.join(': ');
      if (header) headers[header.toLowerCase()] = value;
    });
    return headers;
  }
}

// Client 类
class Client {
  constructor(adapter, options = {}) {
    this.adapter = adapter;
    this.baseURL = options.baseURL || '';
    this.defaultHeaders = options.defaultHeaders || {};
  }

  async request(config) {
    try {
      const finalConfig = {
        ...config,
        url: this.baseURL + config.url,
        headers: { ...this.defaultHeaders, ...(config.headers || {}) }
      };
      console.log(`-----请求开始${finalConfig}-----`);
      const res = await this.adapter.request(finalConfig);
      console.log(`-----请求成功${res}-----`);
    } catch(e) {
      console.log(`-----请求失败${e}-----`);
      throw(e);
    }
  }

  get(url, headers) {
    return this.request({ url, method: 'GET', headers });
  }

  post(url, body, headers) {
    return this.request({ url, method: 'POST', body, headers });
  }
}

// 使用示例
const fetchClient = new Client(new FetchAdapter(), {
  baseURL: 'https://jsonplaceholder.typicode.com',
  defaultHeaders: { 'Content-Type': 'application/json' }
});

const xhrClient = new Client(new XHRAdapter(), {
  baseURL: 'https://jsonplaceholder.typicode.com',
  defaultHeaders: { 'Content-Type': 'application/json' }
});

fetchClient.get('/todos/1').then(res => console.log('FetchAdapter normalized:', res));
xhrClient.get('/todos/2').then(res => console.log('XHRAdapter normalized:', res));

162行代码,核心的代码就实现完毕了(实际开发可以拆分文件处理)

主要干了这几件事情:

  1. 定义BaseAdapter适配器抽象类,负责初始化请求、入参统一、出参统一三个核心能力;
  2. 定义XHRAdapterFetchAdapter,实现各自的请求函数,基于原生不同的参数标准化处理响应结构,返回给Client层;
  3. 定义Client请求实例,调用传入的Adapter,透传对应配置,中转请求发出和响应;

这样 在每个 Adapter 内部,对 入参出参 做一层 标准化处理,这样无论使用什么请求方式,Client 都能拿到统一结构的返回值,同时向 Adapter 传入的 config 也会有统一的字段,这也是很多通用库(axios 等)会做的事。

结尾

基于这个思路和模板,你非常容易的扩展很多能力,当你的公司业务需要接入京东小程序,请求相关的迭代很简单,新建一个适配器,把京东相关的DSL开发一下,把请求库更新个版本就搞定了。

请求监控、稳定性,你都不用管,因为这些早就成熟稳定地在多个项目跑了很久了。(自从你的请求库投产后)。

很多开源项目都是基于这个思路来工作的,包括你最熟悉的axios

如果这篇文章对你有帮助,欢迎和我一起讨论。

相关推荐
Queen_sy3 小时前
vue3 el-date-picker 日期选择器校验规则-选择日期范围不能超过七天
javascript·vue.js·elementui
lvchaoq3 小时前
react 修复403页面无法在首页跳转问题
前端·javascript·react.js
郝开3 小时前
6. React useState基础使用:useState修改状态的规则;useState修改对象状态的规则
前端·javascript·react.js
Codigger官方4 小时前
Linux 基金会牵头成立 React 基金会:前端开源生态迎来里程碑式变革
linux·前端·react.js
90后的晨仔4 小时前
🌟 Vue3 + Element Plus 表格开发实战:从数据映射到 UI 优化的五大技巧
前端
ObjectX前端实验室5 小时前
【图形编辑器架构】🧠 Figma 风格智能选择工具实现原理【猜测】
前端·react.js
天桥下的卖艺者5 小时前
R语言基于shiny开发随机森林预测模型交互式 Web 应用程序(应用程序)
前端·随机森林·r语言·shiny
技术钱5 小时前
vue3 两份json数据对比不同的页面给于颜色标识
前端·vue.js·json
路很长OoO5 小时前
Flutter 插件开发实战:桥接原生 SDK
前端·flutter·harmonyos