一、写在前面(抛出思考题)
过去的时间,你刷遍了面试题,在公司中工作了很多年,基于axios二次封装单项目级别的请求文件手到擒来。
你有没有想过?你是一个前端团队的资深老人,随着公司业务不断发展,各种各样的前端项目用着不同的请求技术方案。
A项目比较老,用的xhr;
B项目用了axios,拥抱开源;
C项目因为小王拥抱原生的理念,使用了fetch;
现在团队需要技术标准化,统一前端请求方案,将所有的请求集成到一个包里,如何设计技术方案,可以让这个包很健壮,未来很好维护?
这其实和大公司业务面广的背景很相似,比如美团外卖业务,在微信小程序端,你需要使用wx.request;在其他小程序,你需要使用不同的DSL去请求;在H5,你需要用到fetch/xhr/axios;在APP,你需要用到端上bridge能力,发出请求是计算机网络统一标准成熟的事情,如何基于不同的客户端环境来解耦并集成统一的前端解决方案?
二、方案设计
比较快速地实现是拆两层,直接在请求库中判断传入的配置,如axios、fetch、xhr,去执行不同的函数。
这样很简单,也能实现,但是会有很严重的问题:
“请求”这件事不变的点和变的点,耦合在了一起,比如埋点、异常上报、拦截器等共性的功能全部都耦合在了请求库本身中,随着接入能力越来越多,代码会变得更加混乱。
那不耦合在一起呢?放在对应的执行请求文件里?重复代码会很多,相同的“事情”会在多个文件中重复多次。
那有什么比较好的方案呢?
有,开源项目中比较多的技术方案,如axios、umi、openai,通常会引入client请求器+adapter适配器概念,什么意思呢?
- 首先整个请求库需要设定标准统一的入参和出参,如入参就是
url、options.....,出参就是success、data、code......; - client负责处理所有的通用逻辑,如埋点、异常上报、拦截器;
- adapter负责保存特定的请求能力,如wx.request、fetch、xhr,并且基于透传过来的标准化入参先做一层
transform,比如微信小程序的入参命名叫params,那这里就需要将标准化的入参转成这个命名,响应也同理,将不同请求方案返回的结构体标准化处理再回传给client,最后client进行埋点、响应拦截、异常上报等处理,再透传给业务侧;
用图来说即为三层架构:
一句话总结:
在用户和发出请求之间增加一层适配层,负责对用户定制一套标准统一的入参类型、一套统一标准的出参类型,但只需要这一套参数类型标准即可满足所有请求方案。
而前面提到的“耦合”问题,直接在client层执行即可,因此client层做的是 执行请求 + 执行通用事件;adapter层做的是参数转换 + 实际请求逻辑处理。
说到这里光看两张图的前后对比,可能感知没那么明显,接下来我基于xhr + fetch适配器+client来手写一个请求库感受一下这个架构设计吧。
三、代码实现
3.1、类型设计
入参标准化:
1{ 2 url: string, 3 method: 'GET' | 'POST' | ..., 4 headers: Record<string,string>, 5 body?: any, // 自动 JSON.stringify 或 FormData 等 6} 7
出参标准化:
1{ 2 status: number, 3 statusText: string, 4 headers: Record<string,string>, 5 data: any, // 已解析完成的响应数据 6 rawResponse: any // 原始 Response 或 XHR 对象 7} 8
这样在用户调用层 -> 请求库Client层核心的类型就设计好了。
3.2、核心代码设计
1// Adapter 抽象基类 2class Adapter { 3 request(config) { 4 throw new Error("Adapter.request must be implemented"); 5 } 6 7 // 入参标准化(可在基类内定义通用处理) 8 normalizeConfig(config) { 9 return { 10 url: config.url, 11 method: (config.method || 'GET').toUpperCase(), 12 headers: config.headers || {}, 13 body: config.body || null 14 }; 15 } 16 17 // 出参标准化 18 normalizeResponse({ status, statusText, headers, data, rawResponse }) { 19 return { 20 status, 21 statusText, 22 headers, 23 data, 24 rawResponse 25 }; 26 } 27} 28 29// Fetch Adapter 30class FetchAdapter extends Adapter { 31 async request(config) { 32 const cfg = this.normalizeConfig(config); 33 34 const response = await fetch(cfg.url, { 35 method: cfg.method, 36 headers: cfg.headers, 37 body: cfg.body ? JSON.stringify(cfg.body) : null 38 }); 39 40 // 解析 headers 41 const headersObj = {}; 42 response.headers.forEach((v, k) => { headersObj[k] = v; }); 43 44 // 自动解析 json / text 45 let data; 46 const contentType = response.headers.get('content-type') || ''; 47 if (contentType.includes('application/json')) { 48 data = await response.json(); 49 } else { 50 data = await response.text(); 51 } 52 53 return this.normalizeResponse({ 54 status: response.status, 55 statusText: response.statusText, 56 headers: headersObj, 57 data, 58 rawResponse: response 59 }); 60 } 61} 62 63// XHR Adapter 64class XHRAdapter extends Adapter { 65 request(config) { 66 const cfg = this.normalizeConfig(config); 67 68 return new Promise((resolve, reject) => { 69 const xhr = new XMLHttpRequest(); 70 xhr.open(cfg.method, cfg.url, true); 71 72 // 设置 headers 73 Object.entries(cfg.headers).forEach(([key, value]) => { 74 xhr.setRequestHeader(key, value); 75 }); 76 77 xhr.onreadystatechange = () => { 78 if (xhr.readyState === 4) { 79 const headersObj = this.parseHeaders(xhr.getAllResponseHeaders()); 80 let data; 81 try { 82 const contentType = headersObj['content-type'] || ''; 83 if (contentType.includes('application/json')) { 84 data = JSON.parse(xhr.responseText); 85 } else { 86 data = xhr.responseText; 87 } 88 } catch (err) { 89 reject(err); 90 return; 91 } 92 93 resolve(this.normalizeResponse({ 94 status: xhr.status, 95 statusText: xhr.statusText, 96 headers: headersObj, 97 data, 98 rawResponse: xhr 99 })); 100 } 101 }; 102 103 xhr.onerror = () => { 104 reject(new Error('XHR network error')); 105 }; 106 107 xhr.send(cfg.body ? JSON.stringify(cfg.body) : null); 108 }); 109 } 110 111 // 辅助方法解析 headers 字符串 112 parseHeaders(rawHeaders) { 113 const headers = {}; 114 rawHeaders.trim().split(/[\r\n]+/).forEach(line => { 115 const parts = line.split(': '); 116 const header = parts.shift(); 117 const value = parts.join(': '); 118 if (header) headers[header.toLowerCase()] = value; 119 }); 120 return headers; 121 } 122} 123 124// Client 类 125class Client { 126 constructor(adapter, options = {}) { 127 this.adapter = adapter; 128 this.baseURL = options.baseURL || ''; 129 this.defaultHeaders = options.defaultHeaders || {}; 130 } 131 132 async request(config) { 133 try { 134 const finalConfig = { 135 ...config, 136 url: this.baseURL + config.url, 137 headers: { ...this.defaultHeaders, ...(config.headers || {}) } 138 }; 139 console.log(`-----请求开始${finalConfig}-----`); 140 const res = await this.adapter.request(finalConfig); 141 console.log(`-----请求成功${res}-----`); 142 } catch(e) { 143 console.log(`-----请求失败${e}-----`); 144 throw(e); 145 } 146 } 147 148 get(url, headers) { 149 return this.request({ url, method: 'GET', headers }); 150 } 151 152 post(url, body, headers) { 153 return this.request({ url, method: 'POST', body, headers }); 154 } 155} 156 157// 使用示例 158const fetchClient = new Client(new FetchAdapter(), { 159 baseURL: 'https://jsonplaceholder.typicode.com', 160 defaultHeaders: { 'Content-Type': 'application/json' } 161}); 162 163const xhrClient = new Client(new XHRAdapter(), { 164 baseURL: 'https://jsonplaceholder.typicode.com', 165 defaultHeaders: { 'Content-Type': 'application/json' } 166}); 167 168fetchClient.get('/todos/1').then(res => console.log('FetchAdapter normalized:', res)); 169xhrClient.get('/todos/2').then(res => console.log('XHRAdapter normalized:', res)); 170 171
162行代码,核心的代码就实现完毕了(实际开发可以拆分文件处理)
主要干了这几件事情:
- 定义
BaseAdapter适配器抽象类,负责初始化请求、入参统一、出参统一三个核心能力; - 定义
XHRAdapter、FetchAdapter,实现各自的请求函数,基于原生不同的参数标准化处理响应结构,返回给Client层; - 定义
Client请求实例,调用传入的Adapter,透传对应配置,中转请求发出和响应;
这样 在每个 Adapter 内部,对 入参 和 出参 做一层 标准化处理,这样无论使用什么请求方式,Client 都能拿到统一结构的返回值,同时向 Adapter 传入的 config 也会有统一的字段,这也是很多通用库(axios 等)会做的事。
结尾
基于这个思路和模板,你非常容易的扩展很多能力,当你的公司业务需要接入京东小程序,请求相关的迭代很简单,新建一个适配器,把京东相关的DSL开发一下,把请求库更新个版本就搞定了。
请求监控、稳定性,你都不用管,因为这些早就成熟稳定地在多个项目跑了很久了。(自从你的请求库投产后)。
很多开源项目都是基于这个思路来工作的,包括你最熟悉的axios。
如果这篇文章对你有帮助,欢迎和我一起讨论。
《如何设计一个架构良好的前端请求库?》 是转载文章,点击查看原文。