别等 review 才想起 SSR:给 Nuxt 模块级 composable 补上静态护栏
一个移动端 Profile 页需要在页面滚动后让顶部 Header 回显头像和昵称。页面内容和 Header 不在同一个组件树分支里,不能再靠 BEM class 跨组件 query,也不适合让 Header 反向读内容组件内部 DOM。最后我们做了一个很薄的共享 composable:内容区注册滚动容器和 Tab 锚点,Header 只读 tabBarIsSticky。
共享逻辑能满足交互需求,review 时却发现缺了 SSR 分支:这份模块级 DOM 状态只在客户端有意义,SSR 阶段必须显式返回 no-op 或 request-local state。

我们需要分别检查状态归属和执行时机。Pinia、Nuxt useState、VueUse 共享 composable 各有适用范围;项目级 ESLint 负责提示危险结构,单测再验证 SSR no-op、客户端共享和 reset 的具体行为。
为什么需要模块级变量
移动端个人页的 Header 在布局树里,负责顶部导航和滚动后的紧凑展示;Profile 内容区在页面内容树里,负责渲染用户资料、TabBar 和不同 Tab 的主体内容。滚动容器和 TabBar 锚点都属于内容区,Header 不应该跨组件查 DOM,也不应该知道内容区内部 class、层级和锚点怎么摆。
最朴素的方案有三个:
- 让 Header 自己
querySelector内容区 DOM。这样 Header 会反向依赖页面内部结构,TabBar 改个 class 或位置,Header 就坏。 - 把滚动状态提升到更高层。这样页面 layout 要同时理解 Header 展示、内容区滚动、Tab 锚点和 cleanup,局部 UI 逻辑会被抬成页面架构问题。
- 用 Pinia 或 Nuxt
useState存。这样状态看起来更「正规」,但 DOM 节点、ResizeObserver、RAF id、cleanup 句柄都不是可序列化应用数据,也不应该进入 SSR payload。
这个跨组件桥接需要一份浏览器会话级 source:
- 内容区负责注册真实 DOM:滚动根和 Tab 锚点。
- Header 只消费一个布尔状态:Tab 是否已经进入吸顶区。
- 多处调用同一个 composable 时,要读写同一份状态。
- 路由离开、组件卸载和单测结束时,能把旧 DOM、observer 和 RAF 清掉。
这就自然走向了模块级变量。浏览器里,模块级变量能表达「当前 tab 内共享」;在 Nuxt SSR 里,同一份模块却可能被 Nitro 进程复用。于是问题变成:客户端确实需要模块级 source,但服务端绝不能创建或复用这份 source。
问题发生在模块级 source
最容易出问题的写法大概长这样:
// <PROJECT>/app/composables/useProfileMobileTabStickyState/useProfileMobileTabStickyStore.ts
const tabBarIsSticky = ref(false)
const scrollRootElement = shallowRef<HTMLElement | null>(null)
const tabAnchorElement = shallowRef<HTMLElement | null>(null)
let frame = 0
let stickyThreshold = 0
let resizeObserver: ResizeObserver | null = null
export default function useProfileMobileTabStickyStore() {
return {
tabBarIsSticky,
setScrollRootElement,
setTabAnchorElement,
resetStickyState,
}
}这段代码的意图很明确:同一个浏览器页面里,多处调用应该共享同一份状态。Profile 内容区写入 DOM source,Header 读取吸顶状态,路由卸载或测试时再 reset。
问题也在这里:它是模块级单例。浏览器里模块级单例通常等同于「当前 tab 内共享」;SSR 里模块级变量却可能活在 Node 进程里。只要服务端执行到这份模块,状态就不再天然属于某一次请求。
这个例子里的风险集中在 DOM、ResizeObserver、requestAnimationFrame、滚动容器和 cleanup 上。它们的 owner 明显是浏览器,不属于服务端请求。
第一步是在入口分流 SSR 路径:
// <PROJECT>/app/composables/useProfileMobileTabStickyState/index.ts
function createServerStickyState(): ProfileMobileTabStickyState {
const tabBarIsSticky = ref(false)
const noopCleanup = () => undefined
return {
tabBarIsSticky,
registerScrollRoot: () => noopCleanup,
registerTabAnchor: () => noopCleanup,
resetTabStickyState: () => undefined,
}
}
export default function useProfileMobileTabStickyState(): ProfileMobileTabStickyState {
if (import.meta.server) return createServerStickyState()
const store = useProfileMobileTabStickyStore()
const registration = useProfileMobileTabStickyRegistration(store)
return {
tabBarIsSticky: store.tabBarIsSticky,
registerScrollRoot: registration.registerScrollRoot,
registerTabAnchor: registration.registerTabAnchor,
resetTabStickyState: store.resetStickyState,
}
}这段修复表达了两个事实:
- SSR 没有真实滚动容器,返回 no-op 是正确业务语义。
- 客户端继续复用模块级 source,让 Header 和内容区共享同一份吸顶状态。
如果 index.ts 静态 import 了 store 文件,store 顶层的 ref、shallowRef 和 let 仍会在 SSR 模块求值阶段创建。它们没有被返回给业务组件,风险比直接共享给 SSR 低很多,但创建时机仍未表达 client-only 边界。
因此又把浏览器 source 放进 factory,用 useClientOnlySourceState() 控制创建时机。服务端只看到 public facade 的 no-op 返回值;客户端第一次调用时才创建真实 source,之后多处调用复用同一份实例。
composable 里出现模块级 reactive state 或可变变量,就必须说明它在 SSR 阶段属于谁;如果它属于浏览器会话,就不要在 SSR 模块求值阶段创建它。
Pinia 能解决什么,不能解决什么
Pinia 的 SSR 文档和组件外使用 store 的说明里都强调过一个核心点:SSR 应用里,store 要挂在对应的 Pinia 实例上;在组件外使用 store 时,也要确保使用的是当前 app/request 对应的实例,避免全局状态跨请求污染。Pinia 还提供了 hydrate、skipHydrate 之类的机制,帮助处理客户端 hydration 时哪些状态该从 SSR payload 接回来,哪些状态应该在客户端重新创建。
Pinia 适合管理这些应用状态:
- 当前用户资料、钱包余额、未读数这类应用数据。
- 可以序列化进 SSR payload 的页面数据。
- 多组件共享、生命周期不依赖某个 DOM 节点的数据。
- 需要 DevTools、action、插件、持久化策略的业务状态。
它不适合直接管理这类 source:
HTMLElement、ResizeObserver、AbortController、MediaStream、WebSocket listener 句柄。requestAnimationFrameid、滚动容器、鼠标 hover anchor、popover 定位元素。- 必须跟某个浏览器 tab 或某个 DOM 注册动作绑定的短生命周期状态。

