概述
本文档记录了在 Nuxt 3 SSR 项目中,useNuxtApp() 和 Pinia Store 在 await 异步边界后失去上下文的问题、根因分析、修复方案及最佳实践。适用于所有在 <script setup>、composable、工具类中编写异步逻辑的开发者。
背景
问题现象
访问页面时,控制台出现如下错误:
[nuxt] A composable that requires access to the Nuxt instance was called
outside of a plugin, Nuxt hook, Nuxt middleware, or Vue setup function.
问题场景
本项目中,report/normal/:id 路由页面(pages/estimate/result/index.vue)在 SSR 渲染时触发该错误。涉及调用链如下:
<script setup>
└─ await reqCheck()
└─ reqEstimateInfo()
├─ useNuxtApp().$mwpHeaders ← 上下文已丢失
└─ reqMwpEstimateShareReportInfo()
└─ mwpManager.executeRequest()
├─ serverTokenManager.getTokens()
│ └─ useMwpServerStore() ← Pinia 内部调用 useNuxtApp()
└─ await $fetch(...)
└─ handleResponse()
├─ clearKjsTokens()
│ └─ useMwpClientStore() / useMwpServerStore() ← 失败
└─ useAppUserUtil()
└─ useUserStore() ← 失败
根因分析
Nuxt 实例上下文的绑定机制
Nuxt 3 的 composable(useNuxtApp()、useRouter()、Pinia store 等)通过 Vue 的 getCurrentInstance() 绑定到当前组件实例。
关键约束:在默认配置下,该绑定是同步的,每次 await 都是一个异步边界,跨越后上下文失效。
// SSR 环境下的上下文生命周期(默认配置)
<script setup>
// ✅ 同步区域:上下文有效
const nuxtApp = useNuxtApp()
const store = useUserStore()
await firstRequest() // 跨越异步边界
// ❌ 上下文已丢失,下方所有 composable 调用均会报错
useNuxtApp() // Error
useUserStore() // Error
</script>
为什么客户端不报错
| 环境 | 行为 |
|---|---|
| 客户端(CSR) | 应用已完成初始化,Nuxt 实例作为全局单例存在,await 后仍可访问 |
| 服务端(SSR) | 每个请求拥有独立的 Nuxt 实例,上下文严格绑定,await 后上下文失效 |
这也解释了为什么该类 bug 在本地开发时往往不可见,仅在 SSR 环境下复现。
Pinia Store 也受影响
Pinia 的 useXxxStore() 在 Nuxt 中内部调用 useNuxtApp() 获取 Pinia 实例:
// Pinia 在 Nuxt 中的内部实现(简化)
function useStore(id) {
const nuxtApp = useNuxtApp() // 依赖 Nuxt 上下文
return nuxtApp.$pinia._s.get(id)
}
因此 Pinia store 与 useNuxtApp() 同样受 await 边界影响。
模块级单例类的陷阱
本项目中 MwpManager、ServerTokenManager 等工具类作为模块级单例存在:
// utils/core/MwpManager.ts
export const mwpManager = new MwpManager() // 模块加载时创建,脱离 Vue 上下文
类的实例方法在 await $fetch() 之后调用 Pinia store,导致上下文丢失:
// ❌ 问题代码(MwpManager.handleResponse)
private handleResponse(response: any, environment: string) {
// 此方法在 await $fetch() 之后调用
if (data.ret === 'FAIL_BIZ_SESSION_INVALID') {
const tokenManager = this.getTokenManager(normalizedEnv)
tokenManager.clearKjsTokens() // 内部调用 useMwpClientStore()
const { userLogout } = useAppUserUtil() // 内部调用 useUserStore()
}
}
解决方案
方案一:开启 experimental.asyncContext(已采用)
在 nuxt.config.ts 中启用:
// nuxt.config.ts
experimental: {
asyncContext: true, // 使用 AsyncLocalStorage 跨 await 边界保持上下文
},
原理:Nuxt 通过 Node.js 的 AsyncLocalStorage API,将 Nuxt 实例上下文注入到整个异步调用链中,使得所有 await 之后的代码均可正常访问上下文。
适用条件:
- Node.js >= 16(
AsyncLocalStorage正式稳定版本) - Nuxt >= 3.9
优点:无需修改业务代码,从根本上解决所有异步场景下的上下文丢失问题。
注意:该特性为实验性配置,升级 Nuxt 主版本时应关注其变更。
方案二:提前在同步阶段捕获引用
在 <script setup> 顶层(第一个 await 之前)提取所有 composable 引用:
// ✅ 正确:顶层同步捕获
const nuxtApp = useNuxtApp()
const router = useRouter()
const userStore = useUserStore()
const mwpHeaders = nuxtApp.$mwpHeaders // 捕获一次,后续复用
// ✅ 异步函数内使用已捕获的引用
const reqEstimateInfo = async () => {
// 使用外部变量,不再重复调用 composable
const { data } = await reqMwpEstimateShareReportInfo(
{ estimateId: trans.id },
mwpHeaders // ✅ 使用捕获的引用
)
}
本项目中,reqEstimateInfo()(第 215 行)、reqEstimateHighInfo()(第 279 行)、apiAccountGoodsSellCheckReq()(第 172 行)均有此类冗余调用,可统一改为使用第 100 行已捕获的 mwpHeaders。
方案三:runWithContext 手动恢复上下文
当必须在异步回调中调用 composable 时,可使用 nuxtApp.runWithContext():
const nuxtApp = useNuxtApp() // 提前捕获
async function handleSessionInvalid() {
await someRequest()
// 手动在 Nuxt 上下文中执行
await nuxtApp.runWithContext(() => {
const { userLogout } = useAppUserUtil()
userLogout(false)
})
}
适用于无法改变调用时机、但需要在异步后访问上下文的场景(如第三方库回调)。
方案四:依赖注入替代懒加载
对于单例工具类,改为通过构造函数或方法参数注入 store,避免在类内部调用 composable:
// ❌ 旧模式:类内部懒加载
class ServerTokenManager {
getTokens() {
const store = useMwpServerStore() // 依赖 Nuxt 上下文
return { mwToken: store.mwToken }
}
}
// ✅ 新模式:外部注入
class ServerTokenManager {
private store: ReturnType<typeof useMwpServerStore>
constructor(store: ReturnType<typeof useMwpServerStore>) {
this.store = store // 在有上下文的地方注入
}
getTokens() {
return { mwToken: this.store.mwToken } // 使用注入的引用
}
}
// 在 composable 或 plugin 中初始化
const store = useMwpServerStore()
const tokenManager = new ServerTokenManager(store)
各方案对比
| 方案 | 改动范围 | 可靠性 | 推荐场景 |
|---|---|---|---|
asyncContext: true | 仅配置文件 | 高(底层保障) | 项目存在大量异步 composable 调用 |
| 顶层同步捕获 | 每个 <script setup> | 高 | 新增功能时的编码规范 |
runWithContext | 局部异步回调 | 中(手动维护) | 第三方库/特殊异步场景 |
| 依赖注入 | 工具类架构 | 高 | 服务类/单例类重构 |
本项目已采用方案一,后续编码遵循方案二作为日常规范。
最佳实践
<script setup> 编写规范
<script setup lang="ts">
// ─── 1. 同步初始化阶段(上下文有效)─────────────────────────────
const nuxtApp = useNuxtApp()
const router = useRouter()
const store = useUserStore()
const mwpHeaders = nuxtApp.$mwpHeaders // 捕获一次
// ─── 2. 异步函数定义(使用已捕获的引用)──────────────────────────
const fetchData = async () => {
// ✅ 使用外部捕获的 mwpHeaders
const { data } = await reqSomeApi(params, mwpHeaders)
// ✅ 使用外部捕获的 store
store.setData(data)
}
// ─── 3. 顶层 await(执行异步操作)────────────────────────────────
await fetchData()
</script>
工具类编写规范
// ✅ 工具类不调用 composable,依赖通过参数传入
class ApiService {
async request(url: string, params: object, mwpHeaders: HeadersToPassthrough) {
const data = await $fetch(url, { body: params })
// ✅ 错误处理通过返回值传递,不在类内部导航/提示
if (data.status !== 0) {
return { success: false, message: data.message }
}
return { success: true, data: data.data }
}
}
// 在 composable/页面中处理错误
const result = await apiService.request(url, params, mwpHeaders)
if (!result.success) {
// ✅ 在有上下文的地方调用 composable
showToast(result.message)
}
检查清单
编写包含异步操作的 Vue 组件或 composable 时,对照以下列表自查:
- 所有
useNuxtApp()调用是否在第一个await之前? - 所有 Pinia store 的初始化是否在第一个
await之前? - 异步函数内部是否有重复调用
useNuxtApp()的情况? - 工具类/单例类的方法内是否有
useXxxStore()调用? - 是否在错误处理回调(
.catch())中调用了 composable?