[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"site":3,"post-hou-tai-bao-cun-wen-zhang-qian-tai":59,"related-hou-tai-bao-cun-wen-zhang-qian-tai":83},{"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":66,"series":67,"seriesIndex":68,"seriesNav":69,"prev":79,"next":80},"hou-tai-bao-cun-wen-zhang-qian-tai","后台保存文章，前台一小时没变","SSR 缓存要有主动失效这条通道。密钥两侧必须同值、这个接口不能对公网开、回执要能被打脸（计数）。","2026-09-27","2026-10-19",[12],"文章页有 SSR 缓存，TTL 一小时。后台改完内容，前台最多要等一小时才更新。这在只给自己看的时候不是问题，作为编辑体验完全不能接受——我会以为改动没保存，然后重复改一次。\n\n方案是：后端保存成功之后，通知前端把这条路径的缓存清掉。\n\n链路是 server → 内部接口 → 删缓存条目。四个设计决定，每一个都有理由。\n\n一，密钥两侧必须同值，且由同一处注入。\n\n```yaml\nserver:\n  environment:\n    REVALIDATE_KEY: ${REVALIDATE_KEY:-}\nweb:\n  environment:\n    NUXT_REVALIDATE_KEY: ${REVALIDATE_KEY:-}\n```\n\ncompose 里一个变量喂两边，避免手抄出错。这个值没配时前端接口直接返回 501（\"未配置\"），而不是假装成功——整套机制就该明确关掉，让页面退回\"一小时自然过期\"，保存功能本身不受影响。\n\n比较密钥用先哈希再比：\n\n```ts\nconst digest = (v: string) => createHash('sha256').update(v).digest()\nif (!timingSafeEqual(digest(getHeader(event, 'x-revalidate-key') ?? ''), digest(expected))) {\n  throw createError({ statusCode: 401 })\n}\n```\n\n直接比字符串会按字符早退，理论上能靠耗时猜出密钥前缀。这段代码在 compose 内网，攻击面本来就小，但写法上没什么成本，就写了。\n\n二，这个接口不对公网开。它虽有密钥保护，但多一个入口就多一分风险，所以在网关里显式 404：\n\n```\nlocation = \u002Fx-revalidate { return 404; }\n```\n\n后端调的是 compose 内网地址 http:\u002F\u002Fweb:3000，不经过网关。这条链路的正确性我是用 curl 分别验的：从公网打要 404，从容器内打要 200。\n\n三，通知是\"发完就算\"。保存文章不该因为清缓存失败而失败：\n\n```ts\nawait fetch(`${base}\u002Fx-revalidate`, {\n  method: 'POST',\n  headers: { 'x-revalidate-key': key, 'content-type': 'application\u002Fjson' },\n  body: JSON.stringify({ paths }),\n  signal: AbortSignal.timeout(3000),\n}).catch((e) => this.logger.warn(`缓存清理未送达：${e.message}`))\n```\n\n3 秒超时，异常只写日志、不抛出。没有超时的话，前端容器卡住会把保存请求一起拖住，那就本末倒置了。\n\n四，回执要能被打脸。前端返回 { requested, removed }，后端把这一行打进日志：\n\n```\nLOG [RevalidateService] 缓存清理回执：{\"requested\":1,\"removed\":2}\n```\n\n有这两个数，\"我没生效\"和\"本来就没有缓存可清\"是两件能一眼分开的事。第一次线上验证时我看到的是 removed: 0，因为那篇文章刚建、前台还没人访问过，压根没有缓存条目——这是正确结果。为了确认，我先访问一次文章页（这时 last-modified 会固定下来），再保存一次，removed 变成 2。没有这个字段的话，我会以为链路坏了，然后开始改一些其实没坏的东西。\n\nremoved 是 2 而不是 1 也解释得通：缓存键是\"去符号的路径前缀 + 整串 URL 的哈希\"，同一篇文章的不同 URL 变体（带查询参数、HEAD 和 GET 不同时刻）会各存一条，我按前缀匹配所以一次清掉多条。清多了只是多渲染一次，比赌精确格式然后静默不生效可靠。\n\n至于\"删哪条\"这件事本身，我第一版是全错的，因为存储层会把键里的斜杠换成冒号——那个坑单独写一篇。\n\n触发点也比想象中多。不只是\"编辑保存\"要清：新建、改名（旧 slug 和新 slug 都要清）、删除、批量删除，还有两个容易被忽略的——解散系列和重命名系列，因为系列页也在这条缓存规则下。我最后是在这六个地方都埋了同一个调用，而不是只在保存那里。","Nuxt 与服务端渲染",8,[70,74,78],{"slug":71,"title":72,"seriesIndex":73},"rss-he-sitemap-shi-zen-me-bei-pa","RSS 和 sitemap 是怎么被爬虫看到的",6,{"slug":75,"title":76,"seriesIndex":77},"301-302-307-wo-yi-kai-shi-fen","301、302、307 我一开始分不清",7,{"slug":60,"title":61,"seriesIndex":68},{"slug":75,"title":76},{"slug":81,"title":82},"jian-li-de-xie-gang-hui-bian-cheng","键里的斜杠会变成冒号：清缓存清了个空",[84,91,95,99,103],{"slug":85,"title":86,"excerpt":87,"date":88,"tags":89},"dian-xiang-mu-ye-mian-jiu-si-yi","点「项目」页面就死：一次客户端无限重定向","为了保留旧地址大小写加的一条跳转规则，被注册成了客户端路由；路由匹配不区分大小写，于是它自己跳自己。","2026-10-02",[12,90],"浏览器",{"slug":81,"title":82,"excerpt":92,"date":93,"tags":94},"按源码里的分组名去匹配存储键，结果一条都没删掉，接口还回 200。返回一个可被证伪的数字，这类问题当场就能发现。","2026-09-30",[12,15],{"slug":75,"title":76,"excerpt":96,"date":97,"tags":98},"永久和临时不只是名字区别，搜索端权重转移不一样。框架给的默认值不一定是你要的那个，得显式写。","2026-09-18",[12],{"slug":71,"title":72,"excerpt":100,"date":101,"tags":102},"生成时机、地址、robots 里要不要写。sitemap 带 lastmod 之后，改动被抓取的速度明显不一样。","2026-04-28",[12],{"slug":104,"title":105,"excerpt":106,"date":107,"tags":108},"gang-deng-lu-jiu-guo-qi-yi-ci","一个 401，查出四个前端机制：缓存回放、隐藏页的 rAF、百分比圆角、缓动过冲","响应头里的 token 被 304 原样回放，把刚签发的新 token 顶掉，表现成刚登录就过期。顺带记三个前端细节：隐藏标签页里 rAF 不执行、扁盒子的 50% 圆角是胶囊、缓动曲线的过冲只有 0.0008。","2026-10-13",[109,110,111,90],"CSS","JavaScript","Vue"]