一个 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 卡住副作用,都是当时看着动图觉得“应该没问题”就交付了的东西。真正的问题也差不多都出在这种地方:观感和数据是两件事,观感只能证明它此刻在这台机器、这个可见标签页、这个时区下是对的。

相关阅读