别等 review 才想起 SSR:给 Nuxt 模块级 composable 补上静态护栏

有些 SSR 问题不会在代码写完的那一刻立刻爆出来。

这次就是这样。一个移动端 Profile 页需要在页面滚动后让顶部 Header 回显头像和昵称。页面内容和 Header 不在同一个组件树分支里,不能再靠 BEM class 跨组件 query,也不适合让 Header 反向读内容组件内部 DOM。最后我们做了一个很薄的共享 composable:内容区注册滚动容器和 Tab 锚点,Header 只读 tabBarIsSticky

逻辑本身没问题,review 时才发现少了一块关键边界:模块级状态只在客户端有意义,SSR 阶段必须显式返回 no-op 或 request-local state

review 才发现 SSR 分支缺失

这篇记录的是这次问题留下的工程判断:为什么 Pinia、Nuxt useState、VueUse 共享 composable 这些成熟方案都只能解决一部分问题;为什么最终更适合加一条项目级 ESLint 规则;以及 Skill、lint、单测应该分别兜哪一层。

问题发生在模块级 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、ResizeObserverrequestAnimationFrame、滚动容器和 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 和内容区共享同一份吸顶状态。

这里最值得沉淀的是一条判断规则:只要 composable 里出现模块级 reactive state 或可变变量,就必须先说明它在 SSR 阶段属于谁

Pinia 能解决什么,不能解决什么

Pinia 确实有 SSR 相关能力,而且是很成熟的能力。

Pinia 的 SSR 文档组件外使用 store 的说明里都强调过一个核心点:SSR 应用里,store 要挂在对应的 Pinia 实例上;在组件外使用 store 时,也要确保使用的是当前 app/request 对应的实例,避免全局状态跨请求污染。Pinia 还提供了 hydrateskipHydrate 之类的机制,帮助处理客户端 hydration 时哪些状态该从 SSR payload 接回来,哪些状态应该在客户端重新创建。

这说明 Pinia 很适合管理这类状态:

  • 当前用户资料、钱包余额、未读数这类应用数据。
  • 可以序列化进 SSR payload 的页面数据。
  • 多组件共享、生命周期不依赖某个 DOM 节点的数据。
  • 需要 DevTools、action、插件、持久化策略的业务状态。

它不适合直接管理这类 source:

  • HTMLElementResizeObserverAbortControllerMediaStream、WebSocket listener 句柄。
  • requestAnimationFrame id、滚动容器、鼠标 hover anchor、popover 定位元素。
  • 必须跟某个浏览器 tab 或某个 DOM 注册动作绑定的短生命周期状态。

Pinia 适合应用数据,不适合 DOM source

把这次吸顶状态塞进 Pinia,表面上能得到一个“全局 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 的结论要收窄到状态类型上:可序列化的应用数据适合交给 Pinia;client-only DOM source 不适合优先放进 Pinia

useState 也不是这类状态的答案

Nuxt useState 的诱惑也很大。它的名字就像是在说“这里有 SSR 安全的状态”。

它真正适合的是另一类问题:服务端和客户端都需要读到同一份可序列化状态,并且这份状态要参与 Nuxt payload。比如请求结果、首屏配置、某些页面级初始化数据。

这和吸顶状态的需求正好相反。

吸顶状态里的重要输入是浏览器 DOM:

  • 滚动容器是谁。
  • Tab 锚点当前在哪里。
  • 当前 scrollTop 是否越过阈值。
  • ResizeObserver 和 RAF 是否已经注册。

这些内容不应该从服务端带到客户端,也不能通过 payload 复原。服务端最多知道默认值 false,客户端需要等 DOM 注册后重新计算。

useState 写这件事,会让读者误以为“这是 Nuxt request state”,实际上它只是一个 client-only UI bridge。工具名字变熟了,语义反而变模糊了。

VueUse 的共享 composable 能帮一部分

VueUse 有两个相关工具经常会被想到:createGlobalStatecreateSharedComposable

createGlobalState 用来创建跨组件复用的内存状态。它解决的是“我不想每次调用 composable 都重新创建一份 state”。从语义上看,它很接近我们手写的模块级 singleton。

createSharedComposable 更有意思。VueUse 文档里说明它会把一个 composable 包装成共享实例;在 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 是否被保留?调用方是否能看出这里是跨组件桥接?

几个成熟方案分别覆盖哪一块

成熟工具能降低一类风险,但项目里的 owner 边界仍然要写出来。

.client.ts<ClientOnly> 是另一种边界

还有一种常见方案是把代码改成 .client.ts 或把组件包进 <ClientOnly>

这类方案适合纯客户端组件,例如一个只在浏览器里出现的 SDK 宿主、地图组件、播放器或者第三方客服浮层。它们不需要 SSR 输出,服务端可以直接跳过。

