
最近在改一个基于 Vue3 的后台管理系统顺带帮几个同事过代码发现大家集中踩坑的地方竟然高度一致script setup里的 async 到底该怎么写哪些场景不能省写了之后又有哪些隐藏问题。群里经常有人贴出报错信息最常见的就是await is only valid in async function或者页面一加await就白屏、数据加载不出来。这篇文章就围绕“Vue3script setup中 async 通常不能省略”展开把这几年我在后台管理系统、商城项目、低代码平台里沉淀下来的经验一次说清楚。标题这句话其实是个很实用的提醒在script setup中async 不是你想省就能省的。省略的代价往往不是简单的语法错误而是一串连锁反应异步操作链路断掉、错误无法被捕获、组件渲染被挂起。适合刚转到组合式 API 的 Vue2 老手、正在学 Vue3 的初学者以及团队里负责代码评审的人看。下面我按场景拆开讲。1. 先搞清楚script setup到底做了什么1.1script setup是语法糖不是黑魔法很多刚从 Vue2 切过来的同学容易把script setup当成一个普通的模块脚本块写起来像在写一个 JavaScript 文件想当然地到处放await。其实script setup本质上是组合式 API 的语法糖编译器会把里面的顶层内容重新整理成一个setup()函数再自动帮你完成变量、函数的 return。普通写法是这样的// 传统 setup 写法 export default { setup(props, ctx) { const name ref(Vue3) function sayHi() { console.log(hi, ${name.value}) } return { name, sayHi } } }用script setup之后就变成script setup import { ref } from vue const name ref(Vue3) function sayHi() { console.log(hi, ${name.value}) } /script看起来只是少写了 return但关键点在于编译后的代码依然是同步执行的 setup 函数逻辑。既然归根到底是一个函数体那么你只能在允许 async 的函数内部使用 await顶层使用的限制就来自语言本身的规则而不是 Vue 给你开了特例。1.2 在script setup顶层直接 await 会怎么样官方文档其实告诉过我们script setup可以使用顶层 await但前提是组件外面必须包一层Suspense。如果你在顶层写了script setup const list await fetchList() /script没有 Suspense 包裹时组件会直接挂在等待 Promise 的状态上页面上大概率出现空白区域控制台还会打出警告提示你需要在父组件里使用 Suspense 来配合异步 setup。我在实际项目中碰到过一个印象很深的案例同事在商城首页里用顶层 await 拉取商品分类忘了包 Suspense结果一上线首页白屏控制台报错信息又不够直观排查了快两小时才定位到是顶层 await 的问题。所以这里先给大家建立两个基本认知顶层await语法合法但会改变组件的异步加载语义能用onMounted或具体函数里写 async 处理的场景尽量别用顶层 await。1.3 普通 setup 中的 async setup 已被替代老项目里可能见到过这样的写法export default { async setup() { const data await fetchData() return { data } } }这是早期 Vue3 支持的做法但后来官方推荐用script setup加 Suspense 来替代。在script setup中你不必也不能显式写async setup()而是靠“顶层 await Suspense”或“函数内部 async”来完成异步逻辑。很多人不理解这一点以为在script setup里写export default再去弄 async setup 就行结果代码风格越写越别扭还容易触发各种警告。2. 哪些场景中 async 省略不掉2.1 事件处理函数不加 async 就没有 await 的资格这是新手最容易踩的坑也是标题“async 通常不能省略”最直接的解释。比如你写一个搜索按钮的点击事件template button clickhandleSearch搜索/button /template script setup async function handleSearch() { const res await searchApi(keyword.value) list.value res.data } /script这段代码在handleSearch前写了 async一切正常。但如果你把 async 省了script setup function handleSearch() { const res await searchApi(keyword.value) // SyntaxError list.value res.data } /script浏览器直接报语法错误因为 JavaScript 语言规定await只能出现在 async 函数内部。这不是 Vue 的规则而是 ECMAScript 本身的约束。我经常在代码评审里看到有人为了“少写几个字符”把 async 去掉结果编译都过不去这种省略毫无意义。还有一种容易忽略的写法在模板事件里直接内联 asynctemplate button clickasync () { await doSomething() }点击/button /template这种写法虽然能过但内联函数会给模板增加额外的解析负担可读性也差。我建议一律提取成命名函数并明确写上 async别在模板里藏逻辑。2.2 生命周期钩子onMounted 里的异步请求后台管理系统的列表页十有八九要在页面加载时请求数据。很多人会在onMounted里这么写script setup import { onMounted } from vue onMounted(async () { loading.value true try { const res await fetchList(params.value) list.value res.data } finally { loading.value false } }) /script这里onMounted的回调函数必须声明为 async因为函数体里要用 await。你要是漏了 async直接变成onMounted(() { const res await fetchList() // SyntaxError })又是一模一样的语法错误。所以说“async 不能省略”在这些场景下是硬性的语法要求不是风格喜好。还有一个容易被忽略的细节Vue 本身不会等待生命周期回调返回的 Promise无论你写不写 asynconMounted的回调执行到第一个 await 之后生命周期就算走完了。所以不要在 onMounted 里用同步思维去依赖后面的数据。异步请求的时序要靠 loading 状态和响应式变量去管理而不是指望生命周期帮你等着。2.3 watch 和 watchEffect 中处理异步副作用搜索条件变化时自动重新加载列表是后台系统里的经典需求。用 watch 的时候回调里要写 asyncscript setup watch(searchParams, async (newVal, oldVal) { loading.value true try { const res await fetchList(newVal) list.value res.data } finally { loading.value false } }) /script这里不写 async 同样会飘语法错误。但 watch 场景比事件处理更微妙的地方在于watch 的旧值、新值以及回调里的异步操作存在时序竞争。比如用户快速切换分页第一次请求还没返回第二次请求又发出去了结果后返回的旧数据可能覆盖掉新数据。要解决这个问题我习惯在 async 回调里加上一个请求序号或者 AbortController把过期请求的结果丢弃。核心逻辑是script setup let requestSeq 0 watch(searchParams, async () { const currentSeq requestSeq const res await fetchList(searchParams.value) if (currentSeq requestSeq) { list.value res.data } }) /script这类竞态问题如果你不提等到线上数据错乱再排查会很痛苦。使用 async 只是一个开始真正难的是处理好并发覆盖。2.4 组合式函数和工具函数把 Promise 链保持完整在script setup里你可以自由引入组合式函数composables。比如你封装了一个useUserInfo// composables/useUserInfo.js export function useUserInfo() { async function loadUser() { const res await request(/api/user/info) return res.data } return { loadUser } }再在组件里调用script setup const { loadUser } useUserInfo() const user ref(null) async function init() { user.value await loadUser() } /script这种情况下loadUser是 async 函数init也是 async 函数Promise 链才完整。如果你在init里不去 await或者把init写成了非 async那么user.value拿到的就是一个 Promise 对象而不是真实数据页面渲染出来的就是[object Promise]之类的东西。还有一个特殊场景是 Vuex 或 Pinia 的 action。在 Pinia 中 action 经常是 async 的const store useStore() await store.fetchList()如果你在组件里调用 action 后想立刻读取 store 里的数据却不在调用处用 await那么代码顺序会乱套。我在商城项目里就见过很多人写store.fetchList() // 忘了 await console.log(store.list) // 还是空数组省略 async/await 往往能通过编译但运行结果完全不是预期。这种隐性问题比语法错误难查得多。3. 不省略 async 之后还要处理的四个细节3.1 竞态问题响应式变量会被多次异步覆盖前面提到 watch 场景中的竞态其实事件处理、onMounted、路由参数变化都可能出现竞态。比如在一个 Vue3 商城页里用户快速切换分类左右两列接口同时发出请求慢的那个接口先回来就把列表数据覆盖成了不该展示的内容。我在实际项目中常用的套路是“AbortController 请求序号”双保险let seq 0 let controller null async function loadList(params) { const currentSeq seq controller?.abort() controller new AbortController() try { const res await request(/api/list, { params, signal: controller.signal }) if (currentSeq seq) { list.value res.data } } catch (e) { if (e.name ! AbortError currentSeq seq) { error.value e.message } } }这样能保证只有最后一次请求的结果才会生效之前发出去的请求要么被取消要么被标记为过期。很多同学在项目里省略 async 导致的功能异常追根到底就是竞态处理没做而不是 async 本身的问题。3.2 loading 状态不能靠 async 自动管理新手常有一个美丽误会只要函数写了 asyncloading 就会自动变 true 再变 false。实际上不会。loading 是响应式变量的状态需要你在 async 函数里手动赋值。我推荐统一封装一个简单的请求状态管理async function withLoading(promise, loadingRef) { loadingRef.value true try { return await promise } finally { loadingRef.value false } }然后像这样用async function loadData() { const res await withLoading(request(/api/list), loading) list.value res.data }这样每个页面的 loading 逻辑都能收敛不会出现“这个接口没加载完另一个接口先结束导致loading闪烁”之类的状况。finally块保证了无论成功还是失败loading 最后都会被关掉。3.3 错误处理async 函数的 rejection 不会自动被 Vue 捕获事件处理函数一旦写成 async它返回的就是一个 Promise。如果函数内部抛错而这个 Promise 没有被 catch就会出现Unhandled Promise Rejection。在 Vue3 里模板事件绑定的 async 函数抛出异常时Vue 的错误处理机制不一定能兜住因为 Promise 的错误不会自动冒泡到组件的errorCaptured。一个非常现实的场景用户点击提交按钮接口返回 500你只写了async function handleSubmit() { await submitApi(form.value) message.success(提交成功) }报错之后界面没反应控制台刷了一条 unhandled rejection。线上用户看到的就是按钮点击后毫无反馈。我在自己项目里的做法是所有事件型的 async 函数都在内部 try/catch并调用统一的错误提示。3.4 顶层 await 的取舍能用但别滥用虽然上面提醒了很多次顶层 await 要慎用但也不能一棍子打死。如果初始化数据必须在组件渲染前就绪而且项目里已经统一封装了 Suspense 层级那么顶层 await 确实能让代码更简洁。比如详情页需要基础配置数据script setup const config await getConfig() const initialData await getDetail(params.id) /script这样模板里可以直接渲染config和initialData不用在模板里写一堆v-if判断是否加载完成。优点是逻辑扁平、直观。缺点也很明显顶层 await 会阻塞子组件树的渲染父组件不包 Suspense页面就空白。在后台管理系统中如果用户信息接口很慢所有菜单和路由都等着这个 Promise resolve体验会很差。所以我的原则很明确数据必须在首屏渲染前存在且团队统一使用 Suspense 的才用顶层 await可延迟的、可局部 loading 的数据一律放到 onMounted 或事件回调里用 async 函数处理。4. 实操从零写一个带异步加载的后台列表页面4.1 需求描述我们来做一个小型后台列表页演示各种“async 不能省略”的场景。需求是页面加载时自动请求列表数据搜索框输入内容后按回车或点击搜索按钮手动查询搜索条件变化时自动重新加载第一页分页变化时重新请求搜索条件在刷新页面后保留。这个需求几乎覆盖了大部分后台管理系统的核心交互。注意热词里提到的“Vue3 搜索条件保留”“Vue3 动态添加删除 form 表单一行数据”我都会在实现里带上。4.2 页面基础结构与代码实现模板部分template div classpage-container el-form :modelsearchForm inline el-form-item label关键词 el-input v-modelsearchForm.keyword placeholder请输入关键词 keyup.enterhandleSearch / /el-form-item el-form-item el-button typeprimary clickhandleSearch搜索/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form el-table v-loadingloading :datalist border el-table-column propid labelID width80 / el-table-column propname label名称 / el-table-column propstatus label状态 width100 / el-table-column propcreateTime label创建时间 / /el-table el-pagination v-model:current-pagepage v-model:page-sizepageSize :totaltotal layouttotal, prev, pager, next current-changeloadListWithPage / /div /template这里用了 Element Plus因为后台管理系统用它的比例很高。你换成别的 UI 库也完全没问题核心逻辑是一样的。Script 部分script setup import { ref, reactive, watch, onMounted } from vue import { fetchListApi } from /api/list const searchForm reactive({ keyword: }) const list ref([]) const total ref(0) const loading ref(false) const page ref(1) const pageSize ref(10) // 核心加载列表的 async 函数负责请求和状态管理 async function loadList() { loading.value true try { const params { keyword: searchForm.keyword.trim(), page: page.value, pageSize: pageSize.value } const res await fetchListApi(params) list.value res.data.list total.value res.data.total } catch (e) { // 统一错误提示 console.error(e) } finally { loading.value false } } // 搜索按钮点击事件处理函数必须 async async function handleSearch() { page.value 1 await loadList() } // 重置搜索条件并重新加载 function handleReset() { searchForm.keyword page.value 1 loadList() } // 分页变化直接从事件绑定到 async 函数 async function loadListWithPage() { await loadList() } // 监听搜索条件变化自动刷新列表 watch( () searchForm.keyword, async (newVal, oldVal) { if (newVal ! oldVal) { page.value 1 await loadList() } } ) // 页面初始化时请求 onMounted(() { loadList() }) /script注意几个关键点loadList是 async 函数其内部用await fetchListApi(params)这里必须用 asynchandleSearch是事件处理函数内部await loadList()同样必须 asyncloadListWithPage绑定到分页事件也必须是 asyncwatch 回调里用了 async因为里面有await loadList()onMounted里面没有 await只是调用loadList()所以回调可以不写 async。但如果你直接在 onMounted 里写const res await xxx那就必须写成onMounted(async () {...})。4.3 省略 async 会有什么表现我们来逐一验证“省略 async”的后果在handleSearch或loadListWithPage中去掉 async只要函数体重有 await编译阶段就直接报错页面都跑不起来。在 watch 回调中去掉 async同样直接报语法错误。在 onMounted 回调里写const res await fetchListApi()但没把回调声明为 async报错。有一种容易漏网的情况异步函数之间调用比如handleSearch里调用loadList()如果你不写await页面功能表面上能用因为loadList里面自己会把数据赋值给响应式变量。但如果你依赖handleSearch完成后立刻读取某些结果就会出现顺序错了的问题。这个案例引出一个经验在script setup里定义 async 函数时要判断清楚“函数体内有没有 await”有就一定要标记 async这是硬性规则而函数之间是否使用 await则由业务时序决定宁可多写也别让链路断掉。4.4 搜索条件保留的实现热词里提到“Vue3 搜索条件保留”。通常的保留方式有两种通过 URL query 参数保留刷新后路由中有值初始化时回填通过本地存储保留比如 sessionStorage 或 Pinia。我这里用 URL query 方式做个简短演示script setup import { useRoute, useRouter } from vue-router const route useRoute() const router useRouter() // 初始化时从路由里读取 searchForm.keyword route.query.keyword || page.value Number(route.query.page || 1) // 修改路由参数同时保留刷新后的条件 function syncRoute() { router.replace({ query: { keyword: searchForm.keyword, page: page.value } }) } async function handleSearch() { page.value 1 syncRoute() await loadList() } /script这里需要强调的是syncRoute是同步函数没有 await而handleSearch是 async 函数两者可以混用。很多同学误以为“只要函数里有调用其他函数就必须写 async”其实不然。async 的判断标准只有一个函数体内部有没有使用 await 语法。这能帮你减少不必要的 async 声明让代码更清晰。4.5 提炼一个 useTable 组合式函数上面的列表逻辑如果每个页面都复制粘贴代码会冗余。我通常会提炼一个通用的useTable组合式函数// composables/useTable.js import { ref, reactive, watch, onMounted } from vue export function useTable(options) { const { fetchData, searchForm, immediate true } options const list ref([]) const total ref(0) const loading ref(false) const page ref(1) const pageSize ref(10) async function loadList() { loading.value true try { const params { ...searchForm, page: page.value, pageSize: pageSize.value } const res await fetchData(params) list.value res.data.list total.value res.data.total } catch (e) { console.error(e) } finally { loading.value false } } async function handleSearch() { page.value 1 await loadList() } function handleReset() { Object.keys(searchForm).forEach((key) { searchForm[key] undefined }) page.value 1 loadList() } if (immediate) { onMounted(() { loadList() }) } return { list, total, loading, page, pageSize, loadList, handleSearch, handleReset } }在组件里使用script setup import { reactive } from vue import { useTable } from /composables/useTable import { fetchListApi } from /api/list const searchForm reactive({ keyword: }) const { list, total, loading, page, pageSize, loadList, handleSearch, handleReset } useTable({ searchForm, fetchData: fetchListApi }) /script可以看到loadList、handleSearch这些函数都被封装为 async 函数调用方如果不想等待结果可以不写 await但如果调用方希望在函数返回后继续操作就必须在其函数前加 async 并await调用。这正是文章标题说的async 通常不能省略因为你永远不知道下游会在哪里等你。5. 常见问题与排查技巧实录5.1 问题速查表问题表现根本原因解决方案编译报错await is only valid in async function函数内使用了 await但函数没有声明为 async给对应函数补上 async页面白屏控制台提示需要 Suspensescript setup顶层使用了 await但父组件没有包 Suspense要么用 Suspense 包裹要么把 await 移到 onMounted 或具体函数里点击按钮毫无反应控制台有 unhandled rejection事件处理函数是 async内部错误没有被捕获在 async 函数内使用 try/catch并调用统一错误提示数据结果被旧请求覆盖多次请求并发没有处理竞态使用请求序号或 AbortController 取消过期请求组件挂载后拿不到马上需要的数据在 onMounted 中异步请求但模板渲染时机早于请求返回使用 loading 状态和 v-if 控制渲染或在 Suspense 下使用顶层 await页面刷新后搜索条件丢失没有同步搜索条件到 URL 或本地存储通过 router query 或 sessionStorage 保留条件这个表格是我实际排查问题后整理的基本覆盖了script setup里异步相关的绝大部分报错。5.2 为什么我用了 async 但 on-success 依然监听不到热词里有“vue3 on-success 监听不到”这里顺带说一下。如果你封装的子组件内部是异步操作并且你希望操作完成后触发父组件的事件例如on-success那么 emit 的时机很关键。正确的内部实现方式script setup const emit defineEmits([success]) async function handleUpload() { try { const res await uploadApi(file) emit(success, res) } catch (e) { emit(error, e) } } /script父组件监听template ChildComponent successhandleSuccess / /template script setup function handleSuccess(res) { console.log(上传成功, res) } /script为什么有时候监听不到多半是子组件在异步回调之前就 emit 了或者 emit 的数据是 Promise父组件没有 await 就直接用了。另一个可能性是你在子组件的 async 函数里使用了try/catch却没有在 finally 或成功分支里 emit导致事件没发出去。5.3 如何避免 async 事件函数中的重复提交后台系统里表单提交按钮最容易出现重复点击。不写 async 或者写了 async 但没有防抖双击就会发两个请求。我每次都会在 submit 函数里加一个 pending 状态const submitting ref(false) async function handleSubmit() { if (submitting.value) return submitting.value true try { await submitApi(form.value) message.success(提交成功) } finally { submitting.value false } }这个跟 async 是可以共存的。这里要提醒省略 async 时你无法直接await submitApi所以要么用.then链要么老老实实补 async。从代码可维护性的角度讲async/await 完整写出来的版本明显更容易看懂。5.4 在 watch 中写 async 回调需要注意初始触发看一个容易出错的点watch默认不是立即执行的。如果你希望页面初始化时就执行一次加上immediate: truewatch( () searchForm.keyword, async () { page.value 1 await loadList() }, { immediate: true } )加了 immediate 之后watch 回调会在组件创建时执行一次。但如果同时又在 onMounted 里调用 loadList那么会重复请求两次。我的习惯是二选一要么用 onMounted 初始化要么用 watch 的 immediate 初始化不要两个都写。5.5 一个比较好用的统一错误捕获方案在事件处理函数里写 try/catch 很繁琐。我后来采用了一个封装函数让 async 事件函数保持简洁function runAsync(asyncFn, errorHandler) { return async (...args) { try { return await asyncFn(...args) } catch (e) { if (errorHandler) { errorHandler(e) } else { message.error(e?.message || 操作失败) } } } } const handleSearch runAsync(async () { page.value 1 await loadList() })本质上还是要有一个 async 函数存在只是把 catch 抽出去了。这样不会因为省略 async 而破坏 Promise 链也不会让每个函数都写一大堆错误处理样板。写在最后的实操心得从 Vue2 切到 Vue3 后我自己定了三条规则分享出来供参考。第一凡是函数体内出现await函数声明前一律写async不管是在事件处理、watch 回调、生命周期函数还是普通工具函数里。这个规则没有例外能省事的只有写.then链这一条路但可读性会下降不值得。第二顶层 await 只在必要场景用。我现在的判断标准很简单如果数据不加载完页面就没有意义而且项目已经统一用了 Suspense那我可以接受顶层 await否则就放到 onMounted 或具体交互函数中处理。后台管理系统里我几乎不用顶层 await因为首屏渲染效率太重要。第三封装异步请求时尽量把“数据”“loading”“error”三个状态一起返回。比如前面写的 useTable 组合式函数数据加载、loading 切换、错误处理都收敛在内部页面代码只关心业务。这样 async 不省略、错误不裸奔、重复代码也少团队协作时大家都轻松。最后再分享一个排查技巧如果你在script setup里发现某个异步操作不生效先用搜索功能全局找一下有没有函数内部用到了await却没写async再检查一下调用这个函数的地方有没有漏await。大多数所谓“奇怪的问题”最后都出在这两个最基础的地方。