idmp:网络请求去重, 一个优雅地解决幂等(idempotent) 函数的重复和并发调用的小库,纯函数,Gzip 后只有几百字节

前言

啥?"id买票", "id 卖片"??,"id 卖P"...

"id m ake promise"?

No no no, idmp 来自单词幂等 idempotent。幂等本是一个数学概念,表示一个函数运算任意多次的每次结果都全等。

比如在 react hooks 的使用中,很容易触发重复请求,针对这个问题目前最好的解决方案是 swr。 但 swr 并不支持在 hooks 之外调用。这个工具库借鉴了 swr 的思路,但是纯函数,可以在任何地方调用,不依赖 React 框架,而且压缩后大小完全可以忽略不计,只有几百字节。

除了在网络请求去重场景,也可以用它来满足缓存一些大运算量的计算结果、网络自动重试等应用场景。

使用

基础用法

typescript 复制代码
import idmp from 'idmp'

const getInfo = async () => {
  const API = `https://haozi.me/?api/your-info`
  return await fetch(API).then((d) => d.text())
}

export const getInfoIdmp = () => idmp('/api/your-info', getInfo)

for (let i = 0; i < 10; ++i) {
  getInfoIdmp().then((d) => {
    console.log(d)
  })
}

查看网络控制台,会发现只有 1 个网络请求,但会正确触发 10 次回调。

动态参数

typescript 复制代码
const getInfoById = async (id: string) => {
  const API = `https://haozi.me/?api/your-info&id=${id}`
  return await fetch(API).then((d) => d.json())
}

export const getInfoByIdIdmp = (id: string) =>
  idmp(`/api/your-info?${id}`, () => getInfo(id))

Then use getInfoIdmp to replace the original getInfo function.

Options

typescript 复制代码
declare const idmp: {
  <T, A>(
    globalKey: TGlobalKey,
    promiseFunc: Promise<T, A>,
    options?: IOptions,
  ): Promise<T>
  flush: (globalKey: TGlobalKey) => void
}

type TGlobalKey = string | number | symbol | false | null | undefined

interface IOptions {
  /**
   * @default: 30 times
   */
  maxRetry?: number
  /**
   * unit: ms
   * @default: 3000ms
   * @max 604800000ms (7days)
   */
  maxAge?: number
  /**
   *
   * @param err any
   * @returns void
   */
  onBeforeretry?: (
    err: any,
    extra: {
      globalKey: TGlobalKey
      retryCont: number
    },
  ) => void
}

flush

flush 是 idmp 的静态方法,会立即清除缓存,使得临近的下一次调用不使用缓存。

flush 接受一个 globalKey,没有返回值,重复调用或者 flush 一个不存在的 globalKey 不会有任何提示

typescript 复制代码
const fetchData = () => idmp('key', async () => data)

idmp.flush('key')
// will skip cache
fetchData().then(...)

在 React 中去重请求

在 react 共用请求,可以使用 swr 、 Provider 以及更为复杂的专业状态管理库来复用请求。但存在以下几种问题:

  1. swr: 需要将所有的请求变更为 hooks,不能嵌套和条件分支,对于已有项目有改造成本
  2. Provider 数据共享,需要一个中心化的数据管理。数据中心无法感知到哪些模块会消费数据,需要长期维护这些数据,而不敢及时删除
  3. redux 等状态管理库应该管理的是状态的变化和序列,而非共享数据。idmp 让你更关注于局部状态

查看 demo源码

这样当模块 A 或者模块 B 的代码删除后,是不需要维护他们的缓存的。

模块 A 和 B 有了更大的独立性,可以跨工程复用,而不必包裹在特定的 Provider 下。

健壮性

假设一个接口的请求失败率为 10%, 那么通过 3 次尝试后,请求仍失败的可能性将降到 0.1%

使用 idmp 包装的接口,内部会自动在超时或失败后进行重试,这会大大降低异常情况的出现。在每次重试前,你可以通过 onBeforeretry 勾子函数监听异常,便于做一些埋点统计(注意,它不会捕获最后一次错误)

typescript 复制代码
const getUserData = idmp(
  'using a deduplicated string as a key',
  async () => {
    await fetch(xxx)
  },
  {
    onBeforeretry: (rejectReason) => {
      log(rejectReason)
    },
    maxRetry: 30, // default
  },
)

优化大计算

虽然 idmp 的第二个参数必须是一个 Promise 函数,但由于同步函数都可以方便地包装成 Promise 对象。故 idmp 除了可以缓存网络请求外,原则上可以缓存任何函数调用。

这是一个没有经过任何优化的斐波那契数列的示例, 算到 45 项大约需要 10s:

typescript 复制代码
const fib = (n) => {
  if (n <= 2) {
    return 1
  }
  return fib(n - 2) + fib(n - 1)
}

