《互联网产品的客户端架构·端形态与体验基础》10.2 水合与首屏一致性

服务端与客户端首帧不一致通常来自时钟、随机数、本地状态与权限四个来源,本节用字段级帧对比把它们分开,给出可运行的 TypeScript 差异检测器与八组测试,检测器不依赖浏览器,只在进程内比较两棵字段树并标出可延后项,所有结论都由测试固定,方便照搬到自己的组件树。

首屏其实渲染了两次

服务端渲染的页面,用户看到的第一帧不是浏览器算出来的,而是服务端算好、以 HTML 形式传过来的。浏览器下载 JS 之后,会再执行一遍组件树,把事件、状态和后续更新接上去,这个过程叫水合。

问题在于,这两次渲染的输入不可能天然完全相同。服务端在 10:00:00.123 渲染,浏览器在 10:00:01.870 执行;服务端的随机数生成器与浏览器的不是同一个;服务端不知道用户本地存了草稿;服务端会话缓存里的权限可能比浏览器刚拿到的令牌旧一个版本。任何一处不同,都会让第二次渲染生成与 HTML 不同的树。

框架处理这种不同的方式通常有两种:开发环境直接报错,生产环境悄悄丢掉服务端 HTML、整段重新渲染。前者让开发者烦,后者让用户看到闪烁,还白白浪费了服务端渲染的成本。更糟的是,有些差异不会立刻报错,而是留下一个错位的按钮、一个过期的角标,等用户点击时才暴露。

本节不讨论具体框架的报错文案,而是把差异拆成可命名的来源,并写一个不依赖浏览器的检测器。只要两帧能被表示成字段树,差异就能在进程内被测试固定下来。

四个来源

时间

最常见的是相对时间与格式化时间。服务端渲染“3 分钟前”,浏览器加载时已经过了 40 秒,组件重新计算变成“4 分钟前”。绝对时间也不安全:服务端时区、用户时区、夏令时、toLocaleString 的默认区域设置,都可能让同一时间戳输出不同字符串。

规避方式不是禁止显示时间,而是固定输入。服务端把格式化后的字符串连同时间戳一起下发,客户端水合时先用服务端字符串,挂载后再按本地时区更新;或者统一用 UTC 与固定格式,把本地化放到水合之后。

随机数

优惠券码、A/B 分组、列表打乱、占位 ID、Math.random() 生成的 key,都属于这一类。服务端生成一次,客户端执行时又生成一次,两次结果不同,树自然不同。

规避方式是把随机结果当成数据,而不是渲染时的副作用。服务端生成后写进 HTML 或序列化状态,客户端水合阶段读取同一份值;确实需要客户端随机的部分,等挂载后再生成。A/B 分组尤其要注意:分组必须由服务端决定并下发,否则同一用户在首屏与交互后可能属于不同组。

本地状态

草稿、上次选择的分页、折叠面板的展开状态、主题偏好,这些只存在浏览器里。服务端渲染时读不到,客户端首帧却能读到,于是客户端多出一个输入框内容或一个展开的面板。

规避方式是把本地状态排除在首帧之外。首帧按“没有本地状态”渲染,水合完成后再从 localStorage 读取并写入;或者给这块内容一个稳定的占位,避免布局跳动。把本地状态直接塞进首帧,短期看省了一次更新,长期看必然产生水合差异。

权限

服务端用会话 cookie 判断用户是编辑还是读者,客户端可能还没拿到权限接口的响应,首帧先按未登录渲染;反过来,服务端缓存了旧权限,客户端拿到新权限,也会不一致。权限差异比前三种更危险,因为它可能让用户短暂看到不该看到的操作入口。

规避方式有两种:首帧只渲染与权限无关的骨架,权限相关区域等客户端确认后再出现;或者把权限判断完全放在服务端,客户端不在首帧重复判断,只消费服务端下发的布尔值。两种方式都要求权限快照有明确的版本与失效时间。

把首帧拆成字段

与其在浏览器里对着 DOM 树找差异,不如在生成阶段就把首帧表示成一组带来源标签的字段。下面的实现把页面压缩成五个字段,服务端与客户端各自生成一帧,再用同一个比较函数找出差异。

// file: src/render/hydration.ts
// tier: L1
// verify: node --test test/render/hydration.test.ts

export type MismatchCause = "clock" | "random" | "local-state" | "permission" | "content";

/** 生成首帧所需的输入;服务端与客户端拿到的是不同的快照。 */
export interface RenderView {
  /** 渲染时刻(毫秒时间戳) */
  now: number;
  /** 每次渲染生成的随机标识 */
  nonce: string;
  /** 只存在浏览器里的草稿 */
  localDraft: string | null;
  /** 当前会话权限 */
  canEdit: boolean;
  /** 页面正文 */
  title: string;
}

export interface FrameField {
  name: string;
  cause: MismatchCause;
  value: string;
}