把这次吸顶状态放进 Pinia,还需要处理 DOM 句柄、初始化时机和调用方对 store 的理解。
第一,DOM 句柄和 observer 不应该进入 store。它们不能序列化,也不应该参与 SSR payload,更不应该出现在普通业务 store 的调试时间线里。
第二,Pinia 会把问题从「模块级 source 要不要 SSR guard」换成「这个 store 什么时候初始化、谁负责 reset、是否会被其它业务误读」。之前项目里已经踩过 Nuxt plugin 抢跑 Pinia 的问题:插件如果早于 @pinia/nuxt 执行,useXxxStore() 会在运行时触发 getActivePinia() 错误。Pinia 解决 store 归属,不解决所有执行时机。
第三,这个状态不是应用数据,它是两个组件 owner 之间的 DOM 桥接。把它放进 Pinia 会让 store 看起来像业务 source,后续维护者很可能以为它可以被任意页面读取、持久化或复用。
在这个项目里,我按状态类型选择:可序列化的应用数据适合交给 Pinia;client-only DOM source 不适合优先放进 Pinia。
useState 也不是这类状态的答案
Nuxt useState 适合服务端和客户端都需要的可序列化状态,并让这份状态参与 Nuxt payload,例如接口请求结果、首屏配置和页面级初始化数据。
吸顶状态里的重要输入是浏览器 DOM:
- 滚动容器是谁。
- Tab 锚点当前在哪里。
- 当前
scrollTop是否越过阈值。 ResizeObserver和 RAF 是否已经注册。
这些内容不应该从服务端带到客户端,也不能通过 payload 复原。服务端最多知道默认值 false,客户端需要等 DOM 注册后重新计算。
用 useState 保存这些输入,会把 client-only UI bridge 表达成 Nuxt request state,模糊状态的归属。
VueUse 的共享 composable 能帮一部分
VueUse 有两个相关工具经常会被想到:createGlobalState 和 createSharedComposable。
createGlobalState 用来创建跨组件复用的内存状态。它解决的是「我不想每次调用 composable 都重新创建一份 state」。从语义上看,它很接近我们手写的模块级 singleton。
createSharedComposable 会把 composable 包装成共享实例。VueUse 文档说明,在 SSR 环境下它会退回非共享版本,避免跨请求状态污染。它覆盖了 SSR 下不共享同一份状态 的风险,当前项目的 owner 边界仍需另外表达。
这次的 source 不只是「共享一份 ref」。它还需要:
- Header 和 Profile 内容区通过显式注册动作建立桥。
- SSR 返回 no-op,客户端才接 DOM。
- route 卸载和单测要能显式 reset。
- store 内部要合并 scroll / resize 到下一帧。
- 阈值计算要避免 sticky 布局和滚动临界点互相影响。
createSharedComposable 可以成为底层 helper 的候选,但不能替代项目规则。即使用它包起来,review 仍然需要判断:这个 composable 里是不是有 DOM source?SSR 退回的实例是否真的 no-op?reset 是否被保留?调用方是否能看出这里是跨组件桥接?

