什么是异步函数的传染性?
假设有一个函数getUser用于从远程获取用户信息,内部使用了异步操作并将其作为返回结果,那么后续所有依赖函数getUser返回结果的其他函数都会被影响。这就是异步函数的传染性。
举个例子:
js
async function getUser() {
return await fetch('/xx.json')
}
async function m1() {
const user = await getUser()
return user
}
async function m2() {
const user = await m1()
return user
}
async function main() {
const user = await m2()
console.log(user)
}
上面这段代码,函数getUser内部用了fetch函数发起一个网络请求,并将其作为返回结果,所以函数getUser是一个异步函数。
函数m1的返回值又依赖于getUser的返回结果,所以函数m1也成了一个异步函数。以此类推,函数m2和函数main都成了异步函数。
通常情况下,这并没有什么问题。但如果你依赖于一个函数式编程环境,那么这样做就不可行。因为异步函数不是纯函数,具有副作用。
那么有没有一种办法把上面这些异步函数改成纯函数呢?换句话说,就是如何消除异步函数的传染性。
比方说这样:
js
function getUser() {
return fetch('/xx.json')
}
function m1() {
const user = getUser()
return user
}
function m2() {
const user = m1()
return user
}
function main() {
const user = m2()
console.log(user)
}
至于为什么需要这么做,纯函数有哪些优点,这里不做过多展开,我们简单一句话描述:纯函数具有可预测性,相同输入永远得到相同输出,不受外部状态或隐式依赖的影响。
接下来说说如何将上述代码改造成纯函数。
首先,我们可以看出来,函数m1、m2和main函数之所以成为异步函数,都是为了等待函数getUser执行完返回的结果,由于getUser是异步函数,所以调用链上面的函数必须逐步适应这种异步性,这种特性会沿着调用链向上"传播",导致原本同步的函数逐渐被"感染"成异步函数。
不难发现关键还是在于getUser函数,如果能将getUser函数改造成同步函数就可以解决了。
那有的小伙伴可能就困惑了,函数getUser变成同步函数,说白了就是要让fetch函数变成同步函数,但是fetch函数是要通知浏览器发送一个网络请求,且自身的返回值依赖于网络请求返回的结果,如果让函数getUser变成同步函数,那就是让本该异步的网络请求变成同步,那不阻塞了吗。
想到这里,似乎无解。

通过React当中的Suspense组件来思考如何实现
我们不妨想想React当中的Suspense组件。
vue
<Suspense fallback={"loading"}>
<Content/>
</Suspense>
当loading为true时,这个组件渲染的是一个文本------"loading",当loading为false时,才会渲染子组件Content。
Suspense组件工作的基本流程如下:
1、当 React 在 beginWork 的过程中遇到一个 Suspense 组件时,会首先将 Content 组件作为其子节点,根据 React 的遍历算法,下一个遍历的组件就是未加载完成的 Content 组件。
2、当遍历到 Content 组件时,Content 组件会抛出一个异常。该异常内容为组件 promise,react 捕获到异常后,发现其是一个 promise,会将其 then 方法添加一个回调函数,该回调函数的作用是触发 Suspense 组件的更新。并且将下一个需要遍历的元素重新设置为 Suspense,因此在一次 beginWork 中,Suspense 会被访问两次。
3、又一次遍历到 Suspense,本次会将 Content 以及 fallback 都生成。
虽然 primary 作为 Suspense 的直接子节点,但是 Suspense 会在 beginWork 阶段直接返回 fallback。使得直接跳过 Content 的遍历。因此此时 Content 必定没有加载完成,所以也没必要再遍历一次。本次渲染结束后,屏幕上会展示 fallback 的内容
当 primary 组件加载完成后,会触发步骤 2 中 then,使得在 Suspense 上调度一个更新,由于此时加载已经完成,Suspense 会直接渲染加载完成的 Content 组件,并删除 fallback 组件。
- Content 会组件抛出异常,React 捕获异常后继续 beginWork 阶段。
- 整个 beginWork 节点,Suspense 会被访问两次
顺序如下:
1、抛出异常
2、react 捕获,添加回调
3、展示 fallback
4、加载完成,执行回调
5、展示加载完成后的组件
beginWork 遍历顺序为:
Suspense -> Content -> Suspense -> fallback
按照这个思路,我们可以封装一个run函数作为启动函数来解决这个问题。
具体实现

js
function run(func) {
// 1. 保存旧的fetch
const oldFetch = window.fetch
// 2. 重写fetch
const cache = {
status: 'pending', // pending, fullfilled, rejected
value: null
}
function newFetch(...args) {
if(cache.status === 'fullfilled') {
return cache.value
} else if(cache.status === 'rejected') {
throw cache.value
}
// 无缓存
// 1. 请求
const p = oldFetch(...args).then(res => res.json())
.then(data => {
cache.status = 'fullfilled'
cache.value = data
})
.catch(err => {
cache.status = 'rejected'
cache.value = err
})
// 2. 抛出错误,终端执行
throw p
}
window.fetch = newFetch
// 3. 执行函数
try {
func()
} catch(err) {
if(err instanceof Promise) {
err.finally(() => {
window.fetch = newFetch
func()
window.fetch = oldFetch
})
}
}
// 4. 恢复fetch
window.fetch = oldFetch
}
run函数实现了一个对 fetch 请求的缓存和重试机制,核心思路是通过拦截 window.fetch 并在首次请求时暂停函数执行,待请求完成后再恢复执行。

1、缓存请求结果
通过 cache 对象存储请求状态(pending/fulfilled/rejected)和结果(value)。 若缓存命中(状态为 fulfilled 或 rejected),直接返回结果或抛出错误。
2、拦截 fetch
重写 window.fetch 为 newFetch,在首次调用时:
发起真实请求(通过保存的 oldFetch)。
缓存请求结果(成功或失败)。
抛出 Promise 对象(throw p)以暂停当前函数执行。
3、恢复执行
在 catch 中捕获抛出的 Promise,待其完成后(finally)重新执行原函数(func()),此时缓存已存在,直接返回结果。
清理还原
最终恢复原始的 window.fetch,避免污染全局。
写完,我们测试一下:
js
run(main)

输出结果:

我们看到main函数执行了两次,最终输出正确结果,符合期望。
总结:
通过纯函数执行抛出错误来中断执行,等到请求结束后,我们再重新执行main函数拿到网络请求返回的结果,从而消除异步函数传染性。上述代码只考虑了单个网络请求请求的情况,如果是多个并发,还需要建立映射关系。