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 还没安装,应用初始化直接挂掉。
这篇记录的是一次启动顺序问题的复盘:为什么 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,
},
};这段代码意味着两件事:
- Pinia 需要先通过
vueApp.use(pinia)安装到当前 Nuxt app。 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.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();
});
},
});这个写法的可读性也好很多。后续读代码的人一眼能看到:这个插件的核心约束是“必须等 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']。
这条检查已经接到几个入口:
{
"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 调度模拟器,也不试图分析所有“插件间依赖是否合理”。
它的优点是:
- 实现简单,不新增依赖。
- 错误信息非常贴近这次事故。
- 可以放进
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 报错,可以按这个顺序查:
- 看报错栈最早的业务入口是不是
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() 才是应该追的根因。
总结
这次问题给我的提醒是:
enforce: 'pre'是分组排序,不是依赖声明。只要插件需要 Pinia,就不要用pre抢在 Pinia 前面。- Nuxt plugin 之间的顺序要写成契约。需要 Pinia 就
dependsOn: ['pinia'];业务插件互相有顺序,就给业务插件命名并声明依赖。 - 不要把手动
setActivePinia()当默认修法。它可以兜历史问题,但会隐藏真正的时序契约。 - 已经踩过的启动链路问题要进工具。本地脚本、ESLint 或架构测试都可以,关键是让问题在 dev / build 前出现。
- 基线不是赦免牌。历史例外要写原因和删除条件,防止新代码把旧问题复制成新问题。
这类 bug 麻烦在两点:修代码只是一行,但现象太像“偶发环境差异”。把启动顺序、框架依赖和 lint 护栏讲清楚后,下次再有人想给 Nuxt plugin 加 pre,至少会先停一下:它到底是要更早,还是需要一个明确的依赖顺序?