.client.ts 和 <ClientOnly> 是另一种边界
还有一种常见方案是把代码改成 .client.ts 或把组件包进 <ClientOnly>。
这类方案适合纯客户端组件,例如 SDK 宿主、地图、播放器或客服浮层。但 .client 后缀只有在 Nuxt 识别它的目录和加载机制中才有意义,例如客户端插件;给任意 utils 或 composable 文件改名,并不能阻止它被服务端静态 import。<ClientOnly> 控制组件渲染,也不能替代对模块顶层副作用的检查。
当前这个 composable 不太一样。Header 和 Profile 页面都在正常 SSR 组件树里,调用方希望安全地拿到一个返回值,只是服务端返回 no-op。调用方不应该关心当前是 server 还是 client;边界应该被 composable 自己封住。
如果把它改成 .client.ts,调用方反而会被迫理解环境差异。后续有组件在 SSR 路径里 import 它,又会变成另一个问题。
适合当前调用方的结构是:

顶层 facade 保留 Nuxt 自动导入和调用方稳定性;环境分支留在 facade 内部;真正复杂的浏览器逻辑可以下沉到同名文件夹里。
一个可复用的解决方案模板
模板需要明确返回值、共享实例和清理责任:
- SSR 返回什么。
- CSR 复用哪一份 source。
- 谁负责 reset。
第一层是一个很薄的 client source helper。它只负责浏览器端单例和测试重置,不负责业务语义:
// app/composables/useClientOnlySourceState.ts
export interface ClientOnlySourceStateOptions {
isServer?: () => boolean
}
export default function useClientOnlySourceState<TState>(
createState: () => TState,
options: ClientOnlySourceStateOptions = {},
) {
let clientState: TState | null = null
const isServer = options.isServer ?? (() => import.meta.server)
function getClientState() {
if (isServer()) return null
clientState ??= createState()
return clientState
}
return {
getClientState,
getExistingClientState: () => {
if (isServer()) return null
return clientState
},
resetClientState: () => {
clientState = null
},
}
}helper 只负责「浏览器端可复用,服务端不创建」。路由、账号、DOM 和业务错误由各自 owner 处理。
第二层是 public composable,也就是 Nuxt auto-import 真正暴露给业务的入口。下面是一个吸顶 Header bridge 例子:
// app/composables/useStickyHeaderBridge/index.ts
function createServerStickyHeaderBridgeState(): StickyHeaderBridgeState {
const compactHeaderIsVisible = ref(false)
const noopCleanup = () => undefined
return {
compactHeaderIsVisible,
registerScrollRoot: () => noopCleanup,
registerStickyAnchor: () => noopCleanup,
resetStickyHeaderBridge: () => undefined,
}
}
const stickyHeaderBridgeClientSource = useClientOnlySourceState(createStickyHeaderBridgeStore)
/**
* 页面内容区和 Header 之间的 client-only DOM bridge。
*
* SSR 每次调用都返回独立 no-op state;浏览器端才复用同一个模块级 source。
* 调用方只注册 DOM,不需要知道阈值、RAF、ResizeObserver 和 cleanup 细节。
*/
export default function useStickyHeaderBridge(): StickyHeaderBridgeState {
if (import.meta.server) return createServerStickyHeaderBridgeState()
const store = stickyHeaderBridgeClientSource.getClientState()
if (!store) return createServerStickyHeaderBridgeState()
const { registerScrollRoot, registerStickyAnchor } = useStickyHeaderBridgeRegistration(store)
return {
compactHeaderIsVisible: store.compactHeaderIsVisible,
registerScrollRoot,
registerStickyAnchor,
resetStickyHeaderBridge: store.resetStickyState,
}
}
/** @internal 单测或路由卸载时重置浏览器 source,业务代码不要调用。 */
export function resetStickyHeaderBridgeForTest() {
stickyHeaderBridgeClientSource.resetClientState()
}吸顶计算可以替换,下面这些创建和清理约定需要保留:
createServerStickyHeaderBridgeState()每次调用都创建新的空态,不共享模块级对象。stickyHeaderBridgeClientSource只在浏览器端创建真实状态,保留模块级复用能力。- 默认导出只做环境分流,不再夹业务判断。
- docblock 说明这份状态为什么不进 Pinia /
useState/ payload。 - reset hook 单独标成
@internal,让单测和路由清理有入口,也让业务调用方知道它不是正常能力。
第三层是调用方。内容区是 DOM owner,它只负责注册滚动根和锚点:
<!-- examples/page-content.vue -->
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import useStickyHeaderBridge from '~/composables/useStickyHeaderBridge'
const scrollRoot = ref<HTMLElement | null>(null)
const stickyAnchor = ref<HTMLElement | null>(null)
const { registerScrollRoot, registerStickyAnchor, resetStickyHeaderBridge } = useStickyHeaderBridge()
const stopRegisterScrollRoot = watch(
scrollRoot,
(_element, _oldElement, onCleanup) => {
const cleanup = registerScrollRoot(scrollRoot.value)
onCleanup(cleanup)
},
{ immediate: true, flush: 'post' },
)
const stopRegisterStickyAnchor = watch(
stickyAnchor,
(_element, _oldElement, onCleanup) => {
const cleanup = registerStickyAnchor(stickyAnchor.value)
onCleanup(cleanup)
},
{ immediate: true, flush: 'post' },
)
onBeforeUnmount(() => {
stopRegisterScrollRoot()
stopRegisterStickyAnchor()
resetStickyHeaderBridge()
})
</script>Header 是 consumer,它不碰 DOM,只读取 public composable 暴露出来的语义状态:
<!-- examples/page-header.vue -->
<script setup lang="ts">
import useStickyHeaderBridge from '~/composables/useStickyHeaderBridge'
const { compactHeaderIsVisible } = useStickyHeaderBridge()
</script>
<template>
<header class="page-header" :class="{ 'page-header--compact': compactHeaderIsVisible }">
<strong class="page-header__title">Profile</strong>
</header>
</template>后续维护里还补出了一条更细的判断:下沉的是状态块,不是业务 owner 本身。如果 client source 只是 DOM 注册、observer、RAF 这类底层浏览器状态,拆成 useXxxClientSource() 会让 facade 更清楚;如果 source factory 本身就是业务 owner 的组合逻辑,例如它要同时表达账号作用域、弹窗状态、旧接口兜底 timer、账号切换 reset 和对外返回契约,把这段 factory 直接内联在 facade 里反而更容易读。
useClientOnlySourceState(() => { ... }) 可以直接包住入口里的初始化逻辑,不必一定接一个远处的 useXxxClientState。入口文件里能直接看到「SSR 返回什么」「CSR 创建什么」「谁负责 reset」「最终暴露哪些字段」时,内联是合理的;子 composable 继续拆私有状态块,例如账号作用域、收费提醒、金币不足。这样拆完后,读者从 public composable 进入时不会先跳进一层传声筒,再继续跳到真正 owner。
只有分支里还要做额外副作用、记录日志或多步前置检查时,才把三元表达式改回显式 if。否则就让默认出口保持非常单纯:SSR 拿空态,CSR 拿浏览器 source。
Skill 能提醒,不能当门禁
这次最直接的教训是:这类问题不能只靠 review。
Skill 当然要补。后续写 Nuxt composable 时,规则应该明确成这样:
app/composables/use*.ts或同名文件夹里的index.ts如果出现模块级ref / shallowRef / reactive / computed,必须说明 SSR 策略。- 如果出现模块级
let、Map、Set、timer、RAF、observer、DOM ref,也必须说明它是否只在客户端生效。 - 如果这是 client-only source,顶层 composable 要在
import.meta.server下返回 no-op 或 request-local state。 - 如果它确实是 SSR-safe singleton,旁边要有注释说明为什么不会跨请求污染。
- 如果业务代码只是需要应用数据,不要手写模块级 singleton,优先看 Pinia 或 Nuxt
useState。
Skill 能提醒人和 Agent,却无法强制每次提交遵守。遗漏仍可能进入 diff,因此提交检查需要由 ESLint 或架构脚本执行。
使用 ESLint 插件
Nuxt ESLint 已经有现成规则,比如 nuxt/prefer-import-meta,可以要求使用 import.meta.server / import.meta.client 这种 Nuxt 推荐写法。项目如果已经接了 @nuxt/eslint,通常也会通过 withNuxt() 扩展 flat config;这里补充的是项目语义层的静态检查。
现成规则可以提示用 import.meta.server 替代旧的 process.server,但不会检查模块级 shallowRef<HTMLElement | null> 与默认导出是否缺少 SSR 分支的组合。
这条检查现在已经沉淀到 @sheng/eslint-plugin 里的 @sheng/nuxt-no-ssr-unsafe-module-state。项目接入时优先看规则文档和插件的使用指南,不要再从这篇文章里手动复制旧源码。
它不需要一开始就做成完美的 SSR 分析器。第一版只扫最危险的范围:
- 目标文件:
app/composables/use*/index.ts和app/composables/use*.ts。 - 命中条件:顶层出现
ref、shallowRef、reactive、computed,或顶层出现可变let/Map/Set/ DOM 类型相关变量。 - 豁免条件:默认导出的 composable 内部有
if (import.meta.server) return ...,或者显式使用项目认可的 client-only helper。 - 报告方式:先
warn,不 autofix。

