别等 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 还要维护 DOM 注册与清理的生命周期:
- 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。
第一层 helper 只负责浏览器端状态槽:服务端返回 null,客户端第一次读取时才调用 factory,后续复用同一份状态。
function getClientState(): TState | null {
if (isServer()) return null
clientState ??= createState()
return clientState
}
第二层 facade 把服务端 null 转成业务认可的 no-op 返回值。第三层由内容区注册滚动根和锚点,Header 只读取 compactHeaderIsVisible;DOM owner 卸载时执行注册函数返回的 cleanup。
重置有两个动作:先调用旧 source 的 resetStickyState(),取消 RAF、解绑监听并断开 observer;再清空 helper 中的实例引用。只把引用设为 null,不会停止旧实例已经注册的浏览器回调。
完整实现与接入示例见完整源码与接入方法弹窗。
后续维护里还补出了一条更细的判断:下沉的是状态块,不是业务 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。项目接入时优先看规则文档和插件的使用指南,不要再从这篇文章里手动复制旧源码。
第一版先检查 composable 入口中的模块级可变状态:
- 目标文件:
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。
静态告警只说明结构可疑;no-op、reset、旧 DOM 与 RAF 的行为仍需独立测试,最后再检查页面交互。完整内容见模块级状态的回归验证弹窗。
什么时候可以不用这套规则
以下模块级状态可以保留,但要写清楚各自的安全条件:
- 纯常量:例如颜色表、枚举映射、固定阈值。
- 服务端专用模块:例如
server/utils里的进程级缓存、日志收集器、文件写入队列。进程缓存仍要检查租户和用户隔离、缓存键及容量上限,不能因为位于server/就允许跨请求共享私有数据。 - 已经被明确限制在
.client.ts的浏览器插件,并且不会被 SSR import。 - 客户端 overlay singleton,SSR 只读取默认 false,实际组件在
<ClientOnly>内挂载。 - 测试专用 reset hook,且业务代码不会调用。
这些例外不能靠「大家知道」维持。代码旁边至少要有一句注释,说明它为什么安全;否则下一轮 review 仍然会重新争论。
总结
这次吸顶 Header 需要跨组件共享浏览器 DOM 状态,模块级 source 能满足客户端共享,却不能在 SSR 中默认复用。顶层 facade 因此返回服务端认可的 no-op,客户端通过 factory 延迟创建真实 source;DOM owner 负责注册与 cleanup,reset 既要停止旧 RAF、监听和 observer,也要清空实例引用。
Skill 提醒归属,ESLint 提示危险结构,单测验证 no-op、共享与 reset,E2E 再检查最终吸顶交互。静态规则放过 guard,并不证明顶层副作用或导入模块安全。
后续选择状态 owner 时,可以对照这些归属与 SSR 策略:
| 状态类型 | 推荐 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 收集、服务端文件缓存 |
模块级状态的回归验证
单测负责验证语义
ESLint 只能发现「这段代码看起来危险」。它无法证明 no-op 返回值的业务语义是否正确。
因此,针对性的测试要分别覆盖规则判断和 composable 行为。
第一层是静态规则自己的测试:用几段代码确认它会报告危险写法,放过规则认可的 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 问题,成本已经太高了。
Nuxt client-only source 源码
工具包根路径:https://shengsheng.fun/files/nuxt-composable-ssr-guardrail/kits/nuxt-client-only-source-guardrail/
先读 README.md、MANIFEST.json、FILES.json、CHANGELOG.md 和 AGENT_PROMPT.md,再按 FILES.json 读取 helper、bridge、调用示例和测试。ESLint 规则优先使用当前维护的 @sheng/eslint-plugin。