如何封装一个ajax库在面试是经常被问到的一个问题,大多数同学回答是:基于axios的拦截器做一些业务封装,某些情况面试官可能会问你,如果不使用axios应当怎么封装?
大多数同学到这里可能就选择放弃了,今天呢,我就以小程序为例,教大家一个使用洋葱模型来封装一个可任意拓展的微信小程序的AJAX库。
一、为社么要封装ajax
1、封装一些通用逻辑
2、处理与后端约定的状态码
3、统一添加接口监控,进行上报。如TTFP、报错等
4、其他各种问题...
当然了随着项目的演进,我们可能还要改这个方法,我是不太喜欢改代码,因为能跑尽量不动,所以我更喜欢增加
也就说这个库是需要一个可以近乎于无限拓展的功能。
还有一点就是AJAX分为两个过程,请求和响应,我希望能够在这个处理函数内部来控制自身的执行周期,而不是把控制权放出去,与外侧代码耦合。
于是这里我们采用洋葱模型来设计这个ajax库
二、洋葱模型是个啥?
可能很多同学没有听过这个,但是大名鼎鼎的koa.js应该都了解吧,这个就是基于洋葱模型来设计的(ps:可以搜一下很多),这里我用一张图,简单说明一下:
画的有点丑不过意思呢,就是这个意思:
第一点:每个中间件都有两次被调用的机会,在koa.js中使用,next()方法来进行分割,next()之前的是在请求前被执行,next()之后的在响应时被执行。
第二点:中间件是可以无限增加的,koa.js是使用use来进行添加的
第三点:中间件之间的数据流转使用一个统一的对象处理,这里使用ctx
第四点:既然是工具,TS务必要整上,提供代码提示,同时还要保证整个ajax环节,响应类型不能丢失
第五点:提供一些封装好的,应用的方法如get post,让我们使用起来更加的方便
三、直接上成品
说了这么多,我这里先上成品
// 定义一个请求参数的接口
interface RequestOptions {
url: string;
method: "GET" | "POST" | "OPTIONS" | "HEAD" | "PUT" | "DELETE" | "TRACE" | "CONNECT" | undefined;
[key: string]: any; // 其他任意属性
}
// 定义一个响应结果的接口,接受一个泛型 T
interface ResponseResult<T> {
data: T; // 响应数据,类型为 T
statusCode: number; // 状态码
header: any; // 响应头
}
// 定义一个上下文对象的接口,接受一个泛型 T
interface Context<T> {
req: RequestOptions; // 请求参数
res: ResponseResult<T> | null; // 响应结果,类型为 ResponseResult<T>,初始为 null
}
// 定义一个中间件函数的类型,接受一个泛型 T
type Middleware<T> = (
ctx: Context<T>,
next: () => Promise<void>
) => Promise<void>;
// 定义一个请求库类,并将类名修改为 Ajax
class Ajax {
// 存储中间件函数的数组,类型为 Middleware<any>[]
private middlewares: Middleware<any>[];
constructor() {
this.middlewares = [];
}
// 注册中间件函数,类型为 Middleware<any>
use(fn: Middleware<any>) {
this.middlewares.push(fn);
return this; // 链式调用
}
// 发起请求,接受一个泛型 T,用来指定响应数据的类型
request<T>(options: RequestOptions) {
// 创建一个上下文对象,包含请求和响应的相关信息,类型为 Context<T>
const ctx: Context<T> = {
req: options,
res: null,
};
// 调用洋葱模型函数,传入上下文对象和中间件数组,并反转数组的顺序,使得后加入的先执行
return this.onion(ctx, this.middlewares.reverse());
}
// 洋葱模型函数,返回一个 Promise 对象,接受一个泛型 T,用来指定响应数据的类型
onion<T>(ctx: Context<T>, middlewares: Middleware<any>[]): Promise<Context<T>> {
// 定义一个迭代函数,用来依次调用中间件函数
const dispatch = (i: number) : any => {
// 取出第 i 个中间件函数,如果不存在,则返回一个 resolved 的 Promise 对象
const fn = middlewares[i] || (() => Promise.resolve());
// 调用中间件函数,传入上下文对象和 next 函数,并返回一个 Promise 对象
return Promise.resolve(
fn(ctx, () => {
// next 函数,用来调用下一个中间件函数
return dispatch(i + 1); // 递归调用迭代函数,实现洋葱模型
})
);
};
// 从第一个中间件函数开始调用
return dispatch(0);
}
// 封装 get 请求的方法,只需要传入 url 和其他可选参数即可,接受一个泛型 T,用来指定响应数据的类型
get<T>(url: string, options?: Omit<RequestOptions, "url" | "method">) {
return this.request<T>({
url,
method: "GET",
...options, // 合并其他可选参数
});
}
// 封装 post 请求的方法,只需要传入 url 和其他可选参数即可,接受一个泛型 T,用来指定响应数据的类型
post<T>(url: string, options?: Omit<RequestOptions, "url" | "method">) {
return this.request<T>({
url,
method: "POST",
...options, // 合并其他可选参数
});
}
}
// 创建一个请求库实例,并将类名修改为 Ajax
export const ajax = new Ajax();
// 注册一个中间件函数,用来发起真正的网络请求,并将响应结果保存在上下文对象中
ajax.use(async (ctx, next) => {
console.log("AJAX请求函数")
// 使用 wx.request 发起网络请求,并返回一个 Promise 对象,指定泛型为 ResponseResult<any>
const res = await new Promise<ResponseResult<any>>((resolve, reject) => {
wx.request({
...ctx.req, // 使用上下文对象中的请求参数
success(res) {
resolve(res); // 请求成功,返回响应结果
},
fail(err) {
reject(err); // 请求失败,返回错误信息
},
});
});
ctx.res = res; // 将响应结果保存在上下文对象中
await next(); // 等待下一个中间件函数执行完毕(如果有的话)
});
// 注册一个中间件函数,用来打印请求的开始时间和结束时间
ajax.use(async (ctx, next) => {
console.log("开始结束时间中间件,next之前")
console.log("开始请求", ctx.req.url);
const start = Date.now();
await next(); // 等待下一个中间件函数执行完毕
console.log("next之后,开始结束时间中间件")
const end = Date.now();
console.log("结束请求", ctx.req.url, "耗时", end - start, "ms");
});
interface ResponseData {
code: number;
message: string;
data: any;
}
// 定义一个接口,用来描述响应数据的类型
// 使用请求库发起一个 GET 请求,并打印响应数据,使用封装的 get 方法,并指定泛型为 ResponseData
ajax.get<ResponseData>("https://www.baidu.com/").then((ctx) => {
console.log(ctx.res?.data); // 打印响应数据,使用可选链操作符防止空指针错误
});
// // 使用请求库发起一个 POST 请求,并打印响应数据,使用封装的 post 方法,并指定泛型为 ResponseData
// ajax.post<ResponseData>("https://example.com/api", { data: { name: "Alice" } }).then((ctx) => {
// console.log(ctx.res?.data); // 打印响应数据,使用可选链操作符防止空指针错误
// });
代码不是很多,200来行且我基本都加了注释,如果看懂了呢,下面就可以不用看了
四、重点讲解
onion<T>(ctx: Context<T>, middlewares: Middleware<any>[]): Promise<Context<T>> {
// 定义一个迭代函数,用来依次调用中间件函数
const dispatch = (i: number) : any => {
// 取出第 i 个中间件函数,如果不存在,则返回一个 resolved 的 Promise 对象
const fn = middlewares[i] || (() => Promise.resolve());
// 调用中间件函数,传入上下文对象和 next 函数,并返回一个 Promise 对象
return Promise.resolve(
fn(ctx, () => {
// next 函数,用来调用下一个中间件函数
return dispatch(i + 1); // 递归调用迭代函数,实现洋葱模型
})
);
};
// 从第一个中间件函数开始调用
return dispatch(0);
}
这个onion函数就是这段代码的关键函数,首先可以看出每一个middleware必须是一个promise,fn 函数中传入的,第二个function参数就是next函数。
ajax.use(async (ctx, next) => {
console.log("开始请求", ctx.req.url);
const start = Date.now();
await next(); // 等待下一个中间件函数执行完毕
const end = Date.now();
console.log("结束请求", ctx.req.url, "耗时", end - start, "ms");
});
从这个中间件我们来分析一下执行顺序首先我们直线了这个中间件的前两行,然后执行了next函数,里面递归执行了middlewares的下一个中间件,下一个中间件也是类似的代码,以此类推。
直到我们执行到了最后一个中间件,一个AJAX请求中间件:
ajax.use(async (ctx, next) => {
// 使用 wx.request 发起网络请求,并返回一个 Promise 对象,指定泛型为 ResponseResult<any>
const res = await new Promise<ResponseResult<any>>((resolve, reject) => {
wx.request({
...ctx.req, // 使用上下文对象中的请求参数
success(res) {
resolve(res); // 请求成功,返回响应结果
},
fail(err) {
reject(err); // 请求失败,返回错误信息
},
});
});
ctx.res = res; // 将响应结果保存在上下文对象中
await next(); // 等待下一个中间件函数执行完毕(如果有的话)
});
这个就是最后一个中间件,执行了wx.request。由于没有函数可以在递归了,就开始反过来执行next下面的代码,顺序和进来的时候相反。
但问题来了,从开发角度来说,我们当前唯一能确认的中间件是这个ajax中间件,也就说ajax这个中间件,要先被放入middlewares数组,如果按照先进先出的原则是无法完成.
所以在调用onion这个方法是,我们反转一下数组:this.middlewares.reverse()
request(options: RequestOptions) {
// 创建一个上下文对象,包含请求和响应的相关信息,类型为 Context<T>
const ctx: Context<T> = {
req: options,
res: null,
};
// 调用洋葱模型函数,传入上下文对象和中间件数组,并反转数组的顺序,使得后加入的先执行
return this.onion(ctx, this.middlewares.reverse());
}
这样就能保证最先被放进来的ajax中间件被放在最里层了。
是不是很简单,如果要封装自己的中间件,参照“打印请求的开始时间和结束时间”即可,如果需要修改request和response的话,直接修改ctx中对应的key即可。
最后来看一下执行结果: