现况
前端的请求库,大家基本都用的是 Axios
而他是基于 XHR 封装的,目前 XHR 已经停更了
相较于 fetch,缺失了一些功能
如:
- 可读流
- 中断请求
- 自定义referrer
由于 fetch 是 Promise ,所以只有两种状态,即 成功 | 失败
所以 fetch 不能获取请求进度,而 XHR 基于事件,所以可以获取请求进度
此外,fetch 还支持请求的优先级
缺失的功能
这些请求库,大多没有提供如下功能
- 缓存请求
- 重试请求
- 并发请求
不过还是有一些库支持的,但是对于我而言,差点定制化
最重要的是,我喜欢造轮子,而不是写业务代码 😁
实现功能
第一,列出要实现的功能
这点相当重要,因为后面要改,可比先想好再写麻烦多了
- 基于 fetch 封装
- 提供脚手架,生成请求代码的模板
- 支持缓存(幂等)请求
- 支持重试请求
- 支持并发请求
- 支持中断请求
定义接口
滤清思路后,就要定义接口了
为什么一定要写个接口约束呢?
这是因为方便修改
举个例子,你用 XHR 封装了一套 API
这时,fetch 突然发布了,那你不成了 49年入国军 了吗
这时你要改的话,那你就得非常的小心翼翼,一点点的对照之前的函数实现
为了避免以后发布比 fetch 更先进的 API 让我在写一遍
我提供了一个接口和一个抽象类
接口定义基础的请求方法,抽象类实现 缓存请求的方法
接口如下,就是 get | post ...
ts
/** 请求基础接口 */
export interface BaseHttpReq {
get<T, HttpResponse = Resp<T>>(url: string, config?: BaseReqMethodConfig): Promise<HttpResponse>
head<T, HttpResponse = Resp<T>>(url: string, config?: BaseReqMethodConfig): Promise<HttpResponse>
delete<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse>
options<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse>
post<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse>
put<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse>
patch<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse>
}
那么缓存抽象类要怎么缓存呢?
- 定义一个 Map,url 作为键,响应作为值
- Map 还需要存一个时间,如果过期了,就删除这个缓存
- 用户每次请求时,去缓存里看看,用深度递归的方式,比较值。如果请求体、url一致,则直接返回
- 每隔两秒,看看缓存有没有过期的,有则删除,释放内存
ts
/** 带缓存控制的请求基类 */
export abstract class AbsCacheReq implements BaseHttpReq {
abstract http: BaseHttpReq
/** 缓存过期时间,默认 1 秒 */
protected _cacheTimeout = 1000
/** 未命中缓存 */
protected static NO_MATCH_TAG = Symbol('No Match')
/** 缓存已超时 */
protected static CACHE_TIMEOUT_TAG = Symbol('Cache Timeout')
protected cacheMap = new Map<string, Cache>()
// ====================================================
constructor(protected config: BaseCacheConstructorConfig) {
this.clearCachePeriodically()
const { cacheTimeout } = config
if (cacheTimeout === undefined) return
this.cacheTimeout = cacheTimeout
}
static getErrMsg(status: number | string, msg?: string) {
if (msg != undefined) return msg
if (status == 400) {
return '请求参数错误'
}
if (status == 401) {
return '未授权,请重新登录'
}
if (status == 403) {
return '禁止访问'
}
if (status == 404) {
return '资源不存在'
}
if (status == 500) {
return '服务器内部错误'
}
if (status == 502) {
return '网关错误'
}
if (status == 503) {
return '服务不可用'
}
if (status == 504) {
return '网关超时'
}
}
// ======================= cache =======================
protected setCache(data: {
url: string
params: any
cacheData: any
cacheTimeout?: number
}) {
const { url, ...rest } = data
this.cacheMap.set(url, {
time: performance.now(),
...rest
})
}
/** 设置缓存超时时间 */
set cacheTimeout(timeout: number) {
if (timeout < 1) {
console.warn('缓存时间不能小于 1 毫秒')
return
}
this._cacheTimeout = timeout
}
protected compareCache(url: string, params: any) {
const cache = this.clearOneCache(url)
if (typeof cache === 'symbol') {
return cache
}
// 比较相同则返回缓存
if (deepCompare(params, cache?.params)) {
return cache.cacheData
}
return false
}
protected getCache(url: string, params: any) {
const cache = this.compareCache(url, params)
if (this.isMatchCache(cache)) {
console.log(`%c缓存命中 ${url}:`, 'color: #f40', cache)
return cache
}
return AbsCacheReq.NO_MATCH_TAG
}
protected isMatchCache(cache: any) {
return ![AbsCacheReq.CACHE_TIMEOUT_TAG, AbsCacheReq.NO_MATCH_TAG, false].includes(cache)
}
/** 定期清理缓存 */
protected clearCachePeriodically(gap = 2000) {
setInterval(
() => {
for (const url of this.cacheMap.keys()) {
this.clearOneCache(url)
}
},
gap
)
}
protected clearOneCache(url: string) {
const cache = this.cacheMap.get(url)
// 没匹配到
if (!cache) return AbsCacheReq.NO_MATCH_TAG
const now = performance.now()
const { cacheTimeout = this._cacheTimeout, time } = cache
// 超时则删除
if (now - time > cacheTimeout) {
this.cacheMap.delete(url)
return AbsCacheReq.CACHE_TIMEOUT_TAG
}
return cache
}
// ======================= 请求方法 =======================
get<T, HttpResponse = Resp<T>>(url: string, config?: BaseReqMethodConfig): Promise<HttpResponse> {
return this.http.get<T, HttpResponse>(url, config)
}
head<T, HttpResponse = Resp<T>>(url: string, config?: BaseReqMethodConfig): Promise<HttpResponse> {
return this.http.head<T, HttpResponse>(url, config)
}
delete<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse> {
return this.http.delete<T, HttpResponse>(url, data, config)
}
options<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse> {
return this.http.options<T, HttpResponse>(url, data, config)
}
post<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse> {
return this.http.post<T, HttpResponse>(url, data, config)
}
put<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse> {
return this.http.put<T, HttpResponse>(url, data, config)
}
patch<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config?: BaseReqMethodConfig): Promise<HttpResponse> {
return this.http.patch<T, HttpResponse>(url, data, config)
}
/** 缓存响应,如果下次请求未超过缓存时间,则直接从缓存中获取 */
async cacheGet<T, HttpResponse = Resp<T>>(url: string, config: BaseCacheReqMethodConfig = {}): Promise<HttpResponse> {
const cache = this.getCache(url, config.query)
if (this.isMatchCache(cache)) {
return cache as Promise<HttpResponse>
}
const { cacheTimeout, ...rest } = config
const cacheData = await this.get<T, HttpResponse>(url, rest)
this.setCache({
url,
cacheData,
params: config.query,
cacheTimeout
})
return cacheData
}
/** 缓存响应,如果下次请求未超过缓存时间,则直接从缓存中获取 */
async cachePost<T, HttpResponse = Resp<T>>(url: string, data?: ReqBody, config: BaseCacheReqMethodConfig = {}): Promise<HttpResponse> {
const cache = this.getCache(url, data)
if (this.isMatchCache(cache)) {
return cache as Promise<HttpResponse>
}
const { cacheTimeout, ...rest } = config
const cacheData = await this.post<T, HttpResponse>(url, data, rest)
this.setCache({
url,
cacheData,
params: data,
cacheTimeout
})
return cacheData
}
}
类型定义完毕,接下来只要实现请求的接口
然后继承那个抽象类即可
以后有再多的请求 API,也仅需实现基础接口即可
这个后端同学应该比较熟
实现自动生成代码功能
- 定义配置文件
- 读取配置文件,生成对应的代码
就这两步,是不是很简单
但是读取文件只能用 cjs
因为 ESM 不支持绝对路径导入模块,所以你想用动态 import 是不行的
但是我就想用 ESM 写配置文件怎么办呢?
那就只能转译一下代码,把 esm 转成 cjs
先写个辅助函数,给配置文件加上类型提示
ts
export function defineConfig(config: Config) {
return config
}
export type Config = {
/** 顶部导入的路径 */
importPath: string
/** 类名 */
className: string
/** 可以发送请求的对象 */
requestFnName: string
/** 类里的函数 */
fns: Fn[]
}
export type Fn = {
/** 函数的名字 */
name: string
/** 添加异步关键字 */
isAsync?: boolean
/** 请求地址 */
url: string
/**
* 生成 TS 类型的代码
* 你可以像写 TS 一样写,也可以写字面量,字面量会被 typeof 转换
*/
args?: Record<string, any>
/** 请求的方法,如 get | post | ... */
method: Lowercase<HttpMethod>
}
于是这样就有了类型提示,就算你用 js 也有
搭建脚手架
首先在 package.json
里的 bin
,写上执行的文件路径和执行命令名字
json
"bin": {
"jl-http": "./cli/index.cjs"
},
创建 ./cli/index.cjs
文件,第一行的注释告诉他要执行命令
下面的代码是打印你传递的参数
js
#!/usr/bin/env node
console.log(getSrc())
function getSrc() {
const [_, __, input, output] = process.argv
return {
input: resolve(process.cwd(), input || ''),
output: resolve(process.cwd(), output || ''),
}
}
然后 npm link
接下来你就能用自定义的命令了,比如我上面的命令
bash
jl-http ./src/config.ts ./src/output.ts
执行这行命令会输出你传递的路径
识别配置文件
我希望我能用 ESM ,但是上面的 cjs 代码显然是不能读取的
于是我写个简单的代码转移一下,然后把转移的文件,放入 node_modules
里的临时目录
到时候我读取那个临时文件即可,读完再删掉
ts
/** 代码转换 */
function esmTocjs(path) {
const content = readFileSync(path, 'utf-8')
const reg = /import\s*\{\s*(.*?)\s*\}\s*from\s*['"](.*?)['"]/g
return content
.replace(reg, (_match, fn, path) => {
return `const { ${fn} } = require('${path}')`
})
.replace(/export default/g, 'module.exports =')
}
/** 写入文件 */
function writeTempFile(cjsCode, tempPath, tempFile) {
createDir(tempPath)
writeFileSync(resolve(process.cwd(), `${tempPath}/${tempFile}`), cjsCode, 'utf-8')
}
最终要实现的效果如下,左边的配置会转成右边的代码
Q:你这配置文件比你代码还多,你是不是有病?(疑 )
A:写接口最麻烦的事就是定义类型,所以 args 参数直接复制文档即可
我这里的类型如果识别不到,就会用 typeof
转换,所以你直接复制就行了(悟! )
Q:为什么要用类呢?(疑 )
A:如果你接口写多了,那你导入的时候,你要import { ... 好多好多 }
,你记得住吗?
写静态类的话,你直接 类名.
就有代码提示了(悟!)
接下来的内容就很简单了,就是配置转字符串,也叫编译
也就类型转换有点难度,我把这部分贴一下,参数就是配置文件里的 args
js
const typeMap = {
string: 'string',
number: 'number',
boolean: 'boolean',
true: 'true',
false: 'false',
array: 'any[]',
object: 'object',
any: 'any',
null: 'null',
undefined: 'undefined',
function: 'Function',
BigInt: 'BigInt',
}
function genType(args) {
if (!args) return ''
let ts = '{'
for (const k in args) {
if (!Object.hasOwnProperty.call(args, k)) continue
const value = args[k]
const type = normalizeType(value)
ts += `\n\t\t${k}: ${type}`
}
ts += '\n\t}'
return ts
}
function normalizeType(value) {
const type = typeMap[value] ?? value
if (Object.keys(typeMap).includes(type)) return type
if (typeof type === 'string') {
let match = type.match(/.+?\[\]/g)
if (match?.[0]) {
return match[0]
}
}
return typeof type
}
至此,大功告成,代码我已经发布在 npm
,大家直接去下载就能用了
代码内提供了完整的文档注释