SSR 串号、刷新残留、内存不回落:app.provide 与模块顶层 ref 的跨请求泄漏
在生产 SSR 里出现「A 用户看到 B 用户的数据」,第一反应往往是怀疑 CDN 缓存或代理配置。但另一类成因更常见:状态被放在了模块作用域,然后通过 app.provide / nuxtApp.provide 注入给了每一个请求。
症状分型
**偶发串号。**两个请求时间接近时,B 的 HTML 里出现 A 的用户名、购物车、租户 ID。刷新几次又正常。频率与并发量正相关,单机低峰期几乎复现不出来。
**刷新后残留。**上一个请求写入的值在新请求的 SSR 输出里还在,或者客户端 hydration 之后仍显示旧值。表现为「改完资料刷新看还是旧的,重启进程就好了」。
**内存随请求涨。**进程 RSS 单调上升,不随流量回落,容器不重启不释放。堆快照里能看到某个对象被模块级变量长期持有,retainer 链指向 plugins/ 或 utils/ 下的模块。
排查顺序
**先确认是 SSR 输出还是客户端渲染的问题。**直接
curl页面地址,看返回的 HTML 里有没有他人的数据。有,问题在服务端;没有,往后端查。**搜模块顶层的响应式容器。**重点是
plugins/、composables/、utils/、stores/这类会被多个请求共用的目录:
grep -rnE "^\s*(const|let|var)\s+\w+\s*=\s*(ref|reactive|shallowRef|shallowReactive|computed)\(" \
plugins composables utils stores 2>/dev/null
看 provide 的来源。
nuxtApp.vueApp.provide('x', ...)或nuxtApp.provide(...)的第二个参数,如果指向上面搜出来的模块级变量,基本可以定性。**看 useState 的 key。**缺 key 时 Nuxt 会基于文件与行号生成,跨文件共享同一个 key 才是同一份状态;key 写错或写重复,也会出现「以为共享了其实没有」或「不该共享却共享了」。
**内存问题用堆快照对比。**两个不同请求结束后各拍一次,看增量对象的 retainer 是否落在模块作用域。
机制:为什么一个请求的数据会跑到另一个请求里
**Node 是常驻进程。**ESM 模块只在首次 import 时求值一次,模块顶层 const state = ref() 求值出来的那个容器,从此属于整个进程,而不是某个请求。
// plugins/state.ts —— 反例
import { ref } from 'vue'
const state = ref({ user: null }) // 模块作用域,全进程仅此一份
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.provide('appState', state) // 每个请求都 provide 同一个对象引用
})
这段代码里,nuxtApp 是每请求新建的,provide 也确实执行了,但被 provide 出去的值始终是同一个 state。请求 B 拿到的是请求 A 写过的容器。
**nuxtApp 实例本身是按请求创建的。**所以把状态挂在 nuxtApp 上、或用 useState(内部即 nuxtApp.payload.state)是安全的;不安全的只有把状态挂在模块作用域。
Nuxt 文档 Best Practices 对此写得很直接:Never define const state = ref() outside of `` or setup() function. 例如 export myState = ref({}) 会导致在服务端状态跨请求共享,并可能引起内存泄漏。推荐写法是 const useX = () => useState('x')。
**为什么要限制 composable 的调用位置。**组件生命周期中,Vue 用一个全局变量跟踪当前组件实例的临时引用,Nuxt 同理跟踪 nuxtApp 的临时实例,并在同一个 tick 内清掉。文档说明这层机制在 SSR 下是必须的:既避免在两个用户之间泄漏共享引用,也避免不同组件之间泄漏。所以 composable 必须在正确的生命周期位置、以同步方式调用。
Nuxt Bridge 迁移文档里对 ssrRef → useState 的说明是同一件事:useState 只能在 Nuxt plugin(defineNuxtPlugin)或组件实例中调用,理由是 cannot use useState with a global/ambient context, because of the danger of shared state across requests。Nuxt 2 的 composition-api Gotchas 也记录过:如果在全局状态里声明 refs(比如插件里、或作为 Vuex 替代的 state/store 文件里),生产模式下这些 refs 会跨请求持久化;ssrRef 只有在 setup() 或 onGlobalSetup 中初始化,数据才只存在于请求状态。
正确写法
**useState 带显式 key。**唯一 key 用于保证数据获取在请求之间正确去重;同一 key 在所有组件间共享同一份状态,而这份状态挂在请求内的 payload 上。
// composables/useCounter.ts
export const useCounter = () =>
useState('counter', () => Math.round(Math.random() * 1000))
其他任何组件调用 useState('counter') 拿到的都是同一个响应式状态。init 是尚未初始化时提供初始值的函数,也可以返回 Ref。
在 defineNuxtPlugin 里 provide。
// plugins/app-state.ts —— 正例
export default defineNuxtPlugin(() => {
const appState = useState('app-state', () => ({ user: null }))
return {
provide: {
appState,
},
}
})
注意两点:useState 在插件函数体内同步调用;provide 出去的是当前请求自己的状态。供组件复用的版本保持同样的 key:
// composables/useAppState.ts
export const useAppState = () =>
useState('app-state', () => ({ user: null }))
**异步初始化放在 app.vue 的 callOnce。**作用类似 Nuxt 2 的 nuxtServerInit:
const websiteConfig = useState('website-config')
await callOnce(async () => {
websiteConfig.value = await $fetch('/api/website-config')
})
需要全局失效已缓存的状态时用 clearNuxtState。
useState 的边界
**只能放 JSON 可序列化的数据。**因为 useState 里的数据会被序列化成 JSON,不要放 class 实例、函数、Symbol。
报错 Cannot stringify arbitrary non-POJOs 就是往 useState 里塞了类实例等不可序列化 payload。确实要存,用 definePayloadPlugin 自定义序列化与反序列化。
**不能全局管理环境调用。**原因同上文的跨请求共享风险,Bridge 文档已明确。
**await 之后再调用会报 Nuxt instance is unavailable。**这个报错基本可以判定为在错误的生命周期位置调用了 Nuxt composable。例外有几处:在 `` 块内、用 defineNuxtComponent 声明的组件 setup 内、defineNuxtPlugin 内、defineNuxtRouteMiddleware 内,构建期会做转换以在 await 之后保持同步上下文。
// defineNuxtPlugin 内属于例外,await 后仍可调用
export default defineNuxtPlugin(async () => {
const me = await $fetch('/api/me')
const state = useState('me', () => me)
return { provide: { me: state } }
})
// 普通异步函数里不在例外列表内,会抛 Nuxt instance is unavailable
async function load() {
await $fetch('/api/me')
useState('me')
}
在非 SFC 组件里需要用到依赖 Nuxt 上下文的 composable 时,用 defineNuxtComponent 包裹,而不是 defineComponent。
另外两条与使用相关的细节:useState 是编译器转换的保留函数名,不要给自己的函数起名 useState。不需要深响应式时配合 shallowRef:
const state = useState('my-shallow-state', () => shallowRef({ deep: 'not reactive' }))
isShallow(state) === true
真要跨请求缓存,不要用共享 ref
跨请求缓存的需求(配置、第三方 API 结果、聚合数据)和请求内状态是两回事。把模块顶层 ref 当缓存用,会同时踩上串号和内存增长两个问题。正确的落点是 Nitro 的缓存能力。
// server/api/products.ts
export default defineCachedEventHandler(async () => {
return await $fetch('https://api.example.com/products')
}, { maxAge: 60 })
按参数隔离的缓存用 cachedFunction:
const getRepo = cachedFunction(async (name: string) => {
return await $fetch(`https://api.github.com/repos/${name}`)
}, {
name: 'repo',
getKey: (name: string) => name,
})
需要手写读写时用 Nitro storage:
const storage = useStorage('cache')
await storage.setItem(`user:${id}`, profile)
判断标准:进缓存的东西必须可序列化,并且 key 里要带上所有影响结果的维度。用户态不进无 key 的缓存。
一个版本回归案例
Nuxt issue #27620(nuxt/nuxt#27620)记录过一次相关回归:ssr: false 时,在 plugin 中使用 useState 之后整个应用不响应;ssr: true 下没有该问题;3.11.2 正常,3.12.1 出现回归。
danielroe 的回复是夜间版本已由 PR #27542 修复,会在下个版本发布。补丁前的 workaround 是新建 ~/plugins/payload-reactivity.client.ts,把 nuxtApp.payload 的 _errors 与 data 用 shallowReactive 包裹、payload.state 用 reactive 包裹。
同一 issue 下还有一条排查记录值得注意:安装某个 npm 包时,版本号前的 ^ 把 Nuxt 升到了 3.12.1,问题才出现,回退版本后恢复,定位花了很久。锁死框架版本、以及升级后先验证 plugin 里的 useState 行为,比事后逐层排查便宜得多。
收束
判断原则只有一条:跨请求要共享的东西,必须走显式缓存并声明 key(Nitro storage / defineCachedEventHandler / cachedFunction);请求内的东西,必须在 setup、defineNuxtPlugin 等处同步创建,通过 useState 或请求级的 nuxtApp 承载。模块顶层的 ref 两者都不属于。