export interface FrameDiff {
  name: string;
  cause: MismatchCause | "structure";
  server: string | null;
  client: string | null;
}

/** 服务端首帧:没有草稿,权限来自服务端会话,时间与随机数各生成一次。 */
export function serverFrame(view: RenderView): FrameField[] {
  return [
    { name: "title", cause: "content", value: view.title },
    { name: "rendered-at", cause: "clock", value: new Date(view.now).toISOString() },
    { name: "nonce", cause: "random", value: view.nonce.slice(0, 8) },
    { name: "role", cause: "permission", value: view.canEdit ? "editor" : "reader" },
  ];
}

/** 客户端首帧:除正文外,每个字段都可能与服务端快照不同。 */
export function clientFrame(view: RenderView): FrameField[] {
  const fields = serverFrame(view);
  if (view.localDraft !== null) {
    fields.push({ name: "draft", cause: "local-state", value: view.localDraft });
  }
  return fields;
}

/**
 * 比较两帧。字段级差异保留来源原因;缺字段或多字段统一记为 structure,
 * 因为结构差异意味着组件树本身不一致,比单个值不一致更难恢复。
 */
export function diffFrames(server: FrameField[], client: FrameField[]): FrameDiff[] {
  const serverMap = new Map(server.map((field) => [field.name, field]));
  const clientMap = new Map(client.map((field) => [field.name, field]));
  const diffs: FrameDiff[] = [];

  for (const [name, left] of serverMap) {
    const right = clientMap.get(name);
    if (right === undefined) {
      diffs.push({ name, cause: "structure", server: left.value, client: null });
      continue;
    }
    if (left.value !== right.value) {
      diffs.push({
        name,
        cause: left.cause === right.cause ? left.cause : "structure",
        server: left.value,
        client: right.value,
      });
    }
  }

  for (const [name, right] of clientMap) {
    if (!serverMap.has(name)) {
      diffs.push({ name, cause: "structure", server: null, client: right.value });
    }
  }

  return diffs;
}

export interface StabilizedFrame {
  /** 水合时直接采用服务端快照的字段,保证首帧逐字节一致 */
  frame: FrameField[];
  /** 必须等水合完成后再写入的本地字段 */
  deferred: FrameField[];
  /** 客户端多出来、又不属于本地状态的字段,应视为缺陷 */
  unexpected: FrameField[];
}

/**
 * 把客户端帧收敛到服务端快照:时钟、随机数、权限一律以服务端为准;
 * 本地草稿不进首帧,放到 deferred,由挂载后的副作用写入。
 */
export function stabilize(server: FrameField[], client: FrameField[]): StabilizedFrame {
  const clientMap = new Map(client.map((field) => [field.name, field]));
  const frame = server.map((field) => {
    if (field.cause === "local-state") {
      return clientMap.get(field.name) ?? field;
    }
    return field;
  });

  const serverNames = new Set(server.map((field) => field.name));
  const extra = client.filter((field) => !serverNames.has(field.name));

  return {
    frame,
    deferred: extra.filter((field) => field.cause === "local-state"),
    unexpected: extra.filter((field) => field.cause !== "local-state"),
  };
}

/**
 * 水合前只允许本地状态缺席;时钟、随机数、权限、正文任何一项不一致都要报错。
 * 返回空数组表示两帧可以安全水合。
 */
export function hydrationViolations(server: FrameField[], client: FrameField[]): FrameDiff[] {
  const localNames = new Set(
    client.filter((field) => field.cause === "local-state").map((field) => field.name),
  );
  return diffFrames(server, client).filter((diff) => {
    if (diff.cause === "local-state") return false;
    return !(diff.cause === "structure" && localNames.has(diff.name));
  });
}

这里的关键不是字段数量,而是每个字段都带 cause。差异一旦能被归因,处理方式就确定了:时钟、随机数、权限以服务端快照为准;本地状态延后到挂载后;正文不一致则说明数据本身有问题,必须查。

diffFrames 把缺字段与多字段统一记为 structure,因为结构差异意味着组件树形状不同,比单个值不同更难恢复。stabilize 则演示了收敛顺序:先用服务端帧作为首帧,再把本地状态放进 deferred,最后把客户端多出来的非本地字段列为 unexpected。unexpected 不为空时,说明有渲染逻辑绕过了约定,应该当缺陷处理。

运行结果

