Nuxt plugin 抢跑 Pinia 为什么会把应用启动打挂

这次报错看起来很像 Pinia 自己出了问题:

[nuxt] error caught during app initialization Error:
"getActivePinia()" was called but there was no active Pinia.
Are you trying to use a store before calling "app.use(pinia)"?

at setup (ws-system-notify.client.ts:14:17)

测试环境里更难读,生产包压缩后只剩:

TypeError: Cannot read properties of undefined (reading '_s')

第一眼会冒出几个猜测:某个 store 是否在组件外调用了?Nuxt 插件里能不能用 store?开发环境和测试环境的加载顺序是否不一致?

原因在插件顺序:一个客户端 Nuxt plugin 为了「抢在 WebSocket 建连前注册监听」,用了 enforce: 'pre',把自己排到了业务 WebSocket 插件和 @pinia/nuxt 的 pinia 插件之前。插件 setup 里一调用 useAppStore(),Pinia 还没安装,应用初始化直接挂掉。

Nuxt pre 插件抢跑 Pinia 的失败链路

这次用 dependsOn: ['pinia'] 表达业务插件的依赖,再补本地 lint,在 dev / build 前检查同类危险组合。

调用 store 前,Pinia 必须就绪

Pinia 在 Nuxt plugin 里当然可以用,前提是 Pinia 已经安装。

@pinia/nuxt 在运行时安装并激活 Pinia:

const pinia = createPinia();
nuxtApp.vueApp.use(pinia);
setActivePinia(pinia);

return {
  provide: {
    pinia,
  },
};

这段代码意味着两件事:

  1. Pinia 需要先通过 vueApp.use(pinia) 安装到当前 Nuxt app。
  2. setActivePinia(pinia) 之后,useXxxStore() 这类调用才有 active pinia 可读。

Pinia 官方文档在组件外使用 store时也强调过同一个边界:如果不是在已经完成注入的上下文里调用 store,就要确保 pinia 实例已经传入或已经激活。Nuxt 场景里,@pinia/nuxt 帮我们把这一步做掉;但如果业务 plugin 自己跑到了它前面,这个保障就不存在了。

这次出问题的是系统通知 bridge。它需要在 WebSocket 建连前注册 0x524 listener,避免第一条系统通知进来时还没人消费;插件同时又依赖 Pinia,必须表达这两个时序约束。

原始思路大概是:

export default defineNuxtPlugin({
  name: 'ws-system-notify',
  enforce: 'pre',
  setup() {
    const app = useAppStore();
    const bridge = useWsSystemNotifyBridge();
    // ...
  },
});

enforce: 'pre' 确实会「提前执行」,但它提前的是整个插件队列位置,不是「在 Pinia 后面、在 WebSocket 前面」。于是它把自己提前到了 pinia 插件之前。

本地 .nuxt/types/plugins.d.ts 能帮助确认 pinia 和业务插件已被识别,但类型声明排列不能单独证明运行时顺序。修复依赖关系后,还要确认 bridge 在 Pinia 安装后、WebSocket 建连前注册监听;历史 idle-detector 则单独作为基线保留。

这也是为什么浏览器里还会看到 auth.global.ts 或 idle-detector.client.ts 一起出现在错误栈附近:应用初始化阶段已经被一个过早执行的插件打乱,后续 route middleware 和另一个 client plugin 再读 store 时,也会踩到「active pinia 不存在」的同一类症状。根因还是启动顺序,不是每个调用点都需要各自打一层补丁。

enforce: 'pre' 不是依赖系统

Nuxt 4 的 plugin 文档和 defineNuxtPlugin API把插件对象里的几个字段分得很清楚:

  • name:插件名称,给依赖和调试使用。
  • enforce:大致分成 pre / default / post 三段执行。
  • dependsOn:声明当前插件依赖哪些具名插件先执行。
  • order:更强的高级排序控制,通常不应该随手用。

这几个字段里,只有 dependsOn 真正表达「我依赖某个插件已经执行」。enforce: 'pre' 表达的是「我要进入更早的一组」,它不能表达「我需要比 B 早,但必须比 A 晚」。

