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 还没安装,应用初始化直接挂掉。

这次用 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,
},
};这段代码意味着两件事:
- Pinia 需要先通过
vueApp.use(pinia)安装到当前 Nuxt app。 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 晚」。
这次真正的需求其实是两条:
- 系统通知 bridge 需要 晚于 Pinia,因为它要读当前账号 store。
- 系统通知 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();
});
},
});
时序约束写在 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']。

这条检查已经接到几个入口:
{
"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 示例:
检查范围和历史基线
这条脚本只检查 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 时序:
- 看报错栈最早的业务入口是不是
app/plugins/**。 - 如果是 plugin,先查它有没有
enforce: 'pre'。 - 如果它读了
useXxxStore(),优先改成dependsOn: ['pinia']。 - 如果它还要早于另一个业务 plugin,让另一个业务 plugin 依赖它,或者用文件名默认顺序,不要恢复
pre。 - 如果它真的必须
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 前,需要明确它依赖谁、谁又依赖它,再选择分组排序或具名依赖声明。