大家好,这里是大家的林语冰。今天分享的是来自 Vue 官方资讯安利的一篇旧文,作者是"Vitest 之父兼 VueUse 之父兼 UnoCss 之父" ------ Anthony Fu 大佬(下文简称"托尼老师"),祂同时也是 Vue/Vite/Nuxt 核心团队成员。
免责声明
本文属于是语冰的直男翻译了属于是,仅供粉丝参考,英文原味版请临幸 Async with Composition API。
在 Vue 组合式 API 中使用异步函数时有一个重要的警告,我相信你们中的大多数人都邂逅过。我从某处知道祂已经有一段时间了,但每次我想有一个详细的参考并分享给祂者时,我都找不到祂的任何记录。因此,我考虑自己码字,为您整理可能的解决方案,同时详细说明一下。
- 问题所在
- 工作机制
- 局限性
- 解决方案
问题所在
每当使用异步 setup()
时,您不得不在首个 await
语句之前使用 effect(作用)和生命周期 hook(钩子)。(详情)
举个栗子:
js
import { onMounted, onUnmounted, ref, watch } from 'vue'
export default defineAsyncComponent({
async setup() {
const counter = ref(0)
watch(counter, () => console.log(counter.value))
// OK!
onMounted(() => console.log('Mounted'))
// await 语句
await someAsyncFunction() // <-----------
// 无法奏效!
onUnmounted(() => console.log('Unmounted'))
// 仍然奏效,组件销毁后但无法自动回收(内存泄漏!)
watch(counter, () => console.log(counter.value * 2))
}
})
await
语句之后,下列功能会有所限制(no auto-dispose,无法自动回收):
watch / watchEffect
computed
effect
下列功能不再奏效:
onMounted / onUnmounted / onXXX
provide / inject
getCurrentInstance
- ......
工作机制
让我们拿 onMounted
API 充当小白鼠。地球人都知道,onMounted
是一个 hook,当挂载当前组件时祂会注册一个侦听器。请注意,onMounted
(以及其他组合式 API)都是全局的 ,我此处提及的"全局"指的是祂可以在任何地方导入和调用 ------ 没有和祂绑定的局部上下文(local context)。
js
// 局部:onMounted 是其绑定的组件的一个方法
component.onMounted(/* ... */)
// 全局:onMounted 可以无上下文调用
onMounted(/* ... */)
那么,onMounted
如何知道什么组件正在挂载呢?
Vue 诉诸一种有趣的方法来解决这个问题。祂使用一个内部变量来记录当前组件实例。有一个简化的代码:
当 Vue 挂载一个组件时,祂将该实例存储在一个全局变量中。当在 setup()
函数中调用 hook 时,祂将使用该全局变量来获取当前组件实例。
js
let currentInstance = null
// 伪代码
export function mountComponent(component) {
const instance = createComponent(component)
// 保留先前的实例
const prev = currentInstance
// 将实例设置为全局
currentInstance = instance
// setup() 中的 hook 调用会将 currentInstance 作为上下文
component.setup()
// 恢复先前的实例
currentInstance = prev
}
onMounted
的简版实现如下所示:
js
// 伪代码
export function onMounted(fn) {
if (!currentInstance) {
warn(`"onMounted" can't be called outside of component setup()`)
return
}
// 当前实例绑定的侦听器
currentInstance.onMounted(fn)
}
诉诸此方法,只要 onMounted
在组件内部的 setup()
调用,祂就可以获取当前组件的实例。
局限性
目前为止一切顺利,虽然但是,异步函数有什么 bug 呢?
基于 JS 是单线程 的事实,该实现才会奏效。单线程确保下列语句会逐个执行,换而言之,无人可以在同一时间意外修改 currentInstance
(又名 原子操作)。
js
currentInstance = instance
component.setup()
currentInstance = prev
当 setup()
是异步时,情况有所不同。每当您 await
一个 promise,您都可以认为引擎暂停了这里的工作,并且跑去做另一项任务。如果我们 await
该函数,在这段时间内,创建多个组件会不可预测地更改全局变量并最终一塌糊涂。
js
currentInstance = instance
await component.setup() // 原子性丢失
currentInstance = prev
如果我们不诉诸 await
来检查该实例,调用该 setup()
函数将使祂完成首个 await
语句之前的任务,其余任务将在 await
语句 resolved(决议)后才执行。
js
async function setup() {
console.log(1)
await someAsyncFunction()
console.log(2)
}
console.log(3)
setup()
console.log(4)
md
// 输出结果:
3
1
4
(awaiting)
2
这意味着,Vue 无法知道异步部分何时会被外部调用,因此也无法将实例绑定到上下文中。
解决方案
这实际上是 JS 本身的局限性,除非我们有某些新提案来打开语言层级的新世界,否则我们不得不执子之魂,与子共生。
但是为了解决这个问题,我收集了若干解决方案供您按需选择。
牢记该警告并敬而远之
当然,这个"解决方案"一目了然。您可以尝试将您的 effect 和 hook 搬运到首个 await
语句之前,并牢记之后不要再使用祂们。
幸运的是,如果您有使用 ESLint,您可以启动 eslint-plugin-vue
的 vue/no-watch-after-await
和 vue/no-lifecycle-after-await
的规则,每当您犯错时祂都会警告你(祂们在插件预设中被默认启动)。
将异步函数封装为"响应式同步"
在某些情况下,您的逻辑可能依赖于异步请求的数据。这种场景下,您可以考虑使用我在 VueDay 2021 上分享的奇技淫巧 将您的异步函数转换为同步的响应式状态。
js
const data = await fetch('https://api.github.com/').then(r => r.json())
const user = data.user
js
const data = ref(null)
fetch('https://api.github.com/')
.then(r => r.json())
.then(res => (data.value = res))
const user = computed(() => data?.user)
此方法首先会 resolve 您的逻辑之间的"连接",然后在异步函数 resolve 后并填充数据时进行响应式更新。
VueUse 还有若干更通用的工具人:
js
import { useAsyncState } from '@vueuse/core'
const { state, ready } = useAsyncState(async () => {
const { data } = await axios.get('https://api.github.com/')
return { data }
})
const user = computed(() => state?.user)
js
import { useFetch } from '@vueuse/core'
const { data, isFetching, error } = useFetch('https://api.github.com/')
const user = computed(() => data?.user)
显示绑定实例
生命周期 hook 实际上接受第二个参数来显式设置实例。
js
export default defineAsyncComponent({
async setup() {
// await 之前获取并保留该实例
const instance = getCurrentInstance()
await someAsyncFunction() // <-----------
onUnmounted(
() => console.log('Unmounted'),
instance // <--- 将实例传递给祂
)
}
})
虽然但是,缺点是此方案不适用 于 watch / watchEffect / computed / provide / inject
,因为祂们不接受该实例参数。
要让 effect 奏效,您可以使用即将推出的 Vue 3.2 的 effectScope
API。
js
import { effectScope } from 'vue'
export default defineAsyncComponent({
async setup() {
// 在 await 之前创建 scope,那么祂会绑定此实例
const scope = effectScope()
const data = await someAsyncFunction() // <-----------
scope.run(() => {
/* 使用 computed、watch 等等...... */
})
// 生命周期 hook 在这里不奏效,
// 您需要将其与先前的片段结合起来,
// 以使生命周期 hook 和 effect 奏效。
}
})
编译时魔法!
在最近的 <script setup>
提案 更新中,引入了新型的 编译时魔法。
祂的工作方式是在每个 await
语句之后注入一个脚本,用于还原当前实例状态。
js
import { withAsyncContext } from 'vue'
export default {
async setup() {
let __temp, __restore
const post =
(([__temp, __restore] = withAsyncContext(() =>
fetch(`/api/post/1`).then(r => r.json())
)),
(__temp = await __temp),
__restore(),
__temp)
// 保留当前实例上下文
// 比如,onMounted() 依然奏效。
return { post }
}
}
有了祂,当和 <script setup>
梦幻联动时,该异步函数才会奏效 。唯一的耻辱在于祂在 <script setup>
之外无法奏效。
您现在收看的是直男翻译系列,学废了的小伙伴可以点赞友情赞助本系列,我们每天佛系投稿,欢迎关注和订阅最新动态。谢谢大家的彼芯,掰掰~