4.1 useState与状态序列化


4.1 useState 与状态序列化

服务器把 HTML 和 payload 递过来了,浏览器怎么接?本节拆解水合的四个步骤、不匹配警告的成因,以及 Nuxt 内置状态容器 useState 的跨端机制。它是全册排错频率最高的一节——掌握交接区的规则,一半的"灵异现象"会自动消失。

水合的四个步骤

第 3 章管线图的第⑧步之后,浏览器侧发生的事:

  1. 解析 HTML:内容立即可见——这是 SSR 的用户价值,文字图片不等 JS;
  2. 下载执行 JS:应用代码到位,Vue 创建客户端应用实例;
  3. 读取 payload:服务端取好的数据从内嵌脚本读出,作为初始状态(useAsyncData 的 data、useState 的值都在这);
  4. 挂载接管:客户端渲染组件树与已有 DOM 对比匹配,把事件监听挂上去,应用从此可交互。

关键在第 4 步的"对比匹配":Vue 假设客户端渲染结果与服务器渲染结果完全一致。不一致时,开发模式会抛出 hydration mismatch 警告,生产环境可能整棵子树重新客户端渲染(闪烁、性能损耗)。

图 4-1:payload 交接与水合对账

图 4-1:payload 交接与水合对账

三类成因的共同本质:服务端渲染时的输入与客户端水合时的输入不同。修复思路要么让输入一致(固定 seed、粗化时间粒度),要么让那部分内容干脆只在一端渲染(ClientOnly):

<template> <!-- 第三类成因的标准修复:浏览器专属 UI 交给 ClientOnly --> <ClientOnly> <GeolocationBadge /> <!-- 内部访问 navigator,服务端渲染占位 --> <template #fallback> <span>定位获取中…</span> </template> </ClientOnly> </template>

useState:跨端状态的最小容器

ref 建立的状态是组件局部的,跨组件要靠 props 层层传或引入状态库。Nuxt 内置的 useState 提供第三条路:以 key 标识的跨组件、跨端共享状态,服务端设置的值随 payload 到达客户端,水合后两端是同一个值:

// composables/useCart.ts // 购物车数量:任何组件都能读写,且跨端同步 export const useCartCount = () => useState<number>('cart-count', () => 0)
<!-- 组件 A:加购按钮 --> <script setup> const count = useCartCount() </script> <template> <button @click="count++">加入购物车(当前 {{ count }})</button> </template>
<!-- 组件 B:顶栏角标,与组件 A 共享同一状态 --> <script setup> const count = useCartCount() </script> <template> <span class="badge">{{ count }}</span> </template>

机制拆开看:useState(key, init) 维护一张全局键值表;SSR 时表被序列化进 payload;客户端水合时先查表,查到就不再执行 init——这就是"服务端算过的,浏览器不重算"在状态层的实现。两点纪律:

  • key 必须唯一且稳定。两个不相干的状态共用一个 key 会互相覆盖;key 拼写变了,状态就"丢了";
  • init 保持轻量。init 在服务端执行(首次),别放请求逻辑——取数归 useAsyncData 管。

useState 与 useAsyncData 的分工一句话:useAsyncData 管"异步数据的获取与交接",useState 管"同步状态的共享与交接"。两者都以 key 为身份,都搭 payload 这趟车。

序列化的边界

payload 序列化用的是结构化克隆思路,但有边界:函数、Symbol、类实例这类不可序列化的值放不进去,放了会在开发模式收到警告、生产环境丢失。状态里只存纯数据(对象、数组、原始值);方法放组合函数里,不放状态:

// 反例:把函数塞进状态——交接必丢 const bad = useState('bad', () => ({ fn: () => doSomething() })) // 正例:状态存数据,行为放函数 const good = useState('good', () => ({ count: 0 })) function increment() { good.value.count++ }

排错实战:三例水合警告复盘

三个真实案例,把三类成因各走一遍排查流程。

案例一(随机值):活动页的"随机推荐"每次刷新内容不同,控制台规律性报 mismatch。服务端渲染时随机选了商品 A,客户端水合时随机选了商品 B,两本账对不上。修复:随机逻辑挪到 onMounted 之后执行,服务端渲染"推荐位加载中"的占位,浏览器接棒后再填真实推荐——SEO 无损(推荐内容本就不该进索引),警告消失。

案例二(时间差):订单列表显示"五分钟前下单"。服务端说五分钟,浏览器水合时已过六分钟,毫秒级的时间戳两端必然不同。修复:相对时间组件初始渲染绝对时间(两端输入一致),onMounted 后切换相对时间并启动定时刷新——展示了时间显示的正确分层。

案例三(判端分支):顶栏有个"返回顶部"按钮,模板里写了 v-if="isBrowser",值来自 typeof window !== 'undefined'。服务端 false 不渲染,客户端 true 渲染,警告准时到访。修复:换 ClientOnly 组件包住按钮,fallback 槽给个占位符——框架级的"这段归浏览器"声明。

三例的共同教训:水合警告是引擎在对账失败时的如实报告,不是引擎的 bug。抱怨警告烦人之前先问:我的代码是否给了两端一致的输入?答案几乎总是"没有"。

⚠️ 常见坑:用 useState 存了整份接口响应又在另一组件用 useFetch 取同一接口。两条通道各带一份 payload,体积翻倍还可能不同步。同一数据只有一条权威通道:页面数据走 useAsyncData,交互状态走 useState。

💡 关键直觉:水合是"对账"不是"重画"——浏览器不重新生成内容,只验证自己算出的结果与服务器留的底账一致。凡是让两本账对不上的代码(随机、时间、判端),都会在对账时被抓。

本节要点回顾

  • 水合四步:解析 HTML → 执行 JS → 读 payload → 对比挂载,前两步之间用户已可阅读内容;
  • 不匹配三因:随机值、时间差、判端分支,本质都是两端输入不同,修复要么统一输入要么单端渲染;
  • ClientOnly 是浏览器专属 UI 的标准容器,fallback 槽提供占位;
  • useState 机制:全局键值表随 payload 交接,客户端命中即不重跑 init;
  • key 纪律:唯一、稳定、业务命名;init 轻量,不放请求;
  • 序列化边界:状态只存纯数据,函数与类实例放不进 payload。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U