cd .local/checks/echo-client-lab
node --test test/render/hydration.test.ts
✔ 同一份视图生成的两帧没有差异 (2.380959ms)
✔ 时钟与随机数差异被归到各自来源 (0.170917ms)
✔ 权限快照不同记为 permission (0.101375ms)
✔ 只在客户端存在的草稿是结构差异,但可被延后处理 (0.5295ms)
✔ 本地草稿在默认规则下不算水合违规 (0.131958ms)
✔ 正文不一致必须报错,不能被当成可延后状态 (0.081209ms)
✔ 时钟、随机数、权限一律以服务端快照为准 (0.083416ms)
✔ 客户端多出的非本地字段被列为意外结构 (0.080792ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 112.042708

八组测试分别覆盖:完全相同、时钟与随机数、权限、本地草稿、正文不一致、服务端快照收敛、以及客户端多出字段。它们不启动浏览器,也不依赖 DOM,因此可以在每次提交时跑,而不是等到端到端测试才发现。

三个场景的判定

场景差异来源首帧策略水合后动作
文章页显示“3 分钟前”时钟用服务端格式化字符串挂载后按本地时区刷新
优惠券弹窗随机数服务端生成并下发不重新生成
编辑器恢复草稿本地状态首帧不渲染草稿挂载后读取并填入
编辑按钮可见性权限用服务端下发的布尔值权限变更时重新拉取

这张表的顺序也是处理顺序:先固定输入,再延后本地状态,最后处理权限。不要把权限判断留给客户端首帧,也不要用 suppressHydrationWarning 一类开关把差异藏起来。隐藏差异只是把问题推迟到用户交互时。

一次时间戳引发的闪烁

某次版本里,订单列表的每一行都显示“刚刚更新”。服务端渲染时是“刚刚”,浏览器水合时已经过了两分钟,组件重新计算成“2 分钟前”,整列文字闪了一下。开发环境没有报错,因为框架把文本差异当成可恢复差异处理了;生产环境的监控却看到列表区域的重绘比例异常升高。

复盘发现三个问题叠加:

  • 相对时间在渲染函数里直接调用 Date.now(),没有把时间作为输入传入。
  • 服务端与客户端各自格式化,没有共享同一份字符串。
  • 列表没有稳定的 key,文本变化触发整行重排。

修复方式很朴素:服务端把格式化后的字符串和绝对时间一起下发,客户端首帧直接使用字符串,挂载后再按本地时钟更新;列表补上稳定 key。修复后重绘比例回到基线。这个例子说明,水合差异不一定会报错,它可能只是让页面“抖一下”,而抖动的原因很难从现象反推到时间源。

文中提到的重绘比例与耗时是案例假设值,用来说明排查路径,不是任何真实产品的监控数据。

规避策略的优先级

处理水合差异,优先级从高到低是:

  1. 消灭不确定性:随机数、当前时间、权限判断都改成显式输入,由服务端生成并下发。
  2. 延后本地状态:首帧不读 localStorage、不读 window、不判断 navigator,挂载后再读。
  3. 缩小边界:把确实无法统一的部分隔离到独立组件,让差异只影响一小块,而不是整棵树。
  4. 显式标记:对已知且可接受的差异,用水合边界或客户端专属渲染标记出来,并在测试里固定。
  5. 最后才是抑制警告:抑制警告不改变行为,只改变可见性,不能作为常规手段。

这个顺序也解释了为什么“加一个 useEffect 就好了”经常不够:useEffect 能延后副作用,但如果首帧的初始值仍然来自浏览器,差异已经发生在水合之前。

小结

水合不一致的本质是两次渲染的输入不同。把差异拆成时间、随机数、本地状态与权限四个来源,比笼统地说“水合报错”更有用,因为每个来源都有对应的处理方式:固定输入、服务端下发、延后读取、缩小边界。

检测器把首帧表示成带来源标签的字段,差异就能在进程内被比较、被归因、被测试。它不能替代真实浏览器里的水合验证,但能在提交阶段拦住绝大多数可预见的差异,并让“这块为什么允许不一致”变成代码里的显式决定。

下一节把视角从单页拉回到整个产品:渲染模式选完之后,什么时候应该退出,退出要付出多少迁移成本,以及如何把一次决策写成可复核的记录。

阅读导航:上一节:10.1 CSR、SSR、SSG 与流式 · 下一节:10.3 渲染模式的选择与退出 。

常见错误与边界

  • 在渲染函数里直接调用 Date.now()。时间必须是输入,不能是渲染时的隐式副作用。
  • 在服务端与客户端各自生成随机数。随机结果应该由服务端生成并下发。
  • 首帧直接读 localStorage。本地状态应延后到挂载后,或使用稳定占位。
  • 客户端首帧重复判断权限。权限快照应由服务端下发,并带版本与失效时间。
  • 用 suppressHydrationWarning 当常规修复。它只隐藏警告,不消除差异。
  • 把结构差异当成值差异。缺字段或多字段意味着组件树形状不同,恢复成本更高。
  • 只做端到端测试,不在提交阶段检查。水合差异越晚发现,定位成本越高。
  • 认为没有报错就没有差异。文本闪烁、布局抖动、按钮错位都可能是水合差异。
  • 把本地草稿塞进首帧。它会让服务端 HTML 与客户端首帧必然不同。
  • 忽略时区与区域设置。同一时间戳在不同环境下的格式化结果可能不同。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查