一个 401,查出四个前端机制:缓存回放、隐藏页的 rAF、百分比圆角、缓动过冲
后台登录页点下去,成功提示都弹出来了,页面也跳到仪表盘,结果紧跟着又弹一句「登录状态已过期,请重新登录」。一天能撞十几次。
一开始我以为是自己把 token 有效期配短了,后来又怀疑是哪个旧标签页在捣乱。两个方向都不对。最后在前端和后端各加了一行日志,才把过程看清楚。这篇不是想复盘那个 bug,而是顺着它挖出来的四个前端机制——平时它们都藏在“看起来正常”底下。
# 一、响应头里的 token,会被缓存原样吐回来#
问题就出在下面这段代码。axios 的响应拦截器里,后端在 token 剩余有效期不足一半时,会换发一张新的,放在响应头上。前端只要看见就写回本地存储:
const refreshed = response.headers['x-refreshed-token']
if (typeof refreshed === 'string' && refreshed) {
localStorage.setItem(TOKEN_KEY, refreshed)
}
这段代码看起来一点问题都没有。新 token 是服务端刚签的,写回本地天经地义。问题在于,这个请求可能压根没到服务器。
Nest 用 Express 跑,Express 默认给 JSON 响应挂一个弱 ETag。响应里又没有 Cache-Control,浏览器就按启发式规则把它存了下来。之后同一个 URL 再请求,Chrome 会带上 if-none-match: W/"4d6-DmQqBFks3..." 去做再验证,服务端回 304,浏览器于是把存下来的那份响应头和响应体原样交给 axios。那里面夹着一次续期留下的过期 token。前端照单全收,写进 localStorage,把这次登录刚拿到的四小时 token 顶掉了。
日志把这件事钉得很死。我给每一发请求都打了它带出去的 token 的签发时间和时长(只解 JWT 的 payload 读时间字段,不把 token 本身写进日志),登录之后是这样:
发出 GET /admin/analytics/overview | 带的 token: 15:57:19 签发 / 时长 240 分钟
发出 GET /admin/analytics/posts-trend | 带的 token: 15:57:19 签发 / 时长 240 分钟
...(六个请求都一样)
续期写回 收到的: 14:15:28 签发 / 时长 30 分钟 | 本地的: 15:57:19 签发 / 时长 240 分钟 | 采纳: false
发出 GET /admin/analytics/trend | 带的 token: 15:57:19 签发 / 时长 240 分钟
中间那行是关键。/admin/analytics/trend 是首页 load() 里排在 Promise.all 之后才发出去的那一发,也就是时间上最晚的一发。在它前面,有两个响应是从缓存里拿的,把两张一小时四十分前签的过期 token 喂了回来。它一旦落地,后面所有请求读到的都是那张过期 token,服务端直接 TokenExpiredError: jwt expired,拦截器执行登出、清掉 localStorage,剩下的请求连 Authorization 头都拼不出来,变成一串 No auth token。
修的话两头都得堵。服务端把带凭证的响应排除在缓存之外:
// 带凭证的响应一律不进任何缓存:Express 默认给 JSON 挂 ETag,浏览器会拿它做 If-None-Match 再验证,
// 304 命中时旧响应头整份回放,其中就有 X-Refreshed-Token
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.headers.authorization) {
res.setHeader('Cache-Control', 'no-store')
}
next()
})
前端这头不能只信服务端。就算缓存问题解决了,代理、CDN、别的标签页都可能塞来一张过期 token,所以写回之前要过一道判断:
function readTimes(token: string): { iat?: number; exp?: number } {
const body = token.split('.')[1]
if (!body) {
return {}
}
try {
return JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/'))) as { iat?: number; exp?: number }
} catch {
return {}
}
}
// 只接受“自己还没过期、且不比本地那张旧”的一张
function isFresherToken(next: string, held: string | null): boolean {
const candidate = readTimes(next)
if (!candidate.exp || candidate.exp * 1000 <= Date.now()) {
return false
}
const heldIat = held ? readTimes(held).iat : undefined
return heldIat === undefined || (candidate.iat ?? 0) >= heldIat
}
atob 对缺失的 base64 填充是宽容的,所以 JWT 那种去掉 = 的 payload 直接解就行。这里有个细节:判断新旧用的是 iat,而不是“验一下签名对不对”,因为前端没有密钥,也不该有。前端能做的只有读时间字段,把明显不合理的挡在门外,真正确性交给服务端 401。
这类问题的通用版本是:凡是往响应头里塞凭证的设计,都要假设它会被缓存和中间层回放。写进本地存储之前先问一句,这张 token 是不是比本地那张更新。
二、requestAnimationFrame 在隐藏标签页里一次都不执行#
排查过程中我自己还埋了一颗更险的雷。登录成功后我想给按钮加一段反馈动画:收成一个光球,里面画个圆,圆转成勾。为了让宽度过渡有起点,代码里写了这么一句:
armed.value = true
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
okStep.value = step
await new Promise<void>((resolve) => window.setTimeout(resolve, 1000))
我把它放在了接口返回之后、applySession(session)(写 token)之前。逻辑上很顺:先放动画,再跳转。真正的问题是,requestAnimationFrame 的回调在隐藏页面里不执行。浏览器没有必要为一个看不见的页面排帧。后来用探针量过一次:
页面是否隐藏: true | 可见态: "hidden"
双 rAF 在 600ms 内一次都没执行(代码卡在这里)
微任务(nextTick 同层)完成
强制回流读到宽度 172px
于是那条 await 永远不会 resolve。用户在一个后台标签页点登录,接口成功返回,token 拿到了,然后 Promise 卡在那里,token 没写、页面也不跳,就这么挂着。等到很久之后他切回那个标签页,攒下来的帧回调终于跑完,代码从卡住的地方继续执行,把一张八十多分钟前签的过期 token 写进 localStorage,顺带覆盖了另一个标签页里刚登录拿到的那张。上面那一串怪现象,有一半是它贡献的。
改法有两层。第一层是顺序,副作用不能被观感挡在前面:
loading.value = false
// token 先落地再放动画:顺序反过来等于把登录态押在一段观感上
applySession(result)
await celebrate('login')
leave()
第二层是等帧本身。我要的其实只是“让钉住宽度的这一步先生效,再切到收缩状态”,这件事不一定非得等帧,nextTick 加一次强制回流就够了,而且两者都不依赖页面可见:
async function celebrate(step: 'login' | 'otp'): Promise<void> {
const el = submitRef.value?.$el
const play = !!el && !document.hidden && !prefersReducedMotion()
if (play && el) {
el.style.setProperty('--login-btn-w', `${el.offsetWidth}px`)
armed.value = true
await nextTick()
void el.offsetWidth
}
okStep.value = step
if (play) {
await new Promise<void>((resolve) => window.setTimeout(resolve, SUCCESS_MS))
}
}
nextTick 是 Vue 把新的 class 落到 DOM 的那个微任务,void el.offsetWidth 读一次布局属性会强制同步重排,浏览器就把当前这一帧的样式计算结算掉了。之后 class 再变,过渡就有了明确的起点。页面不可见时整个跳过,直接进首页。
顺带一个同类坑:await new Promise(r => requestAnimationFrame(r)) 这种“等一帧再读尺寸”的写法,在无头浏览器、后台标签页,以及某些预渲染环境里都会把你挂死。要读尺寸就同步读,要等布局就显式强制回流。
三、扁盒子上的百分比圆角,第一帧就是胶囊#
动画里按钮要从 320px 宽收到 40px 的圆球。我第一版写的是:
.login__button {
width: 100%;
transition: width 0.42s cubic-bezier(0.65, 0, 0.35, 1), border-radius 0.42s ease;
}
.login__button--ok {
width: 40px;
border-radius: 50%;
}
收拢的过程中我一直觉得中段哪里不对,但说不清,直到把半径算出来才明白问题出在百分比上。border-radius: 50% 的横向半径是宽度的 50%,纵向是高度的 50%。按钮高度由 Element Plus 的 size="large" 定死在 40px,收拢中途的宽度是 269px,也就是横向半径算出 134.5px、纵向只有 20px。浏览器不允许相邻两个圆角重叠,会把它们等比例压缩到 20px,于是从收拢的第一帧起,两端就是标准的半圆,一个胶囊。这不是过渡没做好,这是数学。
写死成高度的一半就没这个问题:
.login__button--ok {
width: 40px;
padding-inline: 0;
/* 圆角取高度的一半,末尾 40×40 正好是正圆;
写 50% 的话中途 269×40 的扁盒子会被折成胶囊形 */
border-radius: 20px;
}
改完实测(把过渡冻在不同时间点取 getComputedStyle):
起点 w=320 radius=4px 左留白 0 右留白 0
100ms w=302 radius=10.1px 左留白 8.8 右留白 8.8
150ms w=269 radius=13.9px 左留白 25.3 右留白 25.3
210ms w=180 radius=16.8px 左留白 70 右留白 70
300ms w=68 radius=19.2px 左留白 126.1 右留白 126.1
420ms w=40 radius=20px 左留白 140 右留白 140
中途一直是圆角矩形,只有最后一帧恰好是正圆。顺带说另一个坑:px 和 % 之间 CSS 是能插值的(走 calc 式的混合插值),所以把 50% 换成 20px 并不会引起跳变,很多“单位不同就不能过渡”的说法是过时的。
四、“弹一下”的过冲,靠缓动曲线是做不出来的#
首页那五张统计卡片要做依次弹入。我第一版是靠缓动曲线过冲:
animation: stat-pop 0.4s cubic-bezier(0.22, 1.2, 0.36, 1) both;
@keyframes stat-pop {
from { opacity: 0; transform: translateY(12px) scale(0.94) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
1.2 那个控制点确实会让进度超过 1。但我量出来的峰值缩放是 1.0008。原因是缓动的过冲是按属性的差值乘的:曲线的最大进度约 1.011,而 scale 的差值只有 0.06,乘出来是 0.0007。这个量级在屏幕上等于没有,肉眼看就是一段普通淡入,所谓的弹根本没发生。发现这件事的唯一办法就是去量它。别凭“我写了个回弹曲线”就认为它回弹了。
过冲得写进关键帧本身,才有控制权:
@keyframes stat-pop {
from {
opacity: 0;
transform: translateY(14px) scale(0.92);
}
55% {
opacity: 1;
}
72% {
transform: translateY(0) scale(1.03);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
55% 那一帧只写 opacity 不写 transform 是有意的:关键帧是按属性各自建列表的,某个属性会在声明了它的前后两帧之间插值,所以 transform 是从 0% 一路插到 72% 的。改完重测:
0ms opacity 0.000 scale 0.9200 translateY 14.00 宽 180.3
160ms opacity 0.960 scale 1.0191 translateY 1.38 宽 199.8
330ms opacity 1.000 scale 1.0300 translateY 0.00 宽 201.9
460ms opacity 1.000 scale 1.0000 宽 196.0
196px 的卡片弹到 201.9px 再落回去,这才叫弹。五张按 55ms 依次起步,整列 680ms 走完。顺便说这段动画的触发时机。页面外层有 v-loading 遮罩,动画如果挂在组件挂载上,会在遮罩底下播完,人等于没看见。所以我把动画的 class 绑在加载状态上:
<div class="stats" :class="loading ? 'stats--pending' : 'stats--in'">
stats--pending 只写 opacity: 0 占位,stats--in 才挂动画,并用 animation-fill-mode: both 覆盖延迟期间和结束之后。同时把 loading 的初值从 false 改成 true,不这么改的话首帧会先渲染一屏 -,再被遮罩盖住,那是另一个独立闪动。开了减少动态效果时这两个 class 都要显式让元素可见,否则动画被禁用、元素永远停在 opacity: 0。
五、怎么把动画量成数字#
上面所有结论都来自同一套探针。方法是这样:先手动把状态 class 打上去让它重新起播,然后把页面上所有动画 pause() 并塞一个 currentTime 进去,读 getComputedStyle。
btn.classList.add('login__button--armed')
void btn.offsetWidth
btn.classList.add('login__button--ok')
const rows = []
for (const t of [0, 150, 300, 420]) {
document.getAnimations().forEach((a) => { a.pause(); a.currentTime = t })
void btn.offsetWidth
const cs = getComputedStyle(btn)
const m = new DOMMatrixReadOnly(cs.transform)
rows.push({ t, scale: m.a, ty: m.f, radius: cs.borderTopLeftRadius })
}
三个坑要提前知道。第一,getComputedStyle(el).opacity 在这类上下文里拿到的是字符串,直接 .toFixed() 会 TypeError,得先 Number()。第二,缩放和位移不能从 cs.transform 字符串里正则抠,用 DOMMatrixReadOnly 取 .a 和 .f 稳。第三,await new Promise(r => requestAnimationFrame(r)) 这种等帧在隐藏页面里根本不会 resolve,也就是第二节那个 bug 的成因,所以取数靠的是 pause 加 currentTime 定格,不是真的等时间过去。
最后#
这四个机制里有三个是我自己写出来的。百分比圆角变胶囊、缓动过冲算不出来、await rAF 卡住副作用,都是当时看着动图觉得“应该没问题”就交付了的东西。真正的问题也差不多都出在这种地方:观感和数据是两件事,观感只能证明它此刻在这台机器、这个可见标签页、这个时区下是对的。