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/nuxtpinia 插件。插件 setup 里一调用 useAppStore(),Pinia 还没安装,应用初始化直接挂掉。

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

这篇记录的是一次启动顺序问题的复盘:为什么 pre 会把插件排到 Pinia 前面,为什么 dependsOn: ['pinia'] 是更准确的修复,以及怎么补一个本地 lint,把同类问题提前挡在 dev / build 之前。

现象不是“Pinia 不能在 Nuxt plugin 里用”

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

@pinia/nuxt 的运行时代码做的事情很直白:

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,避免连接建立后第一条系统通知进来时还没人消费。这个业务诉求是对的,错的是表达方式。

原始思路大概是:

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 也能看到这个方向:pre 插件会出现在默认插件前,pinia 是后面的一个具名插件。修复后,ws-system-notify 已经被排在 pinia 之后;当前只剩历史 idle-detector 作为显式基线保留。

这也是为什么浏览器里还会看到 auth.global.tsidle-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 插件顺序

这个写法的可读性也好很多。后续读代码的人一眼能看到:这个插件的核心约束是“必须等 Pinia”。时序约束写在 plugin object 上,不需要藏在注释、try/catch 或某个手动 setActivePinia() 里。

为什么不靠到处 setActivePinia

一个看似快速的修复是:既然没有 active pinia,那我在 plugin 里手动拿 nuxtApp.$pinia,再 setActivePinia(pinia) 不就行了吗?

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

原因有三个。

首先,它没有表达依赖。读代码的人看见 setActivePinia(),不一定知道这是为了修插件顺序,还是为了多实例隔离,还是为了测试环境兜底。dependsOn: ['pinia'] 直接把依赖写到了 Nuxt 的调度系统里,语义更完整。

其次,它容易掩盖真正的时序错误。如果一个 plugin 需要 Pinia,那就应该让 Nuxt 等 Pinia;如果它还需要早于另一个业务插件,也应该声明业务插件之间的关系。手动恢复 active pinia 只能让这一次不报错,却不能保证下一个依赖也已经准备好。

最后,它会把 Nuxt / Pinia 的启动细节扩散到每个业务 plugin。扩散越多,后续谁也不知道哪个兜底是真的必要,哪个只是历史事故留下的创可贴。

所以这次保留了一个现实边界:历史 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 都会先过一遍。它不会等浏览器启动到一半,也不会等测试环境部署后才看到红色控制台。

这个 lint 不是完整的 Nuxt plugin 分析器

这条脚本是刻意做窄的。它不是 AST 级 Nuxt plugin 调度模拟器,也不试图分析所有“插件间依赖是否合理”。

它的优点是:

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

它的局限也要写清楚:

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

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

工程护栏的目标很具体:把已经发生、已经有明确修法、复发成本很高的问题提前拦住。这个脚本就是这种小护栏。

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

以后看到 Nuxt app initialization 阶段的 Pinia 报错,可以按这个顺序查:

  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() 才是应该追的根因。

总结

这次问题给我的提醒是:

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

这类 bug 麻烦在两点:修代码只是一行,但现象太像“偶发环境差异”。把启动顺序、框架依赖和 lint 护栏讲清楚后,下次再有人想给 Nuxt plugin 加 pre,至少会先停一下:它到底是要更早,还是需要一个明确的依赖顺序?