先用 warn,是因为项目里存在合法的模块级状态。某些聊天房间组件、通知弹窗、全局 UI host,可能本来就是客户端 singleton;也可能已经被 <ClientOnly> 包住,或者 SSR 阶段只读取默认值、不写入、不触碰 DOM。第一版先要求新增代码解释归属,避免一次性阻塞所有历史代码。
warning 需要推动开发者确认:
这份状态在 SSR 阶段属于谁?
如果回答不出来,就不应该悄悄过 review。
规则源码入口是 src/rules/nuxt-no-ssr-unsafe-module-state/index.ts。核心实现只抓三类信号:目标文件、模块级可变状态、默认导出的 SSR 分支。
shouldCheckFile() 把范围限制在 app/composables/use*.ts 和 app/composables/use*/index.ts;isMutableModuleState() 只把模块级 let、ref / shallowRef / reactive / computed、Map / Set 和 DOM 类型变量当成风险;codeHasImportMetaServerReturn() 检查默认导出的 composable 是否有 import.meta.server 返回分支。useClientOnlySourceState() 这类项目认可的 helper 可以通过 allowedHelperNames 放过,迁移期也可以用 ignoredPathPatterns 保留 owner 边界。
这版规则有几个刻意收窄的点:
- 只扫
use*composable,先不扫普通 utils、server、stores。 - 不把纯常量当问题,例如
const THRESHOLD = 4。 let frame = 0这类变量本身不是错;只有它和默认导出的 composable 组成 client source 时才需要 guard。useClientOnlySourceState()被列为认可的 client-only helper;如果项目里已经有等价 helper,可以通过allowedHelperNames配置补进去。- 允许行内禁用,但必须写原因,例如
// eslint-disable-next-line @sheng/nuxt-no-ssr-unsafe-module-state -- 客户端 overlay singleton,SSR 不会注册 DOM。 - 不做 autofix,因为 SSR no-op 的返回值是业务契约,不是机械替换。
等历史 warning 清完,再考虑从 warn 升到 error。
单测负责验证语义
ESLint 只能发现「这段代码看起来危险」。它无法证明 no-op 返回值的业务语义是否正确。
所以还需要 focused test 覆盖两个层面。
第一层是静态规则自己的测试:用几段代码确认它会报告危险写法,放过规则认可的 SSR 分支。认可 guard 只代表结构符合规则,分支外的顶层副作用、导入模块的初始化和 helper 内部的环境访问,仍可能绕过检查,不能据此证明安全。
第二层是 composable 自身的语义测试:
- SSR 分支调用时,返回
tabBarIsSticky = false。 - SSR 分支的
registerScrollRoot/registerTabAnchor返回 no-op cleanup。 - CSR 分支中多处调用共享同一个 state。
- reset 后旧 DOM 和旧 RAF 不会继续写回。
这几层检查分别作用于写代码、提交和运行阶段:

