Nuxt 3 异步上下文指南:useNuxtApp() 与 await 的使用规范

15 阅读4分钟

概述

本文档记录了在 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 边界影响。

模块级单例类的陷阱

本项目中 MwpManagerServerTokenManager 等工具类作为模块级单例存在:

// 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?

参考资料