在 <script setup> 里为什么“不能直接用 await”?如果要用怎么做?
面试速答(30 秒版 TL;DR)
- 严格说:Vue 3.2+ 的
<script setup>支持顶层await,但它会把组件编译成async setup(),组件会进入 Suspense 挂起 语义。 - 面试里常说“不能用 await”,核心原因通常是两类:
- 版本/构建限制:早期 Vue 3 或构建目标不支持顶层
await。 - 实例上下文丢失:在
async setup()里,await之后getCurrentInstance()等“依赖当前实例”的能力会变得不可用;组合式 API 的注册(onMounted/watch/useRouter等)也要求在首个await前同步执行。
- 版本/构建限制:早期 Vue 3 或构建目标不支持顶层
- 需要异步时的推荐解法:把异步放到
onMounted/watchEffect/ 组合式函数里;必须在setup阶段等待时,用<Suspense>承接挂起,并保证实例相关注册在await之前完成。
心智模型:<script setup> 的顶层 await 会发生什么
<script setup> 本质会被编译成组件的 setup()。当你在顶层写 await,编译器会把 setup 变成 async setup(),并做一层“异步上下文保持”的包装,避免 await 之后实例上下文丢失导致的组合式 API 异常。
你可以把它理解成:顶层 await 会让组件在首屏渲染前先等一等,等到 Promise resolve 之后再继续走渲染(如果没有 Suspense 边界,通常会出现警告/空白期)。
为什么很多场景“看起来不能用 await”
1) 版本或构建目标不支持
如果你的 Vue 版本低于 3.2,或构建产物目标不允许顶层 await,那确实会直接报语法/编译错误。这种情况没有技巧,升级 Vue 及构建链 才是正解。
2) await 之后实例上下文丢失,导致组合式 API 失效
在 async setup() 中,await 之后当前组件实例不再“自动可用”。这会带来两个常见坑:
getCurrentInstance()在await之后为null。- 某些依赖“当前实例”的注册必须同步完成:
onMounted、watch、provide/inject、useRouter/useRoute、Pinia 的useStore等(具体取决于实现,但面试按这个规则答最稳)。
因此面试里常会总结成一句话:setup 里不要 await,把异步放到副作用里。
推荐写法 1:setup 内保持同步,异步放到 onMounted
适合:首屏不必须等接口返回才能渲染(大多数业务都属于这类)。
<script setup lang="ts">
import { onMounted, ref } from 'vue';
type User = { id: string; name: string };
const user = ref<User | null>(null);
const loading = ref(false);
const error = ref<unknown>(null);
onMounted(async () => {
loading.value = true;
error.value = null;
try {
const res = await fetch('/api/me');
user.value = await res.json();
} catch (e) {
error.value = e;
} finally {
loading.value = false;
}
});
</script>
关键点:所有组合式 API 注册都是同步执行的,不会踩“await 之后上下文丢失”的坑。
推荐写法 2:需要“可取消”的异步,用 watchEffect + cleanup 处理竞态
适合:依赖响应式输入触发请求(搜索、筛选等),要避免“后发先至”覆盖。
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
const keyword = ref('');
const result = ref<any>(null);
const loading = ref(false);
watchEffect((onCleanup) => {
const q = keyword.value.trim();
if (!q) {
result.value = null;
return;
}
const controller = new AbortController();
onCleanup(() => controller.abort());
loading.value = true;
fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => {
result.value = data;
})
.finally(() => {
loading.value = false;
});
});
</script>
关键点:把异步“放到 effect 里”,并用 onCleanup 解决竞态。
需要 setup 阶段就等待怎么办:用 <Suspense> 承接挂起
适合:组件必须先拿到数据才能渲染,否则渲染没有意义(比如必须先拿到 schema 才能生成表单)。
原则:
- 把所有实例相关注册放在第一个
await之前。 - 在父级用
<Suspense>包住,提供fallback。
<!-- Parent.vue -->
<template>
<Suspense>
<template #default>
<Child />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
</template>
<!-- Child.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const schema = ref<any>(null);
// 顶层 await 会让组件挂起,直到 Promise resolve
const res = await fetch('/api/schema');
schema.value = await res.json();
</script>
典型追问
Q1:async setup() 允许吗?
允许,但它意味着组件会返回 Promise 并进入挂起语义。没有 <Suspense> 边界时体验和告警不可控,通常不建议在普通业务组件滥用。
Q2:为什么“组合式 API 必须在 await 之前注册”?
因为这些 API 依赖“当前组件实例”来记录副作用和清理逻辑;await 会把执行切到未来的微任务/宏任务,当前实例上下文可能已经不再可用。
易错点/坑
- 把
await放在setup顶层后,再调用onMounted/watch/useRouter等,可能出现奇怪的失效或告警。 - 顶层
await会把首屏渲染推迟到 Promise resolve,接口慢时白屏风险更高;除非你明确引入<Suspense>并设计 fallback。