首页
/ React SSR 水合闪烁消除实战:Cal.diy 中"同步内联脚本先修补 DOM"模式与主题持久化实现

React SSR 水合闪烁消除实战:Cal.diy 中"同步内联脚本先修补 DOM"模式与主题持久化实现

2026-09-07 17:17:41作者:毕习沙Eudora

本文围绕一个高频前端难题展开:当页面首屏内容依赖 localStorage、cookie 等客户端存储时,如何同时避免 SSR 崩溃与 hydration 后的"白屏闪烁"。文章完整继承规则文档《Prevent Hydration Mismatch Without Flickering》(rendering-hydration-no-flicker.md)中的错误写法、闪烁写法与正确写法,并结合 Cal.diy 仓库中真实的主题系统实现(getThemeProviderProps.ts)说明该模式如何落地到大规模 Next.js 应用中。读完后,你能掌握"同步内联脚本在 React 水合前更新 DOM"这一模式的原理、适用边界,以及为什么 Cal.diy 选择用 next-themes 库而非手写脚本。

问题的两个失败模式

凡是需要读取客户端存储(localStorage、cookie)来决定首屏渲染结果的场景——主题切换、用户偏好、登录态展示——都会撞上同一堵墙。规则文档把失败模式归纳为两种,这也是本模式要同时消灭的两个目标。

失败模式一:直接读取 localStorage 破坏 SSR

function ThemeWrapper({ children }: { children: ReactNode }) {
  // localStorage is not available on server - throws error
  const theme = localStorage.getItem('theme') || 'light'

  return (
    <div className={theme}>
      {children}
    </div>
  )
}

服务端渲染阶段 localStorageundefined,直接访问会抛错,SSR 直接失败。这是最直观的写法,也是最不可用的写法。

失败模式二:useEffect 读取造成视觉闪烁

function ThemeWrapper({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState('light')

  useEffect(() => {
    // Runs after hydration - causes visible flash
    const stored = localStorage.getItem('theme')
    if (stored) {
      setTheme(stored)
    }
  }, [])

  return (
    <div className={theme}>
      {children}
    </div>
  )
}

这种写法 SSR 不会崩,但用户体验很差:组件先以默认值(light)完成服务端渲染和水合,useEffect 在 hydration 之后才执行 setState,于是用户会看到一次明显的内容闪变(flash of incorrect content)——浅色用户瞬间"闪"成深色。对偏好深色主题的用户来说,每次进页都是一次刺眼的白闪。

正确模式:同步内联脚本在水合前修补 DOM

规则文档给出的正解,核心思想只有一句话:把"读取客户端存储并更新 DOM"的动作,从 React 组件生命周期里剥离出来,交给一段在文档解析阶段同步执行的 <script> 完成——它跑在 React 水合之前,保证水合时 DOM 上已经是有正确值的那个。

function ThemeWrapper({ children }: { children: ReactNode }) {
  return (
    <>
      <div id="theme-wrapper">
        {children}
      </div>
      <script
        dangerouslySetInnerHTML={{
          __html: `
            (function() {
              try {
                var theme = localStorage.getItem('theme') || 'light';
                var el = document.getElementById('theme-wrapper');
                if (el) el.className = theme;
              } catch (e) {}
            })();
          `,
        }}
      />
    </>
  )
}

这个方案能同时满足两个约束的原因:

  1. SSR 安全<script> 标签在服务器上只是被序列化为 HTML 字符串,脚本体中的 localStoragedocument 只会在浏览器端执行,服务端渲染永远不会触碰它们。React 水合时看到的 DOM 与服务器输出的 HTML 在结构上一致(脚本修改的是 className 这类"表现层"属性),不产生 hydration mismatch。
  2. 零闪烁:内联脚本是同步的,浏览器在解析 HTML 流到该脚本时就会立即执行——早于 React 加载与水合,更早于用户看到页面。DOM 在被用户"看见"的那一刻就已经是正确的主题。
  3. 防御性细节:脚本用 IIFE 包裹、用 try/catch 吞掉一切异常(存储不可用、元素不存在),保证它永远不会打断页面加载;var el = document.getElementById(...)if (el) 判空,避免脚本本身成为新的故障点。

规则文档总结其适用范围:主题切换、用户偏好、认证状态,以及任何"客户端独有数据且必须首帧就正确、不能闪一下默认值"的渲染场景

Cal.diy 的落地:next-themes 与精细化的 storageKey

Cal.diy 没有手写上面那段内联脚本,而是采用了业界成熟的 next-themes"next-themes": "0.2.0",见 package.json)——该库内部实现的正是本规则文档描述的同一种"同步 JS snippet 在 hydration 前应用主题"的机制。项目内的 how-theming-works.md 明确说明了这一点:

It provides useNextTheme hook which does the job of applying a theme for the app reliably. It persists the preference in localStorage and then ensures that the theme is reliably and instantly applied on next page load through its JS snippet.

