本文着重对于umi-request源码进行解读,从其设计角度和实现细节方面帮你了解封装一个通用的请求需要考虑哪些内容。
零、啰嗦几句
前端对于使用请求功能并不陌生,但是因为项目迭代关系,亦或者历史包袱问题,在一个团队里面不同项目,甚至不同仓库里面对于请求的使用都是五花八门的。
A:我们用的是Axios
B:还是用fetch吧,毕竟它更新
C:我这个项目里面还有XHR,倒是用着放心
D:我们团队不太好封装,返回值对接的后端规范不一致
E:团队封装的方法在请求拦截这里不好扩展,着急开发我就自己写了一个请求
F:不是我想用XHR,之前交接过来就是这个,我不敢动也没必要动
...
是不是看着都眼熟,我们都会因为这样那样的原因保留了那些五花八门的请求方案。而我们也总会听到这样的解释能用就行,干嘛在意这么多——从结果看确实来看可以这么说🌚。
但是我想阐明一下封装的意义:
- 使用统一,代码更规范
- 使用过程中的问题统一,一次错误踩一次坑,其他所有项目都能受益避坑
- 依靠前端规范来约定后端规范,前端代码更为统一
- 方便扩展团队层面的公共逻辑,不用每个项目都实现一遍
- 深入学习请求逻辑
- 更容易定位请求问题
- 更清楚如何封装逻辑,做好扩展逻辑
再回过头来看上述的列举的几个问题,会发现原因:
- 要么团队没有一个规范的请求封装库
- 要么对于请求逻辑不够清楚,不敢轻易改动逻辑
- 要么就是懒,但其实没有懒到点子上🤦🏻
一、如何设计
思考这个问题之前,我们需要回忆一下真实的请求是个什么样的过程?
如果所示,我想大家都会同意这样一个基本的请求流程,而每个使用的人都在不同程度地修改和调试请求拦截的部分和返回响应的部分,以最终适配我们的使用习惯。
带着这个过程,我们再回到设计的部分,既然是设计,我们究竟需要设计什么?我想有这么几个部分:
- 封装请求拦截
- 封装返回拦截
- 封装扩展能力——方便使用层去定制
- 封装请求的一些公共逻辑
- 封装功能完整:终止请求、自定义头、解析gbk数据格式等等
- 适当的语法糖,降低使用的成本和多样性
- 一些特定的团队规范
上述几个部分总结下来,在设计方面需要完成基本请求过程之外,需要很方便地进行扩展,无论是封装层面的扩展还是使用层面的扩展,因此在请求封装的层级关系之间需要很清晰。
那么,你可能会问,就按照上述的线性流程进行开发封装不就可以了吗?确实没有问题,但是稍微思考一下,也许可以有更好的实现方案?
举两个🌰:
- 请求拦截部分,这里会在封装层面进行开发,同样也会在使用层面进行调整。
- 思考个问题,封装层面怎么给调整逻辑留调整的口子?前面还是后面?
- 这个问题跟返回拦截雷同
- 封装层为了让使用更便利,一定会在请求模块中进行不同粒度功能的封装,这种封装和扩展对于使用层面几近黑盒。
- 第一个问题,用户层面想要扩展功能,怎么方便地与底层的逻辑耦合,怎么方便地管理这些逻辑关系
- 另外底层逻辑如果扩展了新模块或者迭代,怎么确保不影响现有的使用
当然,实际场景中可能不会有这么多的问题出现,可能稍微定制一些槽位即可实现。没错!但是站在封装的这个视角,还是希望考虑的更为全面,更方便使用。我想这也是为什么需要设计的意义所在。
那么,如何更好地解决上述问题呢?我想把上述的请求过程修改成如下所示:
所有的请求都是从用户发起的,最终回到用户这里,而中间要经历至少5个节点,也是封装层面必须要做的部分,与此同时还需要保留用户能够在这其中任意位置自定义的能力。
熟悉洋葱模型的应该从上述图中很容易发现,这个流程特别像洋葱模型逻辑。请求的路径是一层层逻辑处理,在核心请求逻辑之后又是一层层的逻辑处理,最终回到用户这里。
那么,如果请求按照洋葱模型能不能解决上面提到的问题呢?能不能比线性封装更好呢?
先回忆一下洋葱模型的设计逻辑:所有的功能都有一个最核心的功能,也就是该模型的最核心的部分,而核心两边可以扩展无数层逻辑。
而node中的洋葱模型本质就是在解决请求逻辑而存在的。用户如果想要扩展逻辑,只需要开发一些中间件即可,然后维护好中间件的逻辑关系即可保证所有逻辑都可以按照正常的流程处理。
此时,面对上述提到的两个问题,因为洋葱模型的中间件概念,此时无论在封装层还是使用层进行扩展都会变得简单,而且只需要让用户知道整体的中间件(拦截器)的关系即可随意扩展,你可以进行任何粒度任何关系的逻辑编排。
而umi-request就是按照洋葱模型来设计实现的。下面我们看看她在洋葱模型之上针对请求都做了哪些功能。
二、层级设计
先整体看一下umi-request的层级设计关系,如图所示:
一共有四层:
- Request
- 该层为umi特意封装的一层,为了兼容之前版本的api,达成无缝升级
- 主要功能有:透传Core层的中间件能力、拦截器能力,增加请求的取消功能和部分语法糖
- Core
- 使用层主要调用的对象---request
- 定义拦截器、中间件、配置项等
- Onion
- 管理中间件的一层
- 定义了middlewares、defaultMiddlewares、globalMiddlewares和coreMiddlewares四层,依次执行,从middlewares来,最终回到middlewares中
- 基础方法和中间件
- 封装的几个核心中间件
- 洋葱模型源码
- 基础方法
可以将上述封装简化成如下的流程,详细表明了中间件的执行关系。
严格按照Onion层的中间件设计和执行关系,可以很方便地实现自定义逻辑的部分。
三、中间件
1、fetch
- 发送前
- 解析参数
- 是否使用默认的request逻辑
- 缓存使用逻辑
- 发送中
// 超时处理、取消请求处理
// 通过race来决定先返回哪种场景
if (timeout > 0) {
response = Promise.race([cancel2Throw(options, ctx), adapter(url, options), timeout2Throw(timeout, timeoutMessage, ctx.req)]);
} else {
response = Promise.race([cancel2Throw(options, ctx), adapter(url, options)]);
}
- 发送后
- response拦截器
- 存入缓存
2、simpleGet
get请求可以封装什么内容呢?
- 设置credentials
// - omit: 从不发送cookies.
// - same-origin: 只有当URL与响应脚本同源才发送 cookies、 HTTP Basic authentication 等验证信息.(浏览器默认值,在旧版本浏览器,例如safari 11依旧是omit,safari 12已更改)
// - include: 不论是不是跨域的请求,总是发送请求资源域在本地的 cookies、 HTTP Basic authentication 等验证信息.
options.credentials = options.credentials || 'same-origin';
- params参数追加到url逻辑
- 默认支持自定义函数功能
- 默认url参数格式
- 数组和对象类型(约定的格式)
3、simplePost
post请求又需要考虑什么呢?
- 针对不同content-type类型进行body格式的封装
- json
{ "key1": "value1", "key2": "value2" }
- form
key1=value1&key2=value2
- 其他类型
- 不做任何格式的转换
- json
4、parseResponse
- 处理gbk(国标库)
// 处理gbk
function readerGBK(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsText(file, 'GBK'); // setup GBK decoding
});
}
try {
return res
.blob()
.then(readerGBK)
.then(d => safeJsonParse(d, false, copy, req));
} catch (e) {
throw new ResponseError(copy, e.message, null, req, 'ParseError');
}
- 处理Json
- 进行常规的JSON.parse逻辑操作
- 透传源数据,以便业务层面自定义
if (copy.status >= 200 && copy.status < 300) {
// 提供源response, 以便自定义处理
if (getResponse) {
ctx.res = { data: body, response: copy };
return;
}
ctx.res = body;
return;
}
四、扩展能力
umi-request中提供了两种方案进行扩展,一种是拦截器的扩展,另一种是中间件的扩展。这样在整个请求流程中都可以灵活组织你想要的逻辑,来实现你需要的功能。
1、拦截器的扩展
// request拦截器, 改变url 或 options.
request.interceptors.request.use((url, options) => {
return {
url: `${url}&interceptors=yes`,
options: { ...options, interceptors: true },
};
});
// 和上一个相同
request.interceptors.request.use(
(url, options) => {
return {
url: `${url}&interceptors=yes`,
options: { ...options, interceptors: true },
};
},
{ global: true }
);
// response拦截器, 处理response
request.interceptors.response.use((response, options) => {
const contentType = response.headers.get('Content-Type');
return response;
});
// 提前对响应做异常处理
request.interceptors.response.use(response => {
const codeMaps = {
502: '网关错误。',
503: '服务不可用,服务器暂时过载或维护。',
504: '网关超时。',
};
message.error(codeMaps[response.status]);
return response;
});
// 克隆响应对象做解析处理
request.interceptors.response.use(async response => {
const data = await response.clone().json();
if (data && data.NOT_LOGIN) {
location.href = '登录url';
}
return response;
});
2、中间件的扩展能力
因为umi-request中定义了不同级别的中间件,因此简单展示一下不同类型中间件的执行顺序
request.use(async (ctx, next) => {
console.log('instanceA1');
await next();
console.log('instanceA2');
});
request.use(async (ctx, next) => {
console.log('instanceB1');
await next();
console.log('instanceB2');
});
request.use(
async (ctx, next) => {
console.log('globalA1');
await next();
console.log('globalA2');
},
{ global: true }
);
request.use(
async (ctx, next) => {
console.log('coreA1');
await next();
console.log('coreA2');
},
{ core: true }
);
// 执行结果
instanceA1 -> instanceB1 -> globalA1 -> coreA1 -> coreA2 -> globalA2 -> instanceB2 -> instanceA2
五、其他细节
- 取消功能
- CancelToken的方式
- 这是一种已经废弃了的方式,源码中有详细的实现细节
- 核心思想是cancelable-promises方案,感兴趣可以看看
- 已经在使用层面和实现层面都被被废弃了的方案
- AbortController的方式
- 这是现今较为推崇的一种使用方式
- 核心API——AbortController,实例化该类,并通过实例方法abort方法进行主动中断
- CancelToken的方式
六、总结
本文通过umi-request的实现来探讨前端请求的封装案例,包括如何整体设计、如何考虑扩展性、如何封装一些公用能力和一些边缘问题的处理方法。
本文没有过多详细讨论很多细节内容,核心放在了想要阐明这样一种设计思想和部分实现,更多细节的内容可以参考umi-request的使用文档。
不过,Umi4中已经放弃了umi-request的使用方案——因为umi-request中底层使用的是fetch请求,这个方法在部分场景中使用存在一定的问题,在Umi4中他们又回到了axios的请求方式。虽然但是,不影响我们去学习这个请求库的封装设计和思考,也不影响我们去欣赏它别具一格的实现方案。