领导:写个请求库,要支持中断请求、缓存请求(幂等)、重试请求、脚手架生成代码模板...

现况

前端的请求库,大家基本都用的是 Axios

而他是基于 XHR 封装的,目前 XHR 已经停更了

相较于 fetch,缺失了一些功能

如:

  • 可读流
  • 中断请求
  • 自定义referrer

由于 fetchPromise ,所以只有两种状态,即 成功 | 失败

所以 fetch 不能获取请求进度,而 XHR 基于事件,所以可以获取请求进度

此外,fetch 还支持请求的优先级

缺失的功能

这些请求库,大多没有提供如下功能

  • 缓存请求
  • 重试请求
  • 并发请求

不过还是有一些库支持的,但是对于我而言,差点定制化

最重要的是,我喜欢造轮子,而不是写业务代码 😁

实现功能

第一,列出要实现的功能

这点相当重要,因为后面要改,可比先想好再写麻烦多了

  1. 基于 fetch 封装
  2. 提供脚手架,生成请求代码的模板
  3. 支持缓存(幂等)请求
  4. 支持重试请求
  5. 支持并发请求
  6. 支持中断请求

定义接口

滤清思路后,就要定义接口了

为什么一定要写个接口约束呢?

这是因为方便修改

举个例子,你用 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>
}

那么缓存抽象类要怎么缓存呢?

  1. 定义一个 Map,url 作为键,响应作为值
  2. Map 还需要存一个时间,如果过期了,就删除这个缓存
  3. 用户每次请求时,去缓存里看看,用深度递归的方式,比较值。如果请求体、url一致,则直接返回
  4. 每隔两秒,看看缓存有没有过期的,有则删除,释放内存
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,也仅需实现基础接口即可

这个后端同学应该比较熟

实现自动生成代码功能

  1. 定义配置文件
  2. 读取配置文件,生成对应的代码

就这两步,是不是很简单

但是读取文件只能用 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,大家直接去下载就能用了

代码内提供了完整的文档注释

www.npmjs.com/package/@jl...

相关推荐
m0_748255029 分钟前
前端常用算法集合
前端·算法
真的很上进23 分钟前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web1309332039829 分钟前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
NiNg_1_2341 小时前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
如若1231 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~2 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语2 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport2 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg2 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全
胡西风_foxww2 小时前
【es6复习笔记】rest参数(7)
前端·笔记·es6·参数·rest