当前这个 composable 不太一样。Header 和 Profile 页面都在正常 SSR 组件树里,调用方希望安全地拿到一个返回值,只是服务端返回 no-op。也就是说,调用方不应该关心当前是 server 还是 client;边界应该被 composable 自己封住。

如果把它改成 .client.ts,调用方反而会被迫理解环境差异。后续有组件在 SSR 路径里 import 它,又会变成另一个问题。

这也是为什么更稳的形态是:

顶层 Nuxt auto-import facade
    ↓
SSR: 每次调用返回独立 no-op state
    ↓
CSR: 复用模块级 client source

推荐结构:顶层 facade 分流 SSR 和 CSR

顶层 facade 保留 Nuxt 自动导入和调用方稳定性;环境分支留在 facade 内部;真正复杂的浏览器逻辑可以下沉到同名文件夹里。

后续维护里还补出了一条更细的判断:下沉的是状态块,不是业务 owner 本身。如果 client source 只是 DOM 注册、observer、RAF 这类底层浏览器状态,拆成 useXxxClientSource() 会让 facade 更清楚;如果 source factory 本身就是业务 owner 的组合逻辑,例如它要同时表达账号作用域、弹窗状态、旧接口兜底 timer、账号切换 reset 和对外返回契约,把这段 factory 直接内联在 facade 里反而更容易读。

换句话说,useClientOnlySourceState(() => { ... }) 不是只能接一个远处的 useXxxClientState。入口文件里能直接看到「SSR 返回什么」「CSR 创建什么」「谁负责 reset」「最终暴露哪些字段」时,内联是合理的;子 composable 继续拆私有状态块,例如账号作用域、收费提醒、金币不足。这样拆完后,读者从 public composable 进入时不会先跳进一层传声筒,再继续跳到真正 owner。

这类 source 的出口也可以固定成一个模板。docblock 负责说明 SSR 空态和客户端复用 source 的业务语义;函数体只保留环境分流本身:

// app/composables/useXxx/index.ts
interface XxxState {
  value: Ref<string | null>
  reset: () => void
}

function createServerXxxState(): XxxState {
  return {
    value: ref(null),
    reset: () => {},
  }
}

const xxxClientSource = useClientOnlySourceState<XxxState>(() => {
  const value = ref<string | null>(null)

  function reset() {
    value.value = null
  }

  return {
    value,
    reset,
  }
})

/**
 * Xxx source。
 *
 * SSR 返回请求内临时空态;浏览器才复用模块级 source,避免跨请求状态污染。
 */
export default function useXxx(): XxxState {
  return import.meta.server ? createServerXxxState() : xxxClientSource.getClientState()!
}

这个模板的重点是让两件事同时成立:读者在入口处能看到 SSR/CSR 的完整分流,复杂的客户端状态仍然有一个模块级 source 负责复用和单测 reset。只有分支里还要做额外副作用、记录日志或多步前置检查时,才把三元表达式改回显式 if

Skill 能提醒,不能当门禁

这次最直接的教训是:这类问题不能只靠 review。

Skill 当然要补。后续写 Nuxt composable 时,规则应该明确成这样:

  • app/composables/use*.ts 或同名文件夹里的 index.ts 如果出现模块级 ref / shallowRef / reactive / computed,必须说明 SSR 策略。
  • 如果出现模块级 letMapSet、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,最后靠 review 才发现。

门禁应该交给 ESLint 或架构脚本。

更适合加项目级 ESLint 规则

Nuxt ESLint 已经有现成规则,比如 nuxt/prefer-import-meta,可以要求使用 import.meta.server / import.meta.client 这种 Nuxt 推荐写法。项目也已经接了 @nuxt/eslint,并且使用 withNuxt() 扩展 flat config;本地还有一批 sugo/* 自定义规则。

现成规则管的是写法,不管项目语义。它能提醒你别写旧的 process.server,却不会知道“这个文件里有模块级 shallowRef<HTMLElement | null>,默认导出的 composable 又没有 SSR 分支”。

这类问题适合加一条窄规则,例如:

sugo/no-ssr-unsafe-module-state

它不需要一开始就做成完美的 SSR 分析器。第一版只扫最危险的范围:

  • 目标文件:app/composables/use*/index.tsapp/composables/use*.ts
  • 命中条件:顶层出现 refshallowRefreactivecomputed,或顶层出现可变 let / Map / Set / DOM 类型相关变量。
  • 豁免条件:默认导出的 composable 内部有 if (import.meta.server) return ...,或者显式使用项目认可的 client-only helper。
  • 报告方式:先 warn,不 autofix。

ESLint 规则的判断流程

为什么先 warn

