跳到主要内容

<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 前同步执行。
  • 需要异步时的推荐解法:把异步放到 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
  • 某些依赖“当前实例”的注册必须同步完成:onMountedwatchprovide/injectuseRouter/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。