const fibIdmp = (n) => idmp(`fib${n}`, async () => fib(n), { maxAge: Infinity })

for (let i = 0; i < 100; i++) {
  fibIdmp(40).then(console.log)
}

不可变数据

由于 js 的数据可变性,缓存的数据如果被外部修改,将导致后续的数据不一致,所以 idmp 不允许对返回数据进行写操作。 在开发环境中,会使用 Object.freeze 递归冻结数据,但为了线上运行时性能,这个检查会被忽略。

typescript 复制代码
requestIdmp().then((data) => {
  data.hello = 'world' // not allow
  const newData = { ...data }
  newData.hello = 'new world' // allow
  //  注意:由于 js 的特性,对 newData.aaa.bbb 进行写操作,仍然会改变原始数据,这个在开发阶段也会抛错。
})

不合适场景

函数内部会进行重试操作,会缓存请求数据, 故不适合以下场景

  • 非幂等的请求,如 POST/PATCH。注: HTTP 协议只是语义规范,事实上也可以把 GET 实现成非幂等,POST 实现成幂等,在使用前需要自行判断是否真幂等
  • 不能缓存的请求:如每次都要交换新的 token
  • 短于 16ms 的时效性数据,如获取服务器精准时间

注意:将 maxAge 设为 0 依然会在短时间内缓存数据,因为 js 的 setTimeout 是不精准的,设置成 0 依然会进行请求重试。 如果想完全不缓存结果,请把第一个参数设置成假值:'' | false | null | undefined | 0,这时候会完全退化成原始函数,不做失败重试。

typescript 复制代码
idmp(`xxx`, fetchData, { maxAge: 0 }) // 仍会在短时间内共享数据,仍会进行重试操作
idmp(null, fetchData) // 将无视所有配置项,与直接执行 fetchData 完全相同

实现原理

idmp 的核心原理是共用了一块内存地址,使用唯一标识符确定是同一函数的重复调用。 每个 Promise 的 resolve 和 reject 会被记录下来,内部维护了一个状态机,在 fulfilled 或 rejected 时完成回调。

另外,代码在开发环境(process.env.NODE_ENV !== "production")中,使用了非常 geek 的方式判断有没有全局重复使用了相同的 key 值,有兴趣的可以自行阅读源码。

注意事项

idmp 的核心原理是全局维护了一个共用缓存空间和状态机,由于 js 里无法快速比较两个对象实例是否全等,不得不采用了全局 KEY 的方式,所以一定要有一个全局唯一 KEY KEY 的可选值为 string | number | symbol

以及一值假值 false | null | undefined | 0,注意,0 是做为假值使用的,不会有任何缓存及重试效果

如果一个方法需要不同的参数进行多次调用,应当使用不同的 key,一个经典的方式是将参数 JSON.stringify

typescript 复制代码
const getInfo = async (options) => {
  const { id } = options
  const API = `https://google.com/api/your-info?id=${id}`
  return await fetch(API).then((d) => d.json())
}

export const getInfoIdmp = (options) =>
  idmp(`/api/your-info${JSON.stringify(options)}`, () => getUserData(options))

在开发态环境下,内置了一个简单的校验,警告在不同地方使用了相同的 key,但由于只是简单的将 function toString 比较,所以并不能检测到所有问题。

如果你有更复杂的网络需求,如自动刷新、本地与远端数据竞选等,idmp 由于是纯函数,无法实现相关功能,可以尝试 swrswrv

相关推荐
胡西风_foxww3 分钟前
【ES6复习笔记】数值扩展(16)
前端·笔记·es6·扩展·数值
mosen8685 分钟前
uniapp中uni.scss如何引入页面内或生效
前端·uni-app·scss
白云~️5 分钟前
uniappX 移动端单行/多行文字隐藏显示省略号
开发语言·前端·javascript
沙尘暴炒饭7 分钟前
uniapp 前端解决精度丢失的问题 (后端返回分布式id)
前端·uni-app
昙鱼21 分钟前
springboot创建web项目
java·前端·spring boot·后端·spring·maven
天天进步201527 分钟前
Vue项目重构实践:如何构建可维护的企业级应用
前端·vue.js·重构
小华同学ai30 分钟前
vue-office:Star 4.2k,款支持多种Office文件预览的Vue组件库,一站式Office文件预览方案,真心不错
前端·javascript·vue.js·开源·github·office
APP 肖提莫31 分钟前
MyBatis-Plus分页拦截器,源码的重构(重构total总数的计算逻辑)
java·前端·算法
问道飞鱼43 分钟前
【前端知识】强大的js动画组件anime.js
开发语言·前端·javascript·anime.js
k093344 分钟前
vue中proxy代理配置(测试一)
前端·javascript·vue.js