整理业务代码,发现 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 = true 和 finally { loading.value = false } 几乎快成为肌肉记忆。
最初,我以为痛点是重复,但它其实更复杂一点。
想清楚:loading 属于谁?
有很多工具已经处理好了 loading 的设置,比如 vueuse 的 useAsyncState。
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 心智负担
- 每次都要解构重命名变量
- 明明是关联的
execute和loading,每次都要手动做名字的关联
2 规范失控
团队里每个人都会写出不同风格:
submitLoading、isSubmitting、loadingSubmit,甚至直接保留 loading
于是代码库里的语义逐渐混乱。
并且重命名的审核成本极高,同事觉得挑刺,代码又在腐败。
而重命名的麻烦,导致宁可继续用原始的重复代码,也不想用 useAsyncState 后再解构重命名。
真正的痛点
想到这里,这块简单的代码,才暴露出真正的痛点:
- 代码复用 :能自动处理
true / false - 变量关联:在使用处能明显看出哪个 loading 属于哪个 async function
- 开发体验:解决上述问题,不能影响开发体验
死胡同里的摸索
代码复用好说,开发体验也可以先放下,主要问题在变量关联上。
我尝试过很多方案:
查找表模式
ini
const loading = getLoading(submit)
放弃。不优雅,而且全局有内存释放问题。
属性挂载模式
ts
submit.loading = ref(false)
// <button :loading="submit.loading.value">
放弃。不优雅:.value 问题,submit 是函数对象,不是响应式,模板不会自动解包普通对象的 ref 属性
普通对象返回
ts
const { execute, loading } = useAsync(...)
放弃。兜兜转转,好像又回到了原点。仍然需要手动重命名。
理想形态:变量名即关联
在用最原始写法的时候,我忽然意识到:
其实就是有个命名约定 ,两个变量命名成 submit 和 submitLoading,它们自然就是关联的。
需要的是一个命名 submit 输入,自动完成 两个变量名 submit 和 submitLoading 输出。
所以最理想的 API 设计就是:
ts
const { submit, submitLoading } = useAsync('submit', api.submit)
// <button :loading="submitLoading" @click="submit">Submit</button>
- 解构即绑定,变量名天然一致
阅读模板时,不需要跳转定义,就能知道状态属于谁。
- 用 ts 类型提示提醒出
submit和submitLoading
使用时先写 useAsync('submit', ...) 对应中文:使用异步(函数)submit,然后是 submit 的函数体。
再写 const { s 时就能 tab 自动补全出 submit 和 submitLoading,此时相当于在使用两个已经定义好的变量。
思维链依旧是先定义,再使用,是顺畅的
运行时代码的孤掌难鸣
当这个 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', ...) 后,如何让解构时自动出现 submit 和 submitLoading?
类型系统的黑墙
光有运行时代码毫无意义。
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 的年代,技术搜索其实很原始。
如果你不知道关键词,就很难找到答案。
所以这个想法在我的脑海里酝酿了很久。
每次看到模板里的 loading 和 submit 失去联系,我都会想起这个问题。
直到...
静下心与突破点
我决定系统地去理解下 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 设计。
尾声
- 在线体验简化代码:TypeScript Playground
- 实际初版代码:Github
vue-asyncx 后来增加了很多能力,比如竞态处理、异步监听、插件扩展。
但回头看,它最早的核心其实就是这三十行代码。
一个用类型系统把命名约定固化下来,解决异步操作上代码复用、代码语义、开发体验的小工具。