这次真正的需求其实是两条:

  1. 系统通知 bridge 需要 晚于 Pinia,因为它要读当前账号 store。
  2. 系统通知 bridge 需要 早于 WebSocket 建连,因为它要先注册 listener。

第一条用 dependsOn: ['pinia'] 表达;第二条不应该靠 pre。如果两个业务 plugin 都是默认阶段,文件名顺序已经可以让 ws-system-notify.client.ts 排在 ws.client.ts 前面。后续如果这个顺序变得更复杂,也应该让 ws.client 显式 dependsOn: ['ws-system-notify'],而不是把其中一个插件推到全局 pre。

最终代码更像这样:

export default defineNuxtPlugin({
  name: 'ws-system-notify',
  dependsOn: ['pinia'],
  setup() {
    const app = useAppStore();
    const bridge = useWsSystemNotifyBridge();
    const currentAccountUid = computed(() => resolveCurrentAccountUid(app.user));

    bridge.init();

    watch(currentAccountUid, () => {
      bridge.reset();
    });
  },
});

使用 dependsOn 修复 Nuxt 插件顺序

时序约束写在 plugin object 上,读代码时就能看到它必须等待 Pinia,不必从注释、try/catch 或手动 setActivePinia() 中猜测依赖关系。

为什么不靠到处 setActivePinia

也可以考虑在 plugin 里手动读取 nuxtApp.$pinia,再调用 setActivePinia(pinia)。

这个办法只适合非常明确的历史兜底,不适合作为新代码默认解法。

setActivePinia() 没有表达调度依赖。读者很难判断它是在修插件顺序、处理多实例隔离,还是为测试环境兜底;dependsOn: ['pinia'] 则直接把依赖交给 Nuxt 调度。

手动恢复 active pinia 还可能掩盖时序错误。当前调用不再报错,不代表下一个依赖已经就绪。需要 Pinia 就等待 Pinia,需要早于另一个业务插件就声明两者的关系。

如果每个业务 plugin 都补一份手动恢复逻辑,Nuxt / Pinia 的启动细节也会随之扩散,后续更难辨认哪些兜底仍然必要。

这次保留历史 idle-detector.client.ts 为显式基线,它已有独立的 app hook 和 try/catch,需要另行核对执行时机。新代码不能复制这个兜底形态;基线要写清原因和删除条件,不能悄悄成为新模板。

用 lint 把这个坑提前拦住

这类问题只靠 review 不稳。enforce: 'pre' 写起来太像正确解法,尤其在「我想让插件先跑」的上下文里,很容易被复制。

于是项目里加了一个窄脚本:

scripts/check-nuxt-plugin-pinia-order.mjs

它只做一件事:扫描 app/plugins,发现同一个插件文件里同时出现下面两个条件,就失败:

  • enforce: 'pre'
  • useXxxStore() / usePinia() / getActivePinia() / setActivePinia()

脚本会先去掉注释,避免注释里的示例误报;然后检查危险组合。历史 idle-detector.client.ts 被放进 KNOWN_BASELINE,运行时会提示「已知基线」,但不会让检查失败。任何新增同类插件都会红灯,并提示改成 name + dependsOn: ['pinia']。

Nuxt plugin Pinia 顺序检查脚本

这条检查已经接到几个入口:

{
  "predev": "pnpm lint:nuxt-plugin-pinia && pnpm build:emoji-sprite",
  "prebuild": "pnpm lint:tailwind-apply && pnpm lint:nuxt-plugin-pinia && pnpm build:emoji-sprite",
  "prebuild:test": "pnpm lint:tailwind-apply && pnpm lint:nuxt-plugin-pinia && pnpm build:emoji-sprite",
  "prebuild:prod": "pnpm lint:tailwind-apply && pnpm lint:nuxt-plugin-pinia && pnpm build:emoji-sprite",
  "lint": "eslint . && pnpm lint:nuxt-plugin-pinia",
  "lint:nuxt-plugin-pinia": "node scripts/check-nuxt-plugin-pinia-order.mjs"
}