因为项目里确实会存在合法的模块级状态。比如某些 chatroom 组件、通知弹窗、全局 UI host,可能本来就是客户端 singleton;也可能已经被 <ClientOnly> 包住,或者 SSR 阶段只读取默认值、不写入、不触碰 DOM。第一版规则的目标是让新增代码停下来解释,避免把历史代码一次性打红。

这条规则最重要的输出,是让代码回答一个问题:

这份状态在 SSR 阶段属于谁?

如果回答不出来,就不应该悄悄过 review。

规则可以怎么写

当前项目已经有本地 ESLint plugin 形态:

// <PROJECT>/eslint.config.mjs
export default withNuxt({
  plugins: {
    sugo: {
      rules: {
        'prefer-keyed-object-map': preferKeyedObjectMap,
        'prefer-to-refs-props': preferToRefsProps,
      },
    },
  },
  rules: {
    'sugo/prefer-keyed-object-map': 'warn',
  },
})

新规则可以继续沿用这套结构,不额外引入独立包:

// <PROJECT>/eslint/rules/no-ssr-unsafe-module-state/index.mjs
export default {
  meta: {
    type: 'problem',
    docs: {
      description: '提醒 use* composable 里的模块级状态必须显式声明 SSR 策略。',
    },
    messages: {
      missingSsrGuard:
        '{{name}} 在模块级创建了共享状态,但默认导出的 composable 没有显式 SSR 分支;请返回 no-op / request-local state,或写明这个 singleton 为什么 SSR 安全。',
    },
    schema: [],
  },
  create(context) {
    // 伪代码:真实实现里应使用 AST 节点位置和 scope 信息,避免误报 import / type。
    const moduleStateDeclarations = []
    let defaultComposableHasServerReturn = false

    return {
      VariableDeclarator(node) {
        if (!isTopLevel(node)) return
        if (createsReactiveState(node) || createsMutableClientSource(node)) {
          moduleStateDeclarations.push(node)
        }
      },
      IfStatement(node) {
        if (isInsideDefaultExportedComposable(node) && checksImportMetaServer(node) && returnsFromBranch(node)) {
          defaultComposableHasServerReturn = true
        }
      },
      'Program:exit'() {
        if (!moduleStateDeclarations.length || defaultComposableHasServerReturn) return

        for (const node of moduleStateDeclarations) {
          context.report({
            node,
            messageId: 'missingSsrGuard',
            data: { name: context.sourceCode.getText(node.id) },
          })
        }
      },
    }
  },
}

这段是伪代码,不是最终实现。真正落地时要注意几个细节:

  • 只扫 use* composable,先不扫普通 utils、server、stores。
  • 不把纯常量当问题,例如 const THRESHOLD = 4
  • let frame = 0 这类变量本身不是错;只有它和默认导出的 composable 组成 client source 时才需要 guard。
  • 允许行内禁用,但必须写原因,例如 // eslint-disable-next-line sugo/no-ssr-unsafe-module-state -- 客户端 overlay singleton,SSR 不会注册 DOM
  • 不做 autofix,因为 SSR no-op 的返回值是业务契约,不是机械替换。

等历史 warning 清完,再考虑从 warn 升到 error

单测负责验证语义

ESLint 只能发现“这段代码看起来危险”。它无法证明 no-op 返回值的业务语义是否正确。

所以还需要 focused test 覆盖两个层面。

第一层是静态规则自己的测试:给规则喂几段代码,确认它会报危险写法,不会误报已经有 SSR 分支的写法。

第二层是 composable 自身的语义测试:

  • SSR 分支调用时,返回 tabBarIsSticky = false
  • SSR 分支的 registerScrollRoot / registerTabAnchor 返回 no-op cleanup。
  • CSR 分支中多处调用共享同一个 state。
  • reset 后旧 DOM 和旧 RAF 不会继续写回。

测试不应该替代 ESLint。它们负责不同阶段:

Skill、ESLint、单测和 E2E 分别挡哪一层

Skill 让写代码的人先知道规则;ESLint 在提交前提醒危险结构;单测验证 no-op 和 reset 语义;E2E 只负责最终页面滚动和 Header 展示是否符合用户行为。

如果等到 E2E 才发现 SSR 问题,成本已经太高了。

什么时候可以不用这套规则

不是所有模块级状态都要被这套规则重构。

几类场景可以放过,但要写清楚原因:

  • 纯常量:例如颜色表、枚举映射、固定阈值。
  • 服务端专用模块:例如 server/utils 里的进程级缓存、日志收集器、文件写入队列。
  • 已经被明确限制在 .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。

只要 owner 说清楚,代码形态就会跟着清楚。

总结

  • 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) 只是这条规则在当前场景下的代码表达。