主题应用点在 app-providers.tsxCalcomThemeProvider 组件调用 getThemeProviderProps 计算出属性后渲染 <ThemeProvider key={key} {...themeProviderProps}>。而真正的工程难点不在于"要不要用内联脚本",而在于多场景共享同一 origin 的 localStorage 时,如何为每类页面生成正确的 storageKey——这正是 Cal.diy 自己踩过的坑。

三类场景的 storageKey 设计

getThemeProviderProps.ts 的源码和注释(L49-L72)可以看到完整设计:

场景 storageKey 说明
仪表盘等 App 页面 app-theme 用户可强制 light/dark 或跟随系统(L131-L132
独立/直接预订页 booking-theme:${themeBasis} themeBasis 通常是组织者用户名;同一 origin 下不同预订页可能配置不同主题(L133-L134
嵌入式(embed) embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix} 同一页面可并存多个不同主题的 embed,key 中叠加 namespace、组织者、显式主题三层后缀(L127-L130

themeBasis 的生成逻辑在 getUniqueIdentifierForBookingPage 中:它区分用户预订页(/free/free/30mins)、团队预订页(/team/sales)、私有链接页(/d/xxx)与动态组合预订页(/user+user/30mins),为每种 URL 形态提取出"同一预订实体在换 URL 时保持不变"的唯一标识。比如 /free/free/30mins 共用同一主题偏好,切换时长选项时不会跳主题。

注释里记录的闪烁根因:storage 事件竞态

getThemeProviderProps.ts 的注释还记录了一个更隐蔽的闪烁来源——浏览器处理 iframe 内 storage 事件的延迟会导致主题在多个 embed 之间无限互相覆盖

- t1 -> setItem(A) & Fires storageEvent(A) - On Page A) - Current State(A)
- t2 -> setItem(B) & Fires storageEvent(B) - On Page B) - Current State(B)
- t3 -> Receives storageEvent(A) & thus setItem(A) (On Page B) - Current State(A)
- t4 -> Receives storageEvent(B) & thus setItem(B) (On Page A) - Current State(B)
- ... and so on ...

两个页面 A、B 的 embed 各自写入自己的主题时,storage 事件的跨 frame 传播存在时间差,后到的事件会触发再次写入,形成 A→B→A→B 的循环。这也是为什么 storageKey 必须把 namespace 和显式主题编码进去:让"相同 origin、不同主题"的多个 embed 彼此隔离在互不相通的 key 上,从根上消除事件串扰。

注释同时解释了为什么不能"偷懒":

  • 禁用 localStorage 持久化?→ 每次进页面都会先默认 light 几秒再切到 dark,闪烁回归;
  • 禁用 storage 事件监听?→ 一个标签页切主题,其他标签页不刷新不会跟着变,同样是坏体验。

所以结论是:同步脚本(或 next-themes 的等价 snippet)保证首帧正确,精细化的 storageKey 保证多场景共存时互不污染,两者缺一不可。

软导航时的强制重渲染

next-themes 还有一个已知限制被源码注释点明(L143-L145):

// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
// This is how login to dashboard soft navigation changes theme from light to dark
key: storageKey,

next-themes 不会监听 storageKey 属性的变化,因此把 storageKey 同时作为 <ThemeProvider>key 传入——当用户从登录页(forcedThemeKey)软导航进入仪表盘(app-theme)时,key 变化强制 ThemeProvider 整体重建,主题随之从强制 light 平滑切换到用户的 app 偏好。这条实现细节有对应的单元测试覆盖:getThemeProviderProps.test.ts 中逐场景断言了 app-themebooking-theme:*、embed key 与 forcedTheme 的生成结果。

模式选型小结

把规则文档的抽象模式和 Cal.diy 的工程实现放在一起看,可以得到一条清晰的选型路径:

  1. 只有一两个简单场景、想彻底掌控:直接使用规则文档中的内联脚本写法。它约 10 行代码、零依赖,同步执行保证零闪烁,try/catch 保证不影响页面主流程。
  2. 多场景、需要跨标签页同步、需要跟随系统主题:采用 next-themes 这类封装了相同底层机制的库,把精力放在 storageKey 设计、CSP nonce(Cal.diy 通过 props.nonce 注入,保证内联脚本在 CSP 下可执行)和事件竞态治理上,而不是重写脚本。
  3. 任何方案都不能退回的两个"错误示范":组件内直接读 localStorage(破坏 SSR)、useEffect 里读存储再 setState(首帧闪烁)——这两条是规则文档明确列出并应当从团队编码规范中禁止的写法。

一句话概括本规则文档的技术内核:客户端存储驱动的 UI 状态,其"首次应用"必须发生在 React 水合之前的同步脚本层;React 层只负责后续的状态管理与交互。Cal.diy 的主题系统正是以这个内核为骨架,向外扩展出覆盖 App、预订页、Embed 三类场景的完整工程化实现。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389