本地 pnpm dev、测试 / 生产构建和常规 lint 都会先运行检查,危险组合可以在浏览器启动或测试环境部署前暴露。

如果要让 Agent 接入这个脚本,可以直接给它这段 prompt:

工具包根路径:https://shengsheng.fun/files/nuxt-plugin-pinia-order-guardrail/kits/nuxt-plugin-pinia-order-guardrail/
先读 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md;迁移 copy/scripts/check-nuxt-plugin-pinia-order.mjs 和 copy/tests/,按项目插件目录配置 --plugin-dir,并只用 --baseline 记录明确历史例外。

完整源码包在下面,包含脚本、测试和 package scripts 示例:

Nuxt plugin Pinia 顺序检查源码正在加载代码工作区...

检查范围和历史基线

这条脚本只检查 pre 与 Pinia 调用的危险组合,不做 AST 级调度模拟,也不分析完整插件依赖图。

它的优点是:

  • 实现简单,不新增依赖。
  • 错误信息非常贴近这次事故。
  • 可以放进 predev 和 prebuild,运行成本很低。
  • 允许历史基线显式存在,不强迫本轮顺手重构不该碰的旧逻辑。

它的局限也要写清楚:

  • 如果插件文件自己没有写 useXxxStore(),而是 import 一个函数,函数内部再读 store,脚本不一定能发现。
  • 它只检查 app/plugins,不负责 route middleware、组件、普通 composable 的执行时机。
  • 它只能判断「这个组合危险」,不能替 Nuxt 决定插件间完整依赖图。
  • KNOWN_BASELINE 是权宜之策,基线越多,规则越弱;所以每个例外都必须写明原因和删除条件。

如果后续这个问题继续扩大,更成熟的做法可以是 ESLint 自定义规则:用 parser 识别 defineNuxtPlugin({ ... }) 对象,分析 enforce、dependsOn 和 setup 里的调用表达式。那样误报更少,也能给 IDE 更早的提示。但当前项目只需要挡住一个已经真实发生的事故,Node 脚本足够。

下次遇到同类问题怎么判断

Nuxt app initialization 阶段出现 Pinia 报错时,先看 plugin 时序:

  1. 看报错栈最早的业务入口是不是 app/plugins/**。
  2. 如果是 plugin,先查它有没有 enforce: 'pre'。
  3. 如果它读了 useXxxStore(),优先改成 dependsOn: ['pinia']。
  4. 如果它还要早于另一个业务 plugin,让另一个业务 plugin 依赖它,或者用文件名默认顺序,不要恢复 pre。
  5. 如果它真的必须 pre,必须解释为什么不能等 Pinia,并确认它不读任何 Pinia store。

还有一个很实用的取证点:看 .nuxt/types/plugins.d.ts 或 Nuxt 生成的插件类型,里面会列出当前插件名。它不是完整执行日志,但能帮助确认某个插件当前是否已经被 Nuxt 识别为具名插件,以及大致处在插件集合里的哪一段。

最后不要被生产包里的 _s 这种报错吓到。Pinia store 内部会维护 store registry,压缩后属性名和函数名都可能变短。开发环境里更可读的 getActivePinia() 才是应该追的根因。

看到 pre 先看依赖

这次问题给我的提醒是:

  • enforce: 'pre' 是分组排序,不是依赖声明。只要插件需要 Pinia,就不要用 pre 抢在 Pinia 前面。
  • Nuxt plugin 之间的顺序要写成契约。需要 Pinia 就 dependsOn: ['pinia'];业务插件互相有顺序,就给业务插件命名并声明依赖。
  • 不要把手动 setActivePinia() 当默认修法。它可以兜历史问题,但会隐藏真正的时序契约。
  • 已经踩过的启动链路问题要进工具。本地脚本、ESLint 或架构测试都可以,让问题在 dev / build 前出现。
  • 历史基线要有删除条件。每个例外都要写明原因,防止新代码继续复制旧问题。

这类 bug 的修复可能只涉及一行配置,现象却很像偶发环境差异。给 Nuxt plugin 加 pre 前,需要明确它依赖谁、谁又依赖它,再选择分组排序或具名依赖声明。