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.getItem、window.、document. 等浏览器 API,发现所有水合警告的根因是同一个模式:
typescript
// 错误写法:顶层执行,SSR 时无法访问
const user = JSON.parse(safeStorage.getItem('user') || '{}')
const token = safeStorage.getItem("token")
SSR 时服务端没有 localStorage,safeStorage 返回 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 回调中,这些只在客户端执行,不会导致水合问题。
修复方案
方案选择
业界有三种主流方案:
ClientOnly包裹 --- 简单但粗暴,会导致 SSR 内容空白闪烁onMounted延迟读取 --- 常用但无法解决首屏一致性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 的字幕设置(subtitleEnabled、subtitleSettings)是纯客户端偏好,SSR 阶段不需要参与。处理方式:用默认值初始化,然后在 import.meta.client 块中从 localStorage 读取覆盖。
总结
水合问题不是 SSR 的锅,是代码放错位置。核心教训:
- SSR 页面的顶层不要读浏览器 API (
localStorage、window、document) - Cookie 是 SSR 安全的状态存储方案 ,
useCookie天然跨端一致 - 问题范围比想象的小------9 处代码修复,所有页面 SSR 正常