[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"site":3,"post-gang-deng-lu-jiu-guo-qi-yi-ci":59,"related-gang-deng-lu-jiu-guo-qi-yi-ci":93},{"name":4,"url":5,"description":6,"bio":7,"avatar":8,"skills":9,"tools":16,"social":34,"hero":47,"heatmap":52,"icp":55,"police":56,"policeIcon":57,"shortName":58},"时过境迁技术笔记","https:\u002F\u002Fhsgjq.top","一个记录前端与 Node 实践的个人博客，内容围绕 Vue、Nuxt、NestJS 与工程化展开，附源码级的原理笔记。","一名热爱技术的全栈开发者，专注 TypeScript 与 Web 工程化，习惯把踩过的坑和思考记录在这里。","\u002Fuploads\u002Fc485c101-179e-48db-96d1-238693784c38.jpg",[10,11,12,13,14,15],"TypeScript","Vue 3","Nuxt","NestJS","Node.js","工程化",[17,23,28],{"items":18,"title":22},[19,20,21],"Cursor","Git","Chrome DevTools","开发工具",{"items":24,"title":27},[25,26],"Vite","npm","构建工具",{"items":29,"title":33},[30,31,32],"Docker","CI\u002FCD(GitHub Actions)","Vercel","部署运维",[35,39,43],{"href":36,"icon":37,"label":38},"https:\u002F\u002Fgithub.com\u002Fhsgjq","github","GitHub",{"href":40,"icon":41,"label":42},"2971861643@qq.com","mail","邮箱",{"href":44,"icon":45,"label":46},"\u002Frss.xml","rss","RSS",{"lines":48,"subtitle":51},[49,50],"人生若只如初见，","何事秋风悲画扇。","《木兰花·拟古决绝词柬友》",{"enabled":53,"platform":37,"username":54},true,"hsgjq","桂ICP备2024046985号","桂公网安备45011002000103号","\u002Fuploads\u002F72c67ef3-bc86-4b64-b0df-0aba71065050.png","时过境迁",{"slug":60,"title":61,"excerpt":62,"date":63,"updatedAt":64,"tags":65,"content":70,"series":71,"seriesIndex":72,"seriesNav":73,"prev":91,"next":92},"gang-deng-lu-jiu-guo-qi-yi-ci","一个 401，查出四个前端机制：缓存回放、隐藏页的 rAF、百分比圆角、缓动过冲","响应头里的 token 被 304 原样回放，把刚签发的新 token 顶掉，表现成刚登录就过期。顺带记三个前端细节：隐藏标签页里 rAF 不执行、扁盒子的 50% 圆角是胶囊、缓动曲线的过冲只有 0.0008。","2026-10-13","2026-10-19",[66,67,68,69],"CSS","JavaScript","Vue","浏览器","&emsp;&emsp;后台登录页点下去，成功提示都弹出来了，页面也跳到仪表盘，结果紧跟着又弹一句「登录状态已过期，请重新登录」。一天能撞十几次。\n&emsp;&emsp;一开始我以为是自己把 token 有效期配短了，后来又怀疑是哪个旧标签页在捣乱。两个方向都不对。最后在前端和后端各加了一行日志，才把过程看清楚。这篇不是想复盘那个 bug，而是顺着它挖出来的四个前端机制——平时它们都藏在“看起来正常”底下。\n## # 一、响应头里的 token，会被缓存原样吐回来\n&emsp;&emsp;问题就出在下面这段代码。axios 的响应拦截器里，后端在 token 剩余有效期不足一半时，会换发一张新的，放在响应头上。前端只要看见就写回本地存储：\n\n```ts\nconst refreshed = response.headers['x-refreshed-token']\nif (typeof refreshed === 'string' && refreshed) {\n  localStorage.setItem(TOKEN_KEY, refreshed)\n}\n```\n&emsp;&emsp;这段代码看起来一点问题都没有。新 token 是服务端刚签的，写回本地天经地义。问题在于，这个请求可能压根没到服务器。\n&emsp;&emsp;Nest 用 Express 跑，Express 默认给 JSON 响应挂一个弱 ETag。响应里又没有 Cache-Control，浏览器就按启发式规则把它存了下来。之后同一个 URL 再请求，Chrome 会带上 if-none-match: W\u002F\"4d6-DmQqBFks3...\" 去做再验证，服务端回 304，浏览器于是把存下来的那份响应头和响应体原样交给 axios。那里面夹着一次续期留下的过期 token。前端照单全收，写进 localStorage，把这次登录刚拿到的四小时 token 顶掉了。\n&emsp;&emsp;日志把这件事钉得很死。我给每一发请求都打了它带出去的 token 的签发时间和时长（只解 JWT 的 payload 读时间字段，不把 token 本身写进日志），登录之后是这样：\n\n```txt\n发出 GET \u002Fadmin\u002Fanalytics\u002Foverview    | 带的 token: 15:57:19 签发 \u002F 时长 240 分钟\n发出 GET \u002Fadmin\u002Fanalytics\u002Fposts-trend | 带的 token: 15:57:19 签发 \u002F 时长 240 分钟\n...（六个请求都一样）\n续期写回 收到的: 14:15:28 签发 \u002F 时长 30 分钟 | 本地的: 15:57:19 签发 \u002F 时长 240 分钟 | 采纳: false\n发出 GET \u002Fadmin\u002Fanalytics\u002Ftrend       | 带的 token: 15:57:19 签发 \u002F 时长 240 分钟\n```\n&emsp;&emsp;中间那行是关键。\u002Fadmin\u002Fanalytics\u002Ftrend 是首页 load() 里排在 Promise.all 之后才发出去的那一发，也就是时间上最晚的一发。在它前面，有两个响应是从缓存里拿的，把两张一小时四十分前签的过期 token 喂了回来。它一旦落地，后面所有请求读到的都是那张过期 token，服务端直接 TokenExpiredError: jwt expired，拦截器执行登出、清掉 localStorage，剩下的请求连 Authorization 头都拼不出来，变成一串 No auth token。\n&emsp;&emsp;修的话两头都得堵。服务端把带凭证的响应排除在缓存之外：\n\n```ts\n\u002F\u002F 带凭证的响应一律不进任何缓存：Express 默认给 JSON 挂 ETag，浏览器会拿它做 If-None-Match 再验证，\n\u002F\u002F 304 命中时旧响应头整份回放，其中就有 X-Refreshed-Token\napp.use((req: Request, res: Response, next: NextFunction) => {\n  if (req.headers.authorization) {\n    res.setHeader('Cache-Control', 'no-store')\n  }\n  next()\n})\n```\n&emsp;&emsp;前端这头不能只信服务端。就算缓存问题解决了，代理、CDN、别的标签页都可能塞来一张过期 token，所以写回之前要过一道判断：\n\n```ts\nfunction readTimes(token: string): { iat?: number; exp?: number } {\n  const body = token.split('.')[1]\n  if (!body) {\n    return {}\n  }\n  try {\n    return JSON.parse(atob(body.replace(\u002F-\u002Fg, '+').replace(\u002F_\u002Fg, '\u002F'))) as { iat?: number; exp?: number }\n  } catch {\n    return {}\n  }\n}\n\n\u002F\u002F 只接受“自己还没过期、且不比本地那张旧”的一张\nfunction isFresherToken(next: string, held: string | null): boolean {\n  const candidate = readTimes(next)\n  if (!candidate.exp || candidate.exp * 1000 \u003C= Date.now()) {\n    return false\n  }\n  const heldIat = held ? readTimes(held).iat : undefined\n  return heldIat === undefined || (candidate.iat ?? 0) >= heldIat\n}\n```\n&emsp;&emsp;atob 对缺失的 base64 填充是宽容的，所以 JWT 那种去掉 = 的 payload 直接解就行。这里有个细节：判断新旧用的是 iat，而不是“验一下签名对不对”，因为前端没有密钥，也不该有。前端能做的只有读时间字段，把明显不合理的挡在门外，真正确性交给服务端 401。\n&emsp;&emsp;这类问题的通用版本是：凡是往响应头里塞凭证的设计，都要假设它会被缓存和中间层回放。写进本地存储之前先问一句，这张 token 是不是比本地那张更新。\n## 二、requestAnimationFrame 在隐藏标签页里一次都不执行\n&emsp;&emsp;排查过程中我自己还埋了一颗更险的雷。登录成功后我想给按钮加一段反馈动画：收成一个光球，里面画个圆，圆转成勾。为了让宽度过渡有起点，代码里写了这么一句：\n\n```ts\narmed.value = true\nawait new Promise\u003Cvoid>((resolve) => {\n  requestAnimationFrame(() => requestAnimationFrame(() => resolve()))\n})\nokStep.value = step\nawait new Promise\u003Cvoid>((resolve) => window.setTimeout(resolve, 1000))\n```\n&emsp;&emsp;我把它放在了接口返回之后、applySession(session)（写 token）之前。逻辑上很顺：先放动画，再跳转。真正的问题是，requestAnimationFrame 的回调在隐藏页面里不执行。浏览器没有必要为一个看不见的页面排帧。后来用探针量过一次：\n\n```text\n页面是否隐藏: true | 可见态: \"hidden\"\n双 rAF 在 600ms 内一次都没执行（代码卡在这里）\n微任务（nextTick 同层）完成\n强制回流读到宽度 172px\n```\n&emsp;&emsp;于是那条 await 永远不会 resolve。用户在一个后台标签页点登录，接口成功返回，token 拿到了，然后 Promise 卡在那里，token 没写、页面也不跳，就这么挂着。等到很久之后他切回那个标签页，攒下来的帧回调终于跑完，代码从卡住的地方继续执行，把一张八十多分钟前签的过期 token 写进 localStorage，顺带覆盖了另一个标签页里刚登录拿到的那张。上面那一串怪现象，有一半是它贡献的。\n&emsp;&emsp;改法有两层。第一层是顺序，副作用不能被观感挡在前面：\n\n```ts\nloading.value = false\n\u002F\u002F token 先落地再放动画：顺序反过来等于把登录态押在一段观感上\napplySession(result)\nawait celebrate('login')\nleave()\n```\n&emsp;&emsp;第二层是等帧本身。我要的其实只是“让钉住宽度的这一步先生效，再切到收缩状态”，这件事不一定非得等帧，nextTick 加一次强制回流就够了，而且两者都不依赖页面可见：\n\n```ts\nasync function celebrate(step: 'login' | 'otp'): Promise\u003Cvoid> {\n  const el = submitRef.value?.$el\n  const play = !!el && !document.hidden && !prefersReducedMotion()\n  if (play && el) {\n    el.style.setProperty('--login-btn-w', `${el.offsetWidth}px`)\n    armed.value = true\n    await nextTick()\n    void el.offsetWidth\n  }\n  okStep.value = step\n  if (play) {\n    await new Promise\u003Cvoid>((resolve) => window.setTimeout(resolve, SUCCESS_MS))\n  }\n}\n```\n&emsp;&emsp;nextTick 是 Vue 把新的 class 落到 DOM 的那个微任务，void el.offsetWidth 读一次布局属性会强制同步重排，浏览器就把当前这一帧的样式计算结算掉了。之后 class 再变，过渡就有了明确的起点。页面不可见时整个跳过，直接进首页。\n&emsp;&emsp;顺带一个同类坑：await new Promise(r => requestAnimationFrame(r)) 这种“等一帧再读尺寸”的写法，在无头浏览器、后台标签页，以及某些预渲染环境里都会把你挂死。要读尺寸就同步读，要等布局就显式强制回流。\n## 三、扁盒子上的百分比圆角，第一帧就是胶囊\n&emsp;&emsp;动画里按钮要从 320px 宽收到 40px 的圆球。我第一版写的是：\n\n```css\n.login__button {\n  width: 100%;\n  transition: width 0.42s cubic-bezier(0.65, 0, 0.35, 1), border-radius 0.42s ease;\n}\n.login__button--ok {\n  width: 40px;\n  border-radius: 50%;\n}\n```\n&emsp;&emsp;收拢的过程中我一直觉得中段哪里不对，但说不清，直到把半径算出来才明白问题出在百分比上。border-radius: 50% 的横向半径是宽度的 50%，纵向是高度的 50%。按钮高度由 Element Plus 的 size=\"large\" 定死在 40px，收拢中途的宽度是 269px，也就是横向半径算出 134.5px、纵向只有 20px。浏览器不允许相邻两个圆角重叠，会把它们等比例压缩到 20px，于是从收拢的第一帧起，两端就是标准的半圆，一个胶囊。这不是过渡没做好，这是数学。\n&emsp;&emsp;写死成高度的一半就没这个问题：\n\n```css\n.login__button--ok {\n  width: 40px;\n  padding-inline: 0;\n  \u002F* 圆角取高度的一半，末尾 40×40 正好是正圆；\n     写 50% 的话中途 269×40 的扁盒子会被折成胶囊形 *\u002F\n  border-radius: 20px;\n}\n```\n&emsp;&emsp;改完实测（把过渡冻在不同时间点取 getComputedStyle）：\n\n```text\n起点   w=320  radius=4px    左留白 0      右留白 0\n100ms  w=302  radius=10.1px 左留白 8.8    右留白 8.8\n150ms  w=269  radius=13.9px 左留白 25.3   右留白 25.3\n210ms  w=180  radius=16.8px 左留白 70     右留白 70\n300ms  w=68   radius=19.2px 左留白 126.1  右留白 126.1\n420ms  w=40   radius=20px   左留白 140    右留白 140\n```\n&emsp;&emsp;中途一直是圆角矩形，只有最后一帧恰好是正圆。顺带说另一个坑：px 和 % 之间 CSS 是能插值的（走 calc 式的混合插值），所以把 50% 换成 20px 并不会引起跳变，很多“单位不同就不能过渡”的说法是过时的。\n## 四、“弹一下”的过冲，靠缓动曲线是做不出来的\n&emsp;&emsp;首页那五张统计卡片要做依次弹入。我第一版是靠缓动曲线过冲：\n\n```css\nanimation: stat-pop 0.4s cubic-bezier(0.22, 1.2, 0.36, 1) both;\n\n@keyframes stat-pop {\n  from { opacity: 0; transform: translateY(12px) scale(0.94) }\n  to   { opacity: 1; transform: translateY(0) scale(1) }\n}\n```\n&emsp;&emsp;1.2 那个控制点确实会让进度超过 1。但我量出来的峰值缩放是 1.0008。原因是缓动的过冲是按属性的差值乘的：曲线的最大进度约 1.011，而 scale 的差值只有 0.06，乘出来是 0.0007。这个量级在屏幕上等于没有，肉眼看就是一段普通淡入，所谓的弹根本没发生。发现这件事的唯一办法就是去量它。别凭“我写了个回弹曲线”就认为它回弹了。\n&emsp;&emsp;过冲得写进关键帧本身，才有控制权：\n\n```css\n@keyframes stat-pop {\n  from {\n    opacity: 0;\n    transform: translateY(14px) scale(0.92);\n  }\n  55% {\n    opacity: 1;\n  }\n  72% {\n    transform: translateY(0) scale(1.03);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0) scale(1);\n  }\n}\n```\n&emsp;&emsp;55% 那一帧只写 opacity 不写 transform 是有意的：关键帧是按属性各自建列表的，某个属性会在声明了它的前后两帧之间插值，所以 transform 是从 0% 一路插到 72% 的。改完重测：\n\n```text\n0ms    opacity 0.000  scale 0.9200  translateY 14.00  宽 180.3\n160ms  opacity 0.960  scale 1.0191  translateY 1.38   宽 199.8\n330ms  opacity 1.000  scale 1.0300  translateY 0.00   宽 201.9\n460ms  opacity 1.000  scale 1.0000                      宽 196.0\n```\n&emsp;&emsp;196px 的卡片弹到 201.9px 再落回去，这才叫弹。五张按 55ms 依次起步，整列 680ms 走完。顺便说这段动画的触发时机。页面外层有 v-loading 遮罩，动画如果挂在组件挂载上，会在遮罩底下播完，人等于没看见。所以我把动画的 class 绑在加载状态上：\n\n```vue\n\u003Cdiv class=\"stats\" :class=\"loading ? 'stats--pending' : 'stats--in'\">\n```\n&emsp;&emsp;stats--pending 只写 opacity: 0 占位，stats--in 才挂动画，并用 animation-fill-mode: both 覆盖延迟期间和结束之后。同时把 loading 的初值从 false 改成 true，不这么改的话首帧会先渲染一屏 -，再被遮罩盖住，那是另一个独立闪动。开了减少动态效果时这两个 class 都要显式让元素可见，否则动画被禁用、元素永远停在 opacity: 0。\n## 五、怎么把动画量成数字\n&emsp;&emsp;上面所有结论都来自同一套探针。方法是这样：先手动把状态 class 打上去让它重新起播，然后把页面上所有动画 pause() 并塞一个 currentTime 进去，读 getComputedStyle。\n\n```js\nbtn.classList.add('login__button--armed')\nvoid btn.offsetWidth\nbtn.classList.add('login__button--ok')\nconst rows = []\nfor (const t of [0, 150, 300, 420]) {\n  document.getAnimations().forEach((a) => { a.pause(); a.currentTime = t })\n  void btn.offsetWidth\n  const cs = getComputedStyle(btn)\n  const m = new DOMMatrixReadOnly(cs.transform)\n  rows.push({ t, scale: m.a, ty: m.f, radius: cs.borderTopLeftRadius })\n}\n```\n&emsp;&emsp;三个坑要提前知道。第一，getComputedStyle(el).opacity 在这类上下文里拿到的是字符串，直接 .toFixed() 会 TypeError，得先 Number()。第二，缩放和位移不能从 cs.transform 字符串里正则抠，用 DOMMatrixReadOnly 取 .a 和 .f 稳。第三，await new Promise(r => requestAnimationFrame(r)) 这种等帧在隐藏页面里根本不会 resolve，也就是第二节那个 bug 的成因，所以取数靠的是 pause 加 currentTime 定格，不是真的等时间过去。\n## 最后\n&emsp;&emsp;这四个机制里有三个是我自己写出来的。百分比圆角变胶囊、缓动过冲算不出来、await rAF 卡住副作用，都是当时看着动图觉得“应该没问题”就交付了的东西。真正的问题也差不多都出在这种地方：观感和数据是两件事，观感只能证明它此刻在这台机器、这个可见标签页、这个时区下是对的。","线上事故记录",5,[74,78,82,86,90],{"slug":75,"title":76,"seriesIndex":77},"1-9g-nei-cun-de-fu-wu-qi","1.9G 内存的服务器上，nuxt build 在第 36 秒被杀",1,{"slug":79,"title":80,"seriesIndex":81},"wo-ba-compose-de-override-xie-cuo-zhan","我把 compose 的 override 写错，站点停了二十分钟",2,{"slug":83,"title":84,"seriesIndex":85},"jian-li-de-xie-gang-hui-bian-cheng","键里的斜杠会变成冒号：清缓存清了个空",3,{"slug":87,"title":88,"seriesIndex":89},"dian-xiang-mu-ye-mian-jiu-si-yi","点「项目」页面就死：一次客户端无限重定向",4,{"slug":60,"title":61,"seriesIndex":72},{"slug":87,"title":88},null,[94,98,104,110,116],{"slug":87,"title":88,"excerpt":95,"date":96,"tags":97},"为了保留旧地址大小写加的一条跳转规则，被注册成了客户端路由；路由匹配不区分大小写，于是它自己跳自己。","2026-10-02",[12,69],{"slug":99,"title":100,"excerpt":101,"date":102,"tags":103},"dai-ma-kuai-li-de-san-ge-fan","代码块里的三个反引号把正文截断了","解析 Markdown 时嵌套代码块要按围栏长度配对。也解释了为什么\"取正文第一张图\"要先把代码块整段剥掉。","2026-07-28",[67],{"slug":105,"title":106,"excerpt":107,"date":108,"tags":109},"shen-se-mo-shi-yan-se-bian-liang","深色模式：颜色变量该放哪一层","语义变量而不是具体色值，切换只改一组自定义属性。闪烁问题出在样式加载时机，不是 JS。","2026-06-16",[66],{"slug":111,"title":112,"excerpt":113,"date":114,"tags":115},"fen-ye-zu-jian-zai-shou-ji-shang","分页组件在手机上顶掉一行","flex 里有个\"不允许压缩\"的兄弟项，就会把列表挤出去。min-width: 0 这个开关单独说一次。","2026-06-02",[66],{"slug":117,"title":118,"excerpt":119,"date":120,"tags":121},"biao-ge-cha-cao-li-de-row-shi","表格插槽里的 row 是宽类型：TS7053","组件库插槽给的 row 是 Record\u003Cstring, any> 之类，取字段时报错的是索引那一行。枚举映射写成按字段值的函数最省事。","2026-04-10",[10,68]]