SSR 阶段拿不到 localStorage,服务端也没有记忆,水合迟早和客户端对不上。强登录页走 CSR;SSR 只渲染公共内容,凭证校验压到中间件。
面试官追问的那一句,才是考点
SSR 适不适合处理登录态?为什么?
大多数人答到"服务端拿不到 localStorage"就停了。这话没错,但只对一层。面试官跟一句"Cookie 不是能传到服务端吗",就接不住了。
难点从来不是传。是传过去之后,服务端和客户端怎么保持一致。
六条高频答案,第 2 条最要命
| 常见回答 | 实际情况 |
|---|---|
| SSR 完全做不了登录态 | 能做,但通道极窄:只能靠 Cookie,且不能缓存 |
| Cookie 没法把凭证传到服务端 | 同源请求自动携带,通道本身没问题 |
| 登录态不一致只是视觉闪烁 | 会直接触发水合报错,框架丢弃整段 DOM 重建 |
| localStorage 服务端也能读 | Node 里根本没有这个全局对象 |
| 刷新页面就能解决 | 服务端缓存不清,刷十次还是串号 |
| 登录态错乱跟水合没关系 | 水合只是矛盾的出口,根因是两套状态源 |
第 2 条最要命:Cookie 能传,所以"拿不到"不是答案。答案是"拿到了也难对齐"。
差别不在能不能拿到,在拿到的对不对得上
SSR 页面在 Node 侧提前跑一遍组件树、吐出 HTML,客户端 JS 到位后再 hydrate 一次接管。
两套环境、两套输入、两次渲染。登录态只要参与其中一次,就有对不齐的可能。
存储不在同一个世界
这是最直观的一刀,也是所有适配问题的起点。
| 存储方式 | 浏览器可读 | Node 服务端可读 | 能否承载登录凭证 |
|---|---|---|---|
| localStorage | 是 | 否 | 否 |
| sessionStorage | 是 | 否 | 否 |
| IndexedDB | 是 | 否 | 否 |
| 普通 Cookie | 是 | 需解析 req.headers.cookie |
可以 |
| HttpOnly Cookie | 否 | 需解析请求头 | 推荐 |
SSR 阶段唯一可信的凭证来源是请求头里的 Cookie。 把 token 塞进 localStorage 的那套方案,在 SSR 页面里等于不存在。
Next.js 里拿到它的方式是 cookies().get('token'),Express 里是 req.headers.cookie 手动解析------都绕不开"客户端存、服务端读不到"这条鸿沟。
水合不一致不是概率问题,是必然
服务端按 Cookie 渲染出带用户信息的页面,客户端 hydrate 时发现本地状态对不上,事情就炸了。
React 18 的报错原文是 Hydration failed because the initial UI does not match what was rendered on the server.,Vue 3 会抛 Hydration children mismatch / Hydration node mismatch。
关键在于框架的处理方式:它不会"帮你补一下",而是把这段子树丢掉重新渲染。 用户看到的就是头像昵称闪一下消失。
反向也一样成立:token 只存在 Cookie 里,服务端认为已登录,客户端某个 useEffect 里读 localStorage 判定未登录,照样炸。
服务端没有记忆
每次请求都是一次全新的渲染流程。Node 进程不持有任何用户的会话状态,它没法"监听"客户端点了退出登录。
后果很直接:
- 用户在客户端退出登录,服务端缓存里那份 HTML 还是登录态
- token 过期了,客户端已经登出,SSR 缓存还在吐带用户名的页面
- 服务端只能靠每次请求重新校验,一旦缓存介入,校验就被绕过了
线上真会出什么事
事故一:SSR 缓存串号
用户 A 的 HTML 被 CDN 或进程内缓存命中,返回给了用户 B。
B 打开页面看到的是 A 的昵称、A 的订单数,严重的时候是 A 的手机号。这类缓存通常挂在 renderToString 结果或 ISR 产物上,key 只用了路径,没带用户维度。
事故二:半登录页面
头部显示已登录,点进详情接口全是 401。用户不知道该重新登录还是刷新页面------因为页面自己也不知道。
事故三:切换账号时的反复闪烁
A 退出、B 登录,同一路由下服务端缓存和客户端状态来回打架,页面反复重渲染,控制台一路水合警告。
四条落地规矩
该不该让这个页面参与 SSR,其实是个决策问题:
强登录页别碰 SSR
个人中心、订单、会员权益这类页面,登录态高频变更,直接从根上走 CSR。
tsx
'use client'
export default function ProfilePage() {
const { data: user, isLoading } = useSWR('/api/me', fetcher)
if (isLoading) return <ProfileSkeleton />
if (!user) return <LoginPrompt />
return <Profile user={user} />
}
代价是首屏多一个骨架屏,换来的是零水合风险和零缓存风险。
校验必须在渲染之前
ts
// middleware.ts
export function middleware(req: NextRequest) {
const token = req.cookies.get('token')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', req.url))
}
try {
verifyJwt(token) // 过期会抛错
return NextResponse.next()
} catch {
const res = NextResponse.redirect(new URL('/login', req.url))
res.cookies.delete('token')
return res
}
}
export const config = { matcher: ['/profile/:path*', '/orders/:path*'] }
不要渲染完无效页面再让客户端跳转,重定向必须发生在渲染之前。
带登录态的响应一律不许缓存
http
Cache-Control: private, no-store
Vary: Cookie
private 禁止 CDN 缓存,no-store 禁止浏览器和中间层落盘,Vary: Cookie 让缓存 key 带上 Cookie 维度。
真要缓存,就只缓存不含用户信息的外壳,用户区留给客户端填。
水合之后再对齐状态
更彻底的做法是让登录态相关的 UI 在 mount 之后再渲染,SSR 输出的永远是骨架,从源头上消灭不一致:
tsx
function UserBadge() {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return <UserBadgeSkeleton />
return <UserBadgeWithData />
}
服务端和客户端首帧输出完全一致,剩下的状态同步走接口,不再依赖 DOM 层面的"猜"。
SSR 还是 CSR,按页面分
| 维度 | SSR 方案 | CSR 方案 |
|---|---|---|
| 凭证来源 | 请求头 Cookie | Cookie 或 localStorage |
| 首屏内容 | 直接带用户信息 | 骨架屏 |
| 水合风险 | 高,两套状态源 | 无 |
| 缓存串号风险 | 高 | 低 |
| 适合页面 | 营销页、内容页、公共列表 | 个人中心、订单、会员 |
SSR 负责让公共内容变快,登录态交给客户端和中间件。
追问链
Q:Cookie 明明能传到服务端,难在哪? 服务端拿到的是一次性快照,客户端持有的是一份会变的状态。快照和状态对不齐,就是水合报错。
Q:那 SSR 页面里怎么显示用户名? 中间件校验 token 后把用户信息注入 props 或服务端上下文,SSR 一次性输出;hydrate 后不再二次判断登录态。
Q:客户端怎么感知 token 过期? 401 拦截器统一处理,清 Cookie 跳登录页;关键页面再叠一层中间件,请求进来就判掉。
一句话版本:SSR 能处理登录态的渲染,但处理不了登录态的变更。
写在最后
你们项目里的登录态是怎么处理的------老老实实全站 CSR,还是 SSR 加客户端二次校验?踩过 SSR 缓存串号的坑没有,评论区聊聊。
有用的话点个赞。