Vue Asyncx 库三周年,回顾起源时的三十行代码

整理业务代码,发现 vue-asyncx 的最原始文件,提交在23年3月,只有三十行代码。

那是我纠结尝试许久的,想让代码复用代码语义开发体验三者兼顾的解。

重复很痛,但只是一部分

故事的起点很俗套。像很多开发者一样,我厌倦了这样的代码:

ts 复制代码
const data = ref()
const loading = ref(false)

const fetchData = async () => {
  loading.value = true
  try {
    data.value = await api.getData()
  } finally {
    loading.value = false
  }
}

loading.value = truefinally { loading.value = false } 几乎快成为肌肉记忆。

最初,我以为痛点是重复,但它其实更复杂一点。

想清楚:loading 属于谁?

有很多工具已经处理好了 loading 的设置,比如 vueuseuseAsyncState

useAsyncState 关注异步状态 state,这个 state 通过传入的 promise 拿到,isLoading 实际上是个附属品。

而在现实模型里:

  • data 是 async function 的结果
  • loading 是 async function 的状态

async function

  • 可以没有 data,比如大量的异步执行动作:提交表单、登出、发送埋点、刷新令牌,
  • 但一定有 loading,这是异步函数被调用后的固有属性

Loading 绑定的不是 Data,而是 Async Function。

也就是 data 是 async function + loading 模型中的特例。

useAsyncState 在处理这种场景上天然是语义错位的。

我需要一个 Async Function + Loading 的底层纯粹的工具。

但这还没有完。

想清楚:使用处的"语义失联"

假设我自己封装了一个简单的 hooks,仅封装 Async Function + Loading,使用时大概是:

ts 复制代码
const { execute, loading } = useAsync(...)
// 模板使用:<button :loading="loading" @click="execute">Submit</button>

问题就出在这里。

函数需要解构重命名

默认不重命名:模板里,Submit 按钮和函数的 execute 毫无关系。

检查时,会忍不住去确认 execute 就是 Submit 想触发的东西,需要来回跳跃。


如果要重命名,那使用过程就有个小小的思路跳跃:

const { ... } = useAsync(...) 会先写 useAsync(...),再回过头写 const { execute: xxx }。因为先写 const { e 没有 tab 补全。

思维链是 【submit 函数的 body】=> 【useAsync 的 execute】=> 【submit 的命名】,违背了'先定名,再定义'的自然开发流。

但这是个小问题。

loading 也需要重命名

ts 复制代码
const { execute: submit, loading } = useAsync(...)
// <button :loading="loading" @click="submit">Submit</button>

submit 的问题解决,发现 loading 是一个通用变量名,它和 submit 在代码文本上是平行存在的

仅看使用处,是看不出 loading 属于 submit,也就是它们之间的联系是丢失的。

必须跳回定义处,才能确认这个 loading 到底属于 submit 还是不知道哪的 query

如果单个组件存在多个异步,代码开始变成这样:

ts 复制代码
const { execute: submit, loading: submitLoading } = useAsync(...)
const { execute: login, loading: isLoginLoading } = useAsync(...)

这带来了两个工程问题:

1 心智负担

  • 每次都要解构重命名变量
  • 明明是关联的 executeloading,每次都要手动做名字的关联

2 规范失控

团队里每个人都会写出不同风格:

submitLoadingisSubmittingloadingSubmit,甚至直接保留 loading

于是代码库里的语义逐渐混乱。

并且重命名的审核成本极高,同事觉得挑刺,代码又在腐败。

而重命名的麻烦,导致宁可继续用原始的重复代码,也不想用 useAsyncState 后再解构重命名。

真正的痛点

想到这里,这块简单的代码,才暴露出真正的痛点:

  1. 代码复用 :能自动处理 true / false
  2. 变量关联:在使用处能明显看出哪个 loading 属于哪个 async function
  3. 开发体验:解决上述问题,不能影响开发体验

死胡同里的摸索

代码复用好说,开发体验也可以先放下,主要问题在变量关联上。

我尝试过很多方案:

查找表模式

ini 复制代码
const loading = getLoading(submit)

放弃。不优雅,而且全局有内存释放问题。

属性挂载模式

ts 复制代码
submit.loading = ref(false)
// <button :loading="submit.loading.value">

放弃。不优雅:.value 问题,submit 是函数对象,不是响应式,模板不会自动解包普通对象的 ref 属性

普通对象返回

ts 复制代码
const { execute, loading } = useAsync(...)

放弃。兜兜转转,好像又回到了原点。仍然需要手动重命名。

理想形态:变量名即关联

在用最原始写法的时候,我忽然意识到:

其实就是有个命名约定 ,两个变量命名成 submitsubmitLoading,它们自然就是关联的。

需要的是一个命名 submit 输入,自动完成 两个变量名 submitsubmitLoading 输出。

所以最理想的 API 设计就是:

ts 复制代码
const { submit, submitLoading } = useAsync('submit', api.submit)
// <button :loading="submitLoading" @click="submit">Submit</button>
  1. 解构即绑定,变量名天然一致

阅读模板时,不需要跳转定义,就能知道状态属于谁。

  1. 用 ts 类型提示提醒出 submitsubmitLoading

使用时先写 useAsync('submit', ...) 对应中文:使用异步(函数)submit,然后是 submit 的函数体。

再写 const { s 时就能 tab 自动补全出 submitsubmitLoading,此时相当于在使用两个已经定义好的变量。

思维链依旧是先定义,再使用,是顺畅的

运行时代码的孤掌难鸣

当这个 API 形态确定后,运行时代码其实非常简单:

js 复制代码
function useAsync(name, fn) {
  const loading = ref(false)
  const method = (...args: any[]) => {
    loading.value = true
    const p = fn(...args)
    if (p instanceof Promise) {
      p.finally(() => loading.value = false)
    } else {
      loading.value = false
    }
    return p
  }
  return {
    [name]: method, 
    [`${name}Loading`]: loading
  }
}

问题在于,useAsync('submit', ...) 后,如何让解构时自动出现 submitsubmitLoading

类型系统的黑墙

光有运行时代码毫无意义

js 复制代码
const { submit, submitLoading } = useAsync('submit', api.submit)

这样使用时,如果不能自动提示,相当于在要求使用者写三次变量名:

  • 传参时写一次 'submit'
  • 解构方法时写一次 submit
  • 解构 loading 时再写一次 submitLoading

而且这三次必须严格对应,错一个字母就断了链

于是问题变成了一个非常简单,但当时让我卡住很久的问题:

怎么写 TypeScript? 或者更简单一点,TypeScript 能做到吗?

前 AI 时代的摸索

回头看,这个问题似乎不难。

但在 2023 年初,我完全不知道从哪里开始。

我知道自己想要的效果:

ts 复制代码
const { submit, submitLoading } = useAsync('submit', api.submit)

但我不知道 TypeScript 是否支持这种根据字符串动态生成属性名的类型。

更不知道该搜索什么关键词。

不是 "how to write mapped type"。

而是:

TS 能不能做到这种事情?

在那个没有 AI 的年代,技术搜索其实很原始。

如果你不知道关键词,就很难找到答案。

所以这个想法在我的脑海里酝酿了很久。

每次看到模板里的 loadingsubmit 失去联系,我都会想起这个问题。

直到...

静下心与突破点

我决定系统地去理解下 TypeScript 的类型能力。

然后事情慢慢有了变化。

关键是三种 TypeScript 特性的组合:

1 函数泛型

它能将函数参数的类型保存到 ts 的"类型变量"上

ts 复制代码
function useAsync<Name extends string = ''>(name: Name, fn: any): any

2 模板字面量

它能将类型 submit 转换成类型 submitLoading

ts 复制代码
`${Name}Loading`

2 映射类型

它能让字符串类型变成对象类型的键

ts 复制代码
[K in ...]

于是,返回值类型就可以写成:

ts 复制代码
type UseAsyncResult<Name extends string> = {
  [K in `${Name}`]: any
} & {
  [K in `${Name}Loading`]: Ref<boolean>
}

当这个类型写出来后,IDE 就能自动推导:

那一刻我才真正确认:

TypeScript 不只是类型检查工具,它可以参与 API 设计。

尾声

vue-asyncx 后来增加了很多能力,比如竞态处理、异步监听、插件扩展。

但回头看,它最早的核心其实就是这三十行代码。

一个用类型系统把命名约定固化下来,解决异步操作上代码复用、代码语义、开发体验的小工具。

相关推荐
一只不会编程的猫2 小时前
Echart 3D环形图
前端·javascript·3d
脸大是真的好~2 小时前
黑马AI+前端教程 01-HTML-Trae-F12-live Server-标签-块级和内联元素-图片格式-路径
前端·html
前端付豪2 小时前
拍照识题 OCR
前端·后端·python
专业流量卡2 小时前
龙虾写useEffect源码第二天
前端
米开朗积德2 小时前
终于不用看到CSDN该死的弹窗限制了
前端·javascript
汤姆Tom2 小时前
我把 Vue Router 搬到了 React —— 从 API 到文件路由、转场动画,一个都不少
前端·react.js·面试
网络点点滴2 小时前
Vue组件通信-mitt
前端·javascript·vue.js
拾贰_C2 小时前
[spring boot | springboot web ] spring boot web项目启动失败问题
前端·spring boot·后端
王家视频教程图书馆2 小时前
大前端(原生开发的尽头是html css js)
前端·javascript·css