Skill 让写代码的人先知道规则;ESLint 在提交前提醒危险结构;单测验证 no-op 和 reset 语义;E2E 只负责最终页面滚动和 Header 展示是否符合用户行为。
如果等到 E2E 才发现 SSR 问题,成本已经太高了。
什么时候可以不用这套规则
不是所有模块级状态都要被这套规则重构。
几类场景可以放过,但要写清楚原因:
- 纯常量:例如颜色表、枚举映射、固定阈值。
- 服务端专用模块:例如
server/utils里的进程级缓存、日志收集器、文件写入队列。进程缓存仍要检查租户和用户隔离、缓存键及容量上限,不能因为位于server/就允许跨请求共享私有数据。 - 已经被明确限制在
.client.ts的浏览器插件,并且不会被 SSR import。 - 客户端 overlay singleton,SSR 只读取默认 false,实际组件在
<ClientOnly>内挂载。 - 测试专用 reset hook,且业务代码不会调用。
这些例外不能靠「大家知道」维持。代码旁边至少要有一句注释,说明它为什么安全;否则下一轮 review 仍然会重新争论。
一条可执行的项目口径
以后在 Nuxt 项目里看到模块级 state,可以按这张表判断:
| 状态类型 | 推荐 owner | SSR 策略 | 例子 |
|---|---|---|---|
| 可序列化业务数据 | Pinia / useState / 请求缓存 |
每个 request/app 隔离,按需 hydration | 当前用户、未读数、首屏配置 |
| 页面局部状态 | 组件内部 ref / 页面 composable |
组件 setup 内创建 | 表单展开、当前 Tab、本页 loading |
| 跨组件 DOM 桥接 | 顶层 facade + client-only source | SSR 返回 no-op,CSR 复用模块级 source | Header 读内容区滚动状态 |
| 浏览器 API 句柄 | client-only composable | SSR 不注册、不访问 DOM | RAF、observer、media stream |
| 服务端进程缓存 | server util | 明确进程级语义 | i18n key 收集、服务端文件缓存 |
request、browser tab、component instance、server process 和应用 store 的归属不同,需要分别选择创建、共享和清理方式。
规则要落到入口
- Pinia 有成熟的 SSR 隔离和 hydration 能力,但它适合应用数据,不适合直接承载 DOM source、observer、RAF 和短生命周期浏览器句柄。
- Nuxt
useState适合 request/payload 语义,不适合用来保存 client-only DOM 注册结果。 - VueUse
createSharedComposable能处理一部分共享 composable 的 SSR 降级,但它不能替项目判断 owner、reset、DOM 注册和 no-op 契约。 - 顶层 Nuxt auto-import facade 仍然是很好的结构:SSR 分支在入口处返回 no-op,CSR 分支复用 client-only source;当环境分流只是二选一返回值时,用
return import.meta.server ? createServerXxxState() : xxxClientSource.getClientState()!作为标准出口。 - Skill 要沉淀规则,ESLint 要承担门禁,单测要验证 no-op / reset 语义,E2E 只验证用户可见行为。
这次 review 发现的遗漏,后来有了提交前的结构检查。当前场景用 if (import.meta.server) 表达环境边界,no-op 和 reset 是否正确仍需单测验证。