07-SSR水合问题实战排查与修复记录

SSR 水合问题实战排查与修复记录

2026/8/22

背景

项目:B站风格视频平台,Nuxt 3 + Vue 3 + Go 微服务架构

在开发过程中,浏览器控制台反复出现水合警告(hydration mismatch warnings),最初以为是 SSR 框架的问题,一度想放弃 SSR 转纯 CSR。经过几轮排查,发现根源非常集中,修复成本很低。

症状

复制代码
[Vue warn]: Hydration completed but contains mismatches.

伴随现象:

  • 页面闪烁(先显示"未登录"再变成"已登录")
  • 部分交互失效(按钮点击无反应)
  • 控制台警告不断

排查过程

第一轮:怀疑 SSR 本身

最初认为 SSR 是问题根源,考虑全面关闭 SSR 转 CSR。但查阅 B 站官方技术博客后发现,B 站 2021 年重构选择的是 Vue 3 + SSR/CSR 混合方案,首页和 Tag 页 SSR 支撑千万级流量。SSR 不是问题,问题在具体实现。

第二轮:定位到 localStorage

通过全局搜索 safeStorage.getItemwindow.document. 等浏览器 API,发现所有水合警告的根因是同一个模式:

typescript 复制代码
// 错误写法:顶层执行,SSR 时无法访问
const user = JSON.parse(safeStorage.getItem('user') || '{}')
const token = safeStorage.getItem("token")

SSR 时服务端没有 localStoragesafeStorage 返回 null。服务端渲染出"未登录"状态,客户端水合时发现应该是"已登录",两边不一致,Vue 报错。

第三轮:统计影响范围

结果出乎意料------真正有问题的只有 9 处,集中在 5 个文件:

文件 行数 问题
VideoPlayer.vue 87, 106 字幕设置读 localStorage
CollectionDetailView.vue 17 user 对象顶层读取
CollectionListView.vue 13, 15 user 对象顶层读取
CollectionEditView.vue 19 user 对象顶层读取
UserProfileView.vue 45, 49 user 对象顶层读取
api/aiSummary.ts 8 window.location.origin 顶层引用

其余所有 safeStorage 调用都在 onMounted、事件处理函数、watch 回调中,这些只在客户端执行,不会导致水合问题。

修复方案

方案选择

业界有三种主流方案:

  1. ClientOnly 包裹 --- 简单但粗暴,会导致 SSR 内容空白闪烁
  2. onMounted 延迟读取 --- 常用但无法解决首屏一致性
  3. useCookie 替代 localStorage --- Nuxt 官方推荐,SSR 原生支持

最终选择方案 3,因为 Cookie 是 HTTP 请求的一部分,服务端和客户端都能读取。

具体实现

Go 后端改动

在所有登录/注册/刷新 token 的接口中,增加 Set-Cookie 响应头:

go 复制代码
func (h *UserExtendHandler) setSessionCookies(w http.ResponseWriter, token, refreshToken string, userID int64, nickname, avatar string) {
    http.SetCookie(w, &http.Cookie{
        Name: "token", Value: token, Path: "/",
        MaxAge: 86400 * 7, HttpOnly: false, SameSite: http.SameSiteLaxMode,
    })
    http.SetCookie(w, &http.Cookie{
        Name: "refresh_token", Value: refreshToken, Path: "/",
        MaxAge: 86400 * 30, HttpOnly: false, SameSite: http.SameSiteLaxMode,
    })
    userJSON, _ := json.Marshal(...)
    http.SetCookie(w, &http.Cookie{
        Name: "user_info", Value: url.QueryEscape(string(userJSON)), Path: "/",
        MaxAge: 86400 * 7, HttpOnly: false, SameSite: http.SameSiteLaxMode,
    })
}

注意:user_info 是 JSON 字符串,含双引号,必须用 url.QueryEscape 编码后才能写入 Cookie。

前端 useAuth 组合式函数
typescript 复制代码
export const useAuth = () => {
  const cookieToken = useCookie<string | null>('token', { default: () => null })
  const cookieUserRaw = useCookie<string | null>('user_info', { default: () => null })

  const user = computed(() => {
    if (cookieUserRaw.value) {
      try {
        return JSON.parse(decodeURIComponent(cookieUserRaw.value))
      } catch { return null }
    }
    // 客户端兜底:Cookie 不存在时读 localStorage(兼容旧会话)
    if (import.meta.client) return getStoredUser()
    return null
  })

  // ...
  return { token, refreshToken, user, isLoggedIn }
}

useCookie 在 SSR 时从请求的 Cookie 头读取,在客户端从 document.cookie 读取,同一个值,水合一致。

前端 auth.ts 同步
typescript 复制代码
export function setAuthSession(session) {
  // 同时写入 localStorage(兼容旧代码)和 Cookie(供 SSR 读取)
  safeStorage.setItem(TOKEN_KEY, session.token)
  setClientCookie('token', session.token, 86400 * 7)
  // ... 同样处理 refresh_token 和 user_info
}

遗留问题

VideoPlayer.vue 的字幕设置(subtitleEnabledsubtitleSettings)是纯客户端偏好,SSR 阶段不需要参与。处理方式:用默认值初始化,然后在 import.meta.client 块中从 localStorage 读取覆盖。

总结

水合问题不是 SSR 的锅,是代码放错位置。核心教训:

  1. SSR 页面的顶层不要读浏览器 APIlocalStoragewindowdocument
  2. Cookie 是 SSR 安全的状态存储方案useCookie 天然跨端一致
  3. 问题范围比想象的小------9 处代码修复,所有页面 SSR 正常
相关推荐
八荒启·交互动画25 分钟前
# Web特效020—让 Web 特效真正动起来:时间循环应该怎么接
前端·webgl·网页特效·八荒启-交互动画·八荒启
动恰客流统计27 分钟前
线下零售数字化浪潮下,客流统计的3个核心发展趋势
大数据·前端·人工智能
用户938515635071 小时前
从小米前端面试题,彻底搞懂闭包、作用域链与 useState 惰性初始化
前端·面试
粥里有勺糖2 小时前
视野修炼第133期 | Native 回春了?
前端·github·agent
aichitang20242 小时前
前端小skill
前端·人工智能·算法·ai·前端框架
志尊宝2 小时前
Vue3 零基础每日笔记(040):Vite 创建 Vue3 + TS 项目——企业标配从这一篇开始
笔记·vue·html·前端开发·软件开发
前端·柱子3 小时前
q-floodfill白边锯齿?一招搞定抗锯齿边缘问题
前端·html·canva可画
葡萄城技术团队3 小时前
SpreadJS V19.2新特性揭秘:功能区键盘提示
前端
计算机魔术师3 小时前
AI 幻觉导致的错误情报险些引发美军拦截中国船只
前端
万物智能3 小时前
SARADC模数转换—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端