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,发现所有水合警告的根因是同一个模式:

// 错误写法:顶层执行,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 响应头:

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 组合式函数
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 同步
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 正常
Logo

一站式 AI 云服务平台

更多推荐