前段时间阅读了axios
的源码,代码量不多,而且相对vue
源码来说要简单很多,其中的设计思想也非常巧妙。阅读完之后,我就实现了一个微信小程序版本的axios
。然后我将两者进行对比,得出了如下的一些问题:
请求/响应拦截器,取消请求等功能的实现方式,甚至代码几乎是完全一样的
请求适配器会有所差别,主要原因是因为运行的环境不一样,底层的实现会有所不一样
配置参数冗余。首先axios
是支持web端
和node端
的,配置参数包含了web端
特有的,node端
特有的,还有web端
和node端
通用的。这使得我们在使用的时候需要注意哪些是特有配置参数,哪些是通用配置参数。然后自己实现的微信小程序版本的axios
也是如此,因为底层是基于wx.request
,wx.downloadFile
,wx.uploadFile
进行封装的,所以也包含了一些通用和特有的配置参数
基于如上的一些问题,我在想能否把一些通用的功能抽取出来,比如请求/响应拦截器
功能,这些功能都是跟环境无关的。然后把跟环境相关的,比如请求处理函数
,交给外部去实现,然后给到内部去使用。同时还要解决配置参数冗余的问题,解决不同环境下包含了一些多余的配置参数。
基于上面的目的,我第一时间想到的是koa2
的洋葱模型中间件
实现方式,洋葱模型中间件
主要解决的问题就是请求/响应拦截器
,请求/响应数据转换
等功能。实现方面使用es6的class类
实现,在初始化的时候让开发者自己去定义所需要的配置参数
我们首先来实现中间件MiddleWare
类,这个类比较简单,就是准备一个数组,然后将use
方法收集到的中间件存放到数组中,然后还需要提供一个exec
方法来执行所有中间件。代码如下:
class MiddleWare {
static cbs = [];
static use(cb) {
this.cbs.push(cb);
return this;
}
cbs = [];
exec(ctx, next) {
let times = -1;
const cbs = [...MiddleWare.cbs, ...this.cbs];
const dispatch = (pointer = 0) => {
if (cbs.length < pointer) {
return Promise.resolve();
}
if (pointer <= times) {
throw new Error("next function only can be called once");
}
times = pointer;
const fn = cbs[pointer] || next;
return fn(ctx, () => dispatch(++pointer));
};
return dispatch();
}
use(cb) {
this.cbs.push(cb);
return this;
}
}
从上面,我们可以看见,中间件分为 2 种,一种是全局的中间件,使用MiddleWare.use
静态方法进行收集,然后存放在MiddleWare.cbs
数组中。另外一种就是实例中间件。exec
方法在执行中间件的时候,会将全局的中间件和实例的中间件进行合并,然后执行中间件。所以,全局中间件每个实例都会被执行,实例中间件只有在当前实例中才会被执行。中间件分为全局中间件和实例中间件这是一种很常规的做法,很多第三方库设计都是如此的,比方说vue
可以注册全局组件和局部组件。
接着我们就来实现HttpRequest
这个类,这个类主要做的就是参数配置的合并,还有就是调用外部传进来的请求函数。代码如下:
function merge(op1 = {}, op2 = {}) {
return { ...op1, ...op2 };
}
class HttpRequest extends MiddleWare {
static config = {};
constructor(adapter, config = {}) {
super();
this.adapter = adapter;
this.config = merge(PreQuest.config, config);
}
request(url, config = {}) {
const opt = typeof url === "string" ? merge({ url }, config) : path;
const request = merge(this.config, opt);
const response = {};
return this.controller({ request, response });
}
async controller(ctx) {
await this.exec(ctx, async (ctx) => {
ctx.response = await this.adapter(ctx.request);
});
return ctx.response;
}
}
HttpRequest
继承了MiddleWare
这个类,这使得HttpRequest
具备了MiddleWare
类的所有功能。
配置参数。配置参数分为三种。第一种就是全局的配置参数,存储在HttpRequest.config
中,每个初始化出来的HttpRequest
实例都会具备所有全局配置参数;第二种就是实例的配置参数,在初始化的时候,传递进来的配置项跟全局的配置参数进行合并,得到一个新的配置参数,这个新的配置参数就是实例的配置参数;第三个中就是当前请求的配置参数,在发送请求的时候,会将实例的配置参数和请求传递进来的配置参数进行合并,得到一个新的配置参数,这个配置参数就是本次请求的配置参数
ctx
上下文。ctx
上下文包含了request
和response
两个字段。ctx
上下文贯穿于所有中间件中,用来实现中间件与中间件之间的数据传递,这个主要是借助了引用数据类型的特征,所有中间件在读取或者修改ctx
的内容的时候,实际上修改的都是指向同一地址的数据内容
adapter
请求函数其实也是作为一个中间件去执行的,只是adapter
请求函数是作为最后一个中间件,并不存在next
参数
最后,我们看一下基本的使用
// 请求处理函数
function adapter(config) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const { url, params = {} } = config;
const parts = [];
Object.keys(params).forEach((key) => {
parts.push(`${key}=${params[key]}`);
});
const requestUrl = `${url}?${parts.join("&")}`;
xhr.open(config.method.toUpperCase(), requestUrl);
xhr.send();
xhr.addEventListener("readystatechange", () => {
if (xhr.readyState === 4) {
resolve(xhr.responseText);
}
});
xhr.addEventListener("error", resolve);
});
}
// 初始化请求实例,用户自定义配置参数
const instance = new HttpRequest(adapter, {
baseUrl: "https://cnodejs.org/api/v1",
});
// 请求/响应拦截器中间件
instance.use(async function (ctx, next) {
console.log("请求拦截器");
await next();
console.log("响应拦截器");
});
// 请求/响应数据转换中间件
instance.use(async function (ctx, next) {
const { baseUrl, url } = ctx.request;
ctx.request.url = `${baseUrl}/${url}`;
await next();
ctx.response = JSON.parse(ctx.response);
});
// 发起请求
instance
.request("/topics", {
params: { page: 1, limit: 2, mdrender: false },
method: "get",
})
.then((res) => {
console.log("success", res);
});
我们从上面可以看出来,请求/响应拦截器
,请求/响应数据转换
等功能实际上是可以通过中间件去实现的,这使得我们可以将对应的功能拆分出来。这样子做的好处有:
实现按需加载的功能。请求/响应数据转换
功能并不是所有环境下都需要的,比方web
端是需要的,但是微信小程序端,因为wx.request
内部已经是经过封装的了,并不需要请求/响应数据转换
这个功能
HttpRequest
类只负责把所有的功能串联起来,并不负责具体的功能实现
实现了通用功能的提取
关于洋葱模型中间件的执行顺序。我们先来看如下的代码:
instance.use(async function middleware1(ctx, next) {
console.log("1");
await next();
console.log("2");
});
instance.use(async function middleware2(ctx, next) {
console.log("3");
await next();
console.log("4");
});
instance.use(async function middleware3(ctx, next) {
console.log("5");
await next();
console.log("6");
});
instance.use(async function middleware4(ctx) {
console.log("7");
});
执行顺序为1->3->5->7->6->4->2
,先按顺序把await next()
前面的代码执行完毕,然后再倒序执行await next()
后面的代码。实际上可以转化为如下代码:
async function middleware1(ctx) {
console.log("1");
await (async function middleware2(ctx) {
console.log("3");
await (async function middleware3(ctx) {
console.log("5");
await (async function middleware4(ctx) {
console.log("7");
})(ctx);
console.log("6");
})(ctx);
console.log("4");
})(ctx);
console.log("2");
}
最后。借助了koa2洋葱模型中间件
的思想,我们实现了一个精简版的http
请求库,这个请求库跟外部环境无关,具体请求逻辑需要有开发者自己去实现。同时一些通用的功能也是用来中间件去实现,实现了可插拔的功能。这样子做就可以抹平了不同平台上面的一些差异性。基于这个与平台无关的http
请求库,我们可以使用Monorepo
多包架构去实现不同平台下的请求处理函数。这样做一方面可以针对某个单独的平台去实现一些特有的功能,而不是使用if-else
去兼容多个平台。另一方面就是实现了按需加载的功能,减少代码体积,各平台只需要引入对应的包即可使用。