<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>程序员奇趣博客</title>
    <link>https://kairos.cn.mt</link>
    <description>基于 Astro 的程序员个人博客与知识工作台</description>
    <language>zh-CN</language>
    <lastBuildDate>Thu, 03 Sep 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://kairos.cn.mt/rss.xml" rel="self" type="application/rss+xml" />
        <item>
          <title>Astro 预渲染时内容集合为空提示</title>
          <link>https://kairos.cn.mt/pitfalls/astro-prerender-content-warning/</link>
          <guid>https://kairos.cn.mt/pitfalls/astro-prerender-content-warning/</guid>
          <description>在 SSR 项目中预渲染内容详情页时集合提示为空的排查——loader 显式配置、schema 校验与构建期防护的完整记录。</description>
          <content:encoded>现象 构建可以完成，但预渲染阶段提示内容集合不存在或为空，文章详情页的 getStaticPaths 拿到空数组，产出 0 个页面——构建是&quot;绿&quot;的，内容却静默消失。 根因 Astro 7 的 Content Collections 不再自动推断目录结构，每个集合必须 显式声明 loader 。两个常见的静默失败点： 1. glob 的 base 路径与实际内容目录不一致（比如内容在 src/content/posts/ 而 base 指向了旧的 src/content/blog/ ），loader 不会报错，只会给出空集合； 2. 文件的 frontmatter 不符合该集合的 schema（如日期字段类型不对、必填缺失），条目被整体丢弃。 修复：显式 loader 对齐真实目录 本项目 src/content.config.ts 中每个集合都显式声明 glob loader，且 base 与内容目录一一对应： const posts = defineCollection({ loader: glob({ pattern: &apos; / .{md,mdx}&apos;, base: &apos;./src/content/posts&apos; }), schema: postSchema, }); const pitfalls = defineCollection({ loader: glob({ pattern: &apos; / .{md,mdx}&apos;, base: &apos;./src/content/pitfalls&apos; }), schema: postSchema.extend({ severity: z.enum([&apos;low&apos;, &apos;medium&apos;, &apos;high&apos;]).default(&apos;medium&apos;), }), }); 防复发：构建期三道闸 空集合最怕&quot;静默&quot;，所以把校验前移： schema 层 ：日期用 z.coerce.date() 、必填 title/description/publishedAt/tags/category ，写错字段类型在 dev 就会看到 400 行错误而不是空结果； 内容检查脚本 （ scripts/check-content.mjs ，挂进 prebuild ）：统计各集合条目数、slug 冲突、 relatedPosts 引用完整性，坏数据直接让构建失败； 详情渲染批测 ：对 sitemap 中全部文章/踩坑/项目/日志详情页断言标题与正文长度——集合又空了，批测立刻可见地失败。 验证方式 最快的自查两连： 1. 集合是否真的读到了条目 npx astro build 2&gt;&amp;1 | grep -oE &quot;prerendered [0-9]+ pages&quot; 2. 单个集合计数与内容文件数是否一致 ls src/content/posts/ .mdx | wc -l 两个数字对不上（或页面数骤降为文章数+0），优先查 base 路径与某个文件的 frontmatter 是否把整个集合带崩。</content:encoded>
          <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>预渲染</category>
          <category>Content Collections</category>
        </item>
        <item>
          <title>内容治理与发布门禁体系</title>
          <link>https://kairos.cn.mt/projects/content-governance-gates/</link>
          <guid>https://kairos.cn.mt/projects/content-governance-gates/</guid>
          <description>用 schema 校验、构建期生成、草稿隔离断言与运行时检查组成的四层门禁，保证内容站在&quot;随便写&quot;和&quot;放心发&quot;之间两全。</description>
          <content:encoded>它解决什么问题 内容站的腐化通常不来自代码，而来自数据：缺字段的 frontmatter、改 slug 后留下的死引用、草稿混进公开索引、发布后页面出现内部术语。这套体系把这些问题挡在&quot;写作的下一分钟&quot;，而不是&quot;读者的下一次点击&quot;。 四层结构 第 1 层 结构校验 zod schema（内容集合定义）：类型、枚举、默认值、日期 第 2 层 语义校验 scripts/check-content.mjs：引用完整性、slug 唯一、 日期逻辑、字段覆盖率、各集合统计 → content-health.json 第 3 层 生成隔离 prebuild：search:index / graph:data 只读非草稿； 公开产物（索引/图谱/RSS/sitemap/dist）逐一断言无草稿泄漏 第 4 层 运行时门禁 release-gate 脚本对真实响应检查： 全路由 200、站内死链、公开文案禁用词、状态页语义正确 关键决策： 校验先于生成 ：prebuild 顺序固定 content:check → search:index → graph:data → build ，坏数据在最上游失败，下游永远消费干净输入。 草稿隔离用&quot;产物断言&quot;而不是&quot;信任生成器&quot; ：生成器已过滤草稿，但门禁仍独立扫描每个公开产物是否含任一草稿 ID——双保险，因为这里的失误不可逆（搜索引擎缓存）。 内容健康可视化 ：校验结果落 JSON，后台渲染成清单（缺什么字段、差多少内容达标），作者看到的是可执行条目而非日志墙。 人工判断保留给人 ：门禁只查可机械验证的规则（存在性、格式、泄漏、死链）；文案质量、真实性审核是独立人工环节，草稿必须经它才能转正。 运行方式 CI 与本地同一入口 npm ci &amp;&amp; npm run check &amp;&amp; npm run lint &amp;&amp; npm run test \ &amp;&amp; npm run content:check &amp;&amp; npm run build 发布前对运行中的站点： npm run test:release-gate:runtime 历史上这套门禁真实拦下过：新文章标题命中公开页禁用词（改文章，不降规则）、生成索引残留已删路由、 define:vars 脚本在客户端路由下崩溃（由此新增全站交互回归）。 当前状态 四层全部在跑：CI 工作流含全部质量命令；运行时检查接入发布流程；内容后台展示健康度与草稿审核清单。已知边界：禁用词扫描跳过代码块（正文内技术词合法），像素级视觉验收仍需人工（见路线图遗留记录）。 下一步 把&quot;订阅频道承诺 ⊆ Feed 覆盖&quot;这类跨产物一致性规则补进第 3 层；为运行时检查增加性能断言（复用 LHCI 产物而非另起浏览器会话）。</content:encoded>
          <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>CI</category>
          <category>内容系统</category>
          <category>质量保障</category>
        </item>
        <item>
          <title>知识星图浏览器</title>
          <link>https://kairos.cn.mt/projects/knowledge-constellation-explorer/</link>
          <guid>https://kairos.cn.mt/projects/knowledge-constellation-explorer/</guid>
          <description>把文章内容编译成关系图并在 Canvas 上可交互探索：构建期生成、力导向渲染、路径查询与无鼠标等价操作的系统说明。</description>
          <content:encoded>它解决什么问题 &quot;这个站写过哪些东西、彼此怎么关联&quot;是传统博客回答不了的问题。知识星图把文章、项目、踩坑、笔记、标签、系列编译成一张可探索的关系网——读者可以从任何一颗星出发，顺着引力找到相关的一切。 数据链路 内容集合（frontmatter 关系字段） → scripts/generate-graph-data.mjs（构建期，仅非草稿） → public/graph-data.json（当前构建：172 节点 / 253 边） → /graph SSR 页面读取（磁盘直读，见文章《SSR 页面为什么不该向自己发 HTTP 请求》） 边类型：文章↔标签、文章↔系列、项目↔相关文章、踩坑↔标签——全部来自已校验的 frontmatter 引用（引用完整性由内容门禁保证，图里不会出现悬空边）。 渲染层的四个工程决策 1. 力导向自研而非引库 ：确定性初始布局（同一输入同一画面，可回归）、O(n²) 斥力 + 弹簧 + 向心力、alpha 衰减自动停帧——当前规模几百毫秒收敛，浏览器里算得起，无需 Web Worker。 2. 性能自律 ： requestAnimationFrame 只在有能量时运行； IntersectionObserver 离开视口即停帧，后台标签页暂停；拖拽/缩放/平移/双击复位全在 Canvas 坐标系内完成，不触发布局。 3. 降级是设计的一部分 ： prefers-reduced-motion 下同步结算 240 次迭代输出静态星图——信息量不变，只是不&quot;飘&quot;；移动端粒子/光效降级的同一原则在这里复用。 4. 无鼠标等价操作 ：星图是可发现性增强，不是唯一入口——节点列表（可过滤、键盘可达、Enter 聚焦星图对应节点）、类型筛选、路径查询（起点/终点下拉 + 本地 BFS 最短路径，含&quot;无可达路径&quot;空态）全部可纯键盘完成。 交互模型 点击节点 → 邻域高亮（相连边标记 source/target、非相关弱化）+ 邻接面板列出关系；类型筛选与关键词过滤联动重排；BFS 路径提示展示&quot;节点 A → B → C&quot;与跳数。数据在页面内联一份公开 payload（构建期直读生成物），过滤/路径全部内存态——不持久化、不上传任何输入。 当前状态与边界 星图模式已成为探索地图默认视图（节点阈值达标后从二级入口升级）。边界：图规模到千级节点时 O(n²) 斥力需要换成 Barnes-Hut 或分块布局，页面结构已为此预留（渲染器与数据层解耦，输入只是那份 JSON）。 下一步 方向筛选（只看&quot;文章→标签&quot;类边）、跨类型路径高亮动画（尊重 reduced-motion）、以及基于入度的&quot;枢纽文章&quot;榜单接入首页。</content:encoded>
          <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
          <category>Canvas</category>
          <category>数据可视化</category>
          <category>图算法</category>
          <category>Astro</category>
        </item>
        <item>
          <title>周报订阅与投递流水线</title>
          <link>https://kairos.cn.mt/projects/weekly-digest-pipeline/</link>
          <guid>https://kairos.cn.mt/projects/weekly-digest-pipeline/</guid>
          <description>从邮箱确认、MySQL 持久化到可插拔邮件 provider 的订阅系统：状态机、幂等、重试和投递回执的完整链路说明。</description>
          <content:encoded>它解决什么问题 内容站希望读者&quot;按主题订阅更新&quot;，但订阅是一个容易做错的功能：要防误填他人邮箱、要防刷、要在数据库故障时不丢状态、要保证确认和退订链接重复点击不出错。这套流水线覆盖从表单到回执的完整链路。 架构与关键决策 表单 → POST /api/subscribe（校验+限流） → subscription-store（pending 记录，MySQL 权威存储） → 确认邮件（token 链接）→ /api/subscribe/confirm → active / 退订 → unsubscribed 后台：周报预览（/admin/weekly-digest）→ POST /api/admin/weekly-digest 投递 → email provider adapter（console | http-api）→ 投递记录 + 回执 双阶段确认（double opt-in） ：提交只产生 pending ，邮箱点击确认后才 active ——防止把别人邮箱填进列表。 状态机九态 ： pending / confirmed / already-active / unsubscribed / already-unsubscribed / expired / invalid / conflict / rate-limited ，公开响应不返回 token 本体，确认与退订幂等（重复点击返回同型结果，不重复变更）。 fail-closed 存储保护 ：生产缺少 DATABASE URL 时禁止订阅写入而非静默落内存；开发态内存存储与 MySQL 存储共用同一接口层（ subscription-store ）。 可插拔邮件 adapter ： console （沙箱打印）与 http-api （配置化外呼）两种 provider；发送开关默认关闭，投递记录表保存每封的状态、失败原因与最多 3 次的有限重试，周期幂等防同一周重复群发。 限流按 IP+账号维度 ，10 分钟窗口 5 次，429 响应携带可渲染文案。 难点与处理 1. 错误语义透传 ：服务故障（503）与输入错误（400）不能共用一套前端文案——接口为每个失败分支返回独立 state + message ，前端优先渲染服务端 message（详见踩坑《订阅接口 503 被前端翻译成了&quot;邮箱格式不对&quot;》）。 2. 无 JS 路径可用 ：表单原生 POST 提交到同一端点，服务端 303 重定向到带状态参数的结果页，JS 版只是把反馈前置为行内提示（详见文章《渐进增强不是口号》）。 3. 数据迁移安全 ：订阅表走版本化 migration（ schema migrations + advisory lock + checksum），已在 staging 完成备份/隔离库恢复演练。 当前状态与边界 已完成：全链路沙箱闭环、migration 与恢复演练、provider adapter 与投递记录。 待完成：staging 真实发送回执验证与生产部署验收（受外部授权阻塞）。系统在所有未验证环节均选择显式失败而不是模拟成功。 下一步 真实 SMTP 验证通过后接入 bounce 处理回调；订阅统计看板增加按主题的确认率视图。</content:encoded>
          <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
          <category>Node.js</category>
          <category>MySQL</category>
          <category>订阅</category>
          <category>邮件投递</category>
        </item>
        <item>
          <title>无障碍不是一次审计，是一串可自动化的断言</title>
          <link>https://kairos.cn.mt/posts/accessibility-automated-assertions/</link>
          <guid>https://kairos.cn.mt/posts/accessibility-automated-assertions/</guid>
          <description>把键盘可达、焦点可见、对比度、live region、reduced-motion 从&quot;上线前人工点一遍&quot;变成可回归的自动化检查清单。</description>
          <content:encoded>无障碍审计最大的敌人不是&quot;不知道标准&quot;，而是 一次性 ：上线前请人点一遍、修一轮、三个月后新功能把老问题全带回来。解药和所有工程问题一样——把要求翻译成能进 CI 的断言。 分层：哪些能自动查，哪些必须人看 | 层 | 工具 | 能覆盖 | 不能覆盖 | | -------- | --------------------------------------------------- | ------------------------------------- | -------------- | | 静态规则 | axe-core / eslint-plugin-jsx-a118 / Lighthouse a11y | 缺 label、role 误用、对比度、landmark | 交互流是否合理 | | 键盘行为 | Playwright 驱动 | Tab 顺序、焦点陷阱、Esc、方向键 | &quot;好不好用&quot; | | 视觉偏好 | 媒体特征模拟 | reduced-motion 全静态、强制色彩 | 主观可读性 | | 读屏实测 | 人工（NVDA/VoiceOver） | 语义是否&quot;听起来对&quot; | 无法自动化 | 自动化吃下前三层，人只负责第四层——这是个人站点也养得起的分工。 五条最值得固化的断言 1. 键盘可达核心流 （每页模板化）： await page.keyboard.press(&apos;Tab&apos;); // skip-link 第一站 // 断言：纯键盘完成 搜索→选结果→进入详情 const focusables = await page.evaluate( () =&gt; [ ...document.querySelectorAll( &apos;a,button,input,select,textarea,[tabindex=&quot;0&quot;]&apos;, ), ].filter((el) =&gt; el.offsetParent !== null).length, ); 2. 焦点永远可见 ：全站禁止&quot;只有颜色变化&quot;的 focus 态——断言自定义控件存在 :focus-visible 规则且伴随非颜色信号（outline/border/背景差）： const outline = await page.evaluate(() =&gt; { const el = document.activeElement; return getComputedStyle(el).outlineStyle; // 期望非 &apos;none&apos;，或 box-shadow/背景有可辨变化 }); 3. 模态必带焦点管理 ：每个弹层三条断言——打开后焦点在弹层内、Tab×N 不逃逸、关闭后回到触发元素。这三行是回归事故（焦点丢失到 body、Tab 掉进已卸载按钮）的捕获器。 4. 状态播报 ：一切&quot;结果会异步变化&quot;的区域必须有 live region： const live = await page.evaluate(() =&gt; [...document.querySelectorAll(&apos;[aria-live]&apos;)].map((n) =&gt; n.id), ); // 搜索计数、提交结果、成就解锁 → 断言注册在列 5. reduced-motion 全静态且信息不减 ：启动 reducedMotion: &apos;reduce&apos; 的 context，逐页断言：无 running 动画、所有 [data-scroll-reveal] 内容可见、动画承载的状态仍有文本/颜色替身。本项目用 13 项检查把六页跑成了一条命令。 把颜色对比度变成数值而不是感觉 深色主题的对比度问题肉眼不可靠（显示器亮度、环境光都在骗人），交给计算——Lighthouse a11y 满分时它内置的 color-contrast 规则已经逐元素核过一遍。把 --lhci 纳入发布流程，等于免费雇了一个对比度审计员；一次真实修复（全局 a{color:inherit} 压过按钮文字色导致 1.73:1）就是它抓出来的。 反模式清单（规则能查的） ：没有键盘语义——要求交互节点是 button/a 或显式补 role + tabIndex + keydown ； 图标按钮无文本：必须有 aria-label 或视觉隐藏的文本子节点； 只用颜色传达状态：错误=红、成功=绿之外必须有图标/文本； aria-hidden 的容器里残留可聚焦元素：等于给键盘用户造黑洞——隐藏需连 inert/tabindex=-1 一起。 流程建议 1. 每个新交互组件的 PR 附&quot;上表五条断言&quot;中适用的行（写进组件模板，让复制测试比跳过测试容易）； 2. CI 跑静态规则 + 关键页 reduced-motion； 3. 发布前全量跑一遍带 Lighthouse 的门禁； 4. 每季度人工读屏一次核心流——这一项接受无法自动化的事实。 小结 无障碍的质量不取决于你懂多少 ARIA 规范，而取决于 三个月后新功能进来时，老规矩还在不在 。把标准翻译成断言，是把个人意志变成系统能力——键盘用户、读屏用户、对动效敏感的用户，从此不再依赖任何人&quot;记得&quot;测试。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>可访问性</category>
          <category>测试</category>
          <category>键盘导航</category>
          <category>ARIA</category>
        </item>
        <item>
          <title>让动画跑在合成层：transform/opacity 与 layout thrash 的边界</title>
          <link>https://kairos.cn.mt/posts/animation-compositor-performance/</link>
          <guid>https://kairos.cn.mt/posts/animation-compositor-performance/</guid>
          <description>前端动效性能的核心是只动 GPU 能独立处理的属性。讲清 transform/opacity 为何安全、哪些属性会触发布局重排、以及读写分离避免强制同步布局。</description>
          <content:encoded>流畅动画的差别不在用了多少特效，而在每一帧浏览器被迫做多少工作。理解渲染管线，就能预判一个动画是 60fps 还是掉帧。 浏览器渲染四步 JS → Style（计算样式）→ Layout（几何位置）→ Paint（填色）→ Composite（合成上屏） 每帧重跑代价递减：Layout 比 Paint 贵，Paint 比 Composite 贵。理想动画只走最后一步—— Composite ，因为合成由 GPU 处理，主线程几乎不参与。 只有 transform 和 opacity 能&quot;只合成&quot; 改动 transform （translate/scale/rotate）和 opacity 时，元素已被提升为独立合成层，浏览器只需重新合成， 跳过 Layout 与 Paint ： .card-enter { transform: translateY(0.75rem); opacity: 0; } .card-enter-active { transform: translateY(0); opacity: 1; transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms; } 而下面这些属性每变一次都会触发昂贵的重排或重绘： | 动画属性 | 触发的最贵阶段 | | --------------------------------------------------------- | -------------- | | top/left/right/bottom 、 width/height 、 margin/padding | Layout | | box-shadow 、 filter: blur() 、 background | Paint | | font-size 、 line-height | Layout | 同样&quot;上浮淡入&quot;，用 top 位移是每帧 Layout + 父级重排，用 transform 是纯合成——这就是为什么动效规范里写&quot;位移一律 translate，不用 top&quot;。 会触发 Layout 的&quot;隐形雷区&quot;：JS 读几何 即使动画只用 transform，如果在 scroll / resize 回调里 同步读取几何属性再立刻写入 ，会触发&quot;强制同步布局&quot;（layout thrash）： // 反面：每个元素读一次 offsetTop 写一次 style，读被写强制刷新布局 → N 次重排 items.forEach((el) =&gt; { el.style.transform = translateY(${el.offsetTop}px) ; }); 读写分离 ：先批量读，再批量写： const tops = items.map((el) =&gt; el.offsetTop); // 只读一次布局 requestAnimationFrame(() =&gt; { items.forEach((el, i) =&gt; { el.style.transform = translateY(${tops[i]}px) ; }); // 集中写 }); 高频事件（scroll）里还应加节流——用 requestAnimationFrame 把多次事件合并到一帧更新一次，而不是每个事件都读写。 will-change：用对是加速，用满是负债 will-change: transform 提前把元素提成合成层，动画开始前给一帧提示有益。但它有内存代价（每层独立纹理），滥用会让 GPU 内存吃紧、移动端反而更卡。原则： 只在 即将动画 的元素上加，动画结束后移除； 不要给大量列表项同时常驻 will-change ； 揭示完成后释放： el.dataset.revealed &amp;&amp; el.style.willChange = &apos;auto&apos; 。 box-shadow / filter 的光晕怎么办 光晕动效常需 box-shadow 过渡，而它是 Paint 级。折中： 静态光晕用 box-shadow （不变就不重绘）； 需要&quot;发光呼吸&quot;时用叠加一个 opacity 动画的伪元素层，把 Paint 转成合成—— ::after 画好阴影，只动它的 opacity 。 用 reduced-motion 兜底性能与体验 无论动画多便宜，都要尊重 prefers-reduced-motion ： @media (prefers-reduced-motion: reduce) { { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; } } 这既是无障碍要求（前庭功能敏感用户），也是低端设备的事实降载。 小结 动效性能的功课就三条：只动 transform/opacity 、读写分离不制造强制同步布局、 will-change 点到为止。把这三条内化成肌肉记忆，比事后拿性能面板逐帧救火省力得多。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>性能</category>
          <category>动效</category>
          <category>渲染</category>
          <category>CSS</category>
        </item>
        <item>
          <title>文章页图片的懒加载与淡入：不制造布局偏移的做法</title>
          <link>https://kairos.cn.mt/posts/article-image-lazy-load/</link>
          <guid>https://kairos.cn.mt/posts/article-image-lazy-load/</guid>
          <description>正文图片用 loading=lazy + decode=async 延迟解码，配合占位尺寸与淡入，兼顾性能与体验，并处理失败与 reduced-motion。</description>
          <content:encoded>文章里一张大图，处理不好就是 LCP 杀手 + CLS 元凶。好消息是：绝大多数收益靠几行&quot;该加的加上、该设的设对&quot;就能拿到。 基线三件套 &lt;img src=&quot;/img/diagram.png&quot; alt=&quot;架构示意图&quot; width=&quot;1200&quot; height=&quot;800&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt; width/height （或 CSS aspect-ratio ）是防 CLS 的第一功臣 ——浏览器据比例预留占位空间，图片加载完成不撑动版面。缺尺寸的图是内容站最常见的布局偏移来源。 loading=&quot;lazy&quot; ：离视口远的图不请求。 首屏 LCP 图千万别加 ——懒加载会延迟它自己的加载，把最大内容元素推后。判断标准：位于首屏折叠线以上的第一张图，用 loading=&quot;eager&quot; 甚至 fetchpriority=&quot;high&quot; 。 decoding=&quot;async&quot; ：解码移出主线程，避免大图解码卡顿交互。 CSS 侧兜住尺寸 属性宽高会被样式覆盖，用 CSS 强制保持比例、限制不溢出容器： .article-content img { max-width: 100%; height: auto; aspect-ratio: attr(width) / attr(height); / 现代浏览器据属性自动推比例，这行作显式保险 / } 实际现代浏览器已由 width/height 属性自动推 Aspect Ratio，CSS 这行更多是&quot;确保不被某处 height: auto 之外的规则破坏&quot;。 淡入：锦上添花但要处理边界 想给图片加&quot;加载完成淡入&quot;，务必处理三种&quot;到不了 loaded&quot;的情况，否则图会永久隐身： function enhance(img) { if (matchMedia(&apos;(prefers-reduced-motion: reduce)&apos;).matches) return; // 直接可见 if (img.complete &amp;&amp; img.naturalWidth) return; // 已缓存：不淡入，直接显示 img.dataset.reveal = &apos;pending&apos;; const reveal = () =&gt; { img.dataset.reveal = &apos;done&apos;; cleanup(); }; const fail = () =&gt; { img.dataset.reveal = &apos;done&apos;; cleanup(); }; // 加载失败也要可见（别留隐藏态） const cleanup = () =&gt; { img.removeEventListener(&apos;load&apos;, reveal); img.removeEventListener(&apos;error&apos;, fail); }; img.addEventListener(&apos;load&apos;, reveal); img.addEventListener(&apos;error&apos;, fail); } [data-reveal=&apos;pending&apos;] { opacity: 0; } [data-reveal=&apos;done&apos;] { opacity: 1; transition: opacity 400ms ease; } 三个边界对应三条教训： 1. reduced-motion 用户跳过淡入，直接可见； 2. 命中缓存的图 complete=true ， load 事件不会再触发——不判断就会卡在 pending 永久透明； 3. 加载 失败 也必须解除隐藏——否则一张 404 图变成一块永远空白的隐形坑（比显示破图标更糟）。 客户端路由下还要在换页时移除这些 load/error 监听，避免泄漏。 响应式与格式 需要真响应式尺寸时用 srcset/sizes ，让浏览器按 DPR 与视口挑选； 截图/图表类优先 WebP/AVIF（带 type 的 或 srcset 回退）； 装饰性图 alt=&quot;&quot; （不是省略 alt），信息性图 alt 描述内容而非文件名。 小结 图片优化的收益顺序很明确： 设尺寸防 CLS &gt; 别懒加载 LCP 图 &gt; 异步解码 &gt; 现代格式 &gt; 淡入动效 。前四条是纯工程正确，最后一条是体验糖——而糖必须把&quot;缓存/失败/减弱动效&quot;三个苦口处理掉，才不会变成新 bug。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>图片优化</category>
          <category>性能</category>
          <category>CLS</category>
          <category>动效</category>
        </item>
        <item>
          <title>剪贴板 API 的现实：安全上下文、权限与失败反馈</title>
          <link>https://kairos.cn.mt/posts/clipboard-api-failure-feedback/</link>
          <guid>https://kairos.cn.mt/posts/clipboard-api-failure-feedback/</guid>
          <description>navigator.clipboard 在非 HTTPS、无焦点、权限拒绝下的行为差异，以及为复制按钮补齐成功与失败双反馈的实践模式。</description>
          <content:encoded>&quot;点击复制&quot;是工具页和代码块上最不起眼、也最容易做错的功能。做错的常见姿势是：只处理了成功。 三条铁律 navigator.clipboard.writeText() 只在以下前提成立时存在且可用： 1. 安全上下文 ：HTTPS 或 localhost 。 http:// 局域网 IP 访问时 navigator.clipboard 干脆是 undefined 。 2. 文档获得过用户手势 ：多数浏览器要求调用栈可以追溯到一次真实点击/按键，脚本自动触发会被拒绝。 3. 权限与焦点 ：页面不可见或用户在权限策略中拒绝了剪贴板写入时，Promise 以 NotAllowedError 拒绝。 任何一条不满足， clipboard API 路径就走不通——所以复制功能必须有&quot;失败也要被说出来&quot;的设计。 失败反馈是最常被遗漏的分支 一个真实做对的复制按钮应当区分三种结果： const copyText = async (text, button) =&gt; { const label = button.querySelector(&apos;[data-label]&apos;); const original = label.textContent; try { if (!navigator.clipboard?.writeText) { throw new Error(&apos;insecure-context&apos;); } await navigator.clipboard.writeText(text); label.textContent = &apos;已复制&apos;; button.classList.add(&apos;is-copied&apos;); } catch { // 关键分支：不假装成功 label.textContent = &apos;复制失败，请手动选择复制&apos;; } finally { window.setTimeout(() =&gt; { label.textContent = original; button.classList.remove(&apos;is-copied&apos;); }, 1600); } }; 两个细节： is-copied 的视觉反馈只在成功路径加上 ；失败时用文案差异即可，不要让失败看起来像成功。 定时器要记账 。若页面跑在客户端路由下（脚本体会重新执行），保存这些 timeout 并在换页前清除，否则残留回调会写进已经卸载的 DOM——这是动效批次的复盘教训在复制组件上的重演。 旧世界：execCommand 与 textarea 兜底 document.execCommand(&apos;copy&apos;) 早已废弃，且在现代浏览器中同样受权限约束，只是报错方式更暧昧（返回 false ）。它唯一还值得存在的理由是非安全上下文的兜底： const legacyCopy = (text) =&gt; { const area = document.createElement(&apos;textarea&apos;); area.value = text; area.setAttribute(&apos;readonly&apos;, &apos;&apos;); area.style.position = &apos;fixed&apos;; area.style.opacity = &apos;0&apos;; document.body.append(area); area.select(); const ok = document.execCommand(&apos;copy&apos;); area.remove(); return ok; }; 先试 async API、失败再落 legacyCopy 、两条路都不通才提示手动复制——三层漏斗能让绝大多数用户拿到内容，同时保留诚实的兜底话术。 读剪贴板是另一回事 readText() 的权限模型严格得多（通常要求用户显式手势 + 权限弹窗），粘贴类工具（JSON 校验器、diff 对比器）应当提供&quot;手动粘贴&quot;输入区作为一等公民，把&quot;一键读取剪贴板&quot;当作锦上添花的快捷路径，而不是唯一入口。 反馈动效的克制 复制成功的动画（短脉冲、像素星星之类）适可而止：一次性、≤1 秒、可被 prefers-reduced-motion 完全关闭，关闭后文本反馈&quot;已复制&quot;依然在场。动画是强调，不是信息载体本身。 小结 剪贴板功能的质量不在&quot;能不能复制&quot;，而在 不能复制的那一刻你说了什么 。给失败一个准确、可操作的出口（选中手动复制的提示），比在成功时多放一颗星星更能建立信任。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>浏览器 API</category>
          <category>剪贴板</category>
          <category>安全</category>
          <category>交互细节</category>
        </item>
        <item>
          <title>一个可访问的命令面板：从快捷键到焦点管理的全清单</title>
          <link>https://kairos.cn.mt/posts/command-palette-accessibility-checklist/</link>
          <guid>https://kairos.cn.mt/posts/command-palette-accessibility-checklist/</guid>
          <description>实现 Ctrl/Cmd+K 命令面板时的键盘协议、combobox 语义、焦点陷阱与恢复、移动端适配和加载态处理的完整检查清单。</description>
          <content:encoded>命令面板是&quot;高级感&quot;功能里最容易做出形、丢出神的：视觉上一眼像模像样，键盘用户却在三米外迷路。本文把验收一个命令面板需要过的问题列成清单，每一条都对应真实实现过的代码。 1. 唤起与关闭 快捷键： Ctrl/Cmd + K 全局监听；在 input/textarea/[contenteditable] 聚焦时不劫持其它单键快捷键。 关闭途径必须至少三条： Esc 、遮罩点击、显式关闭按钮。面板内的按钮不要用 tabindex=&quot;-1&quot; 把自己藏起来。 关闭时清理：移除焦点圈监听、恢复 body 滚动锁、移除临时 toast。 2. 语义：dialog + combobox 两层结构 外层面板是模态： &lt;div role=&quot;dialog&quot; aria-modal=&quot;true&quot; aria-labelledby=&quot;palette-title&quot; hidden 内层输入框是组合框（combobox），结果列表是 listbox，两者用 aria-controls / aria-activedescendant 关联： &lt;input role=&quot;combobox&quot; aria-expanded=&quot;true&quot; aria-controls=&quot;palette-results&quot; aria-activedescendant=&quot;option-3&quot; /&gt; … 关键决策： 选项用 aria-activedescendant 而不是让焦点真的落进列表 。焦点始终留在输入框，上下键只移动&quot;激活&quot;状态。这样输入法组合、退格编辑都不会丢，也是 ARIA 作者工具箱里 listbox 模式的正统做法。 3. 焦点：去哪了，怎么回来 打开面板： 1. 记录 document.activeElement 作为恢复锚点； 2. 焦点进入输入框（ focus({ preventScroll: true }) ，避免弹层刚出现就滚动页面）； 3. 焦点陷阱 ：监听 Tab ，到达最后一个可聚焦元素后回卷到第一个（ Shift+Tab 反向）。 aria-modal=&quot;true&quot; 只是给读屏器的声明，不能阻止 Tab 走出弹层，陷阱必须自己写。 关闭面板：焦点还给锚点。跳过这一步，键盘用户会被扔回页面顶部。 4. 键盘协议全表 | 键 | 行为 | | ----- | ----------------------------------------------------------- | | ↑ / ↓ | 移动激活项， scrollIntoView({ block: &apos;nearest&apos; }) 保证可见 | | Enter | 激活当前项（链接跳转或执行动作命令） | | Esc | 关闭并恢复焦点 | | Tab | 在面板内圈闭移动（访问关闭按钮等） | 列表为空时上下键应当无操作而不是报错；只有一个结果时 Enter 直接命中，省一次按键。 5. 加载态与空状态是设计的一部分 索引异步加载时，面板不能装作&quot;无结果&quot;。三态文案： 索引未到： 正在加载站内索引… （可先渲染无需索引的导航推荐项，索引到达后增量合并）； 有输入无匹配： 没有找到匹配内容。试试更短的关键词，或浏览推荐入口。 ； 无输入：展示推荐入口 + 最近公开内容。 面板打开的视觉就绪不应等待任何 fetch——先聚焦、先渲染骨架，网络只是后续的数据补全。 6. 跳转不要整页刷新 面板内导航若用 window.location.href = ，会把站点的 View Transitions 客户端路由整个旁路掉：整页重载、过渡动画丢失、SPA 状态清零。同域跳转改为程序化锚点点击，让路由拦截器接管： const link = document.createElement(&apos;a&apos;); link.href = target; document.body.append(link); link.click(); link.remove(); 路由不可用时浏览器自动回退为普通导航，零额外成本。 7. 移动端与 reduced-motion 宽度断点：≤640px 时面板改为全宽贴顶，内边距收紧，选项网格从三列降为一列； 触屏用户没有 Esc：遮罩点击与关闭按钮权重上升，关闭区触控高度不低于 44px； prefers-reduced-motion: reduce ：关闭选项 hover 位移与面板缩放入场，保留边框高亮等静态可辨状态。 8. 验收脚本思路 把上表变成可回归的测试：Playwright 下 Control+k → 断言 document.activeElement 为输入框 → 连按 12 次 Tab → 断言焦点仍在 #command-palette 内 → 输入关键词 Enter → 断言 URL 变化且 window 上的标记变量存活（证明 SPA 导航未重载）。比人工点击可靠，也比快照测试更能抓住&quot;焦点逃逸&quot;这类逻辑缺陷。 小结 命令面板的&quot;高级感&quot;来自毫秒级的响应与丝滑动画，而它的专业底线是那几条看不见的规矩：焦点进得来、出不去、还得回来；空与加载各有各的话；跳转不破坏路由。先把规矩跑通，再谈惊艳。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>可访问性</category>
          <category>键盘导航</category>
          <category>ARIA</category>
          <category>交互设计</category>
        </item>
        <item>
          <title>内容一致性校验：把坏数据挡在构建期而不是发布后</title>
          <link>https://kairos.cn.mt/posts/content-integrity-build-checks/</link>
          <guid>https://kairos.cn.mt/posts/content-integrity-build-checks/</guid>
          <description>个人内容站如何在构建阶段用脚本校验 frontmatter、slug 引用、日期逻辑、阅读时长与关联关系，形成可持续的内容卫生机制。</description>
          <content:encoded>内容站的 bug 大多不是代码 bug，而是 数据 bug ：写错的日期、被改名的文章留下的死引用、缺 frontmatter 字段的半成品。这些问题构建不会报错，只会在读者点开的瞬间变成空白页或 404。解法是给内容层装一道&quot;构建期校验&quot;。 值得校验的六类问题 1. 必填与格式 ：字段缺失在 schema 层拦（zod/内容集合 schema），格式在脚本层兜： // 阅读时长字段必须符合 &quot;N 分钟&quot;，避免混入 &quot;5 mins&quot; / &quot;约五分钟&quot; const readingTimePattern = /^[1-9]\d \s 分钟$/; 2. 引用完整性 ： relatedPosts 、项目关联文章、系列成员这些&quot;指向别的文档&quot;的字段，构建期逐个解析目标是否存在： for (const ref of data.relatedPosts ?? []) { if (!postIds.has(ref)) issues.push( ${file}: relatedPost &quot;${ref}&quot; 不存在 ); } 这是收益最高的一条——slug 改名后忘记同步引用，前端表现为&quot;关联文章点开 404&quot;，而只有这条断言能提前抓到。 3. 日期逻辑 ： updatedAt &lt; publishedAt 必然是笔误（除非你允许未来预约发布，那也要单独校验）；RSS 排序、图谱时间衰减都依赖这个不变量。 4. slug 唯一性 ：不同目录同名 slug、大小写碰撞，在静态路由生成阶段直接互相覆盖——提前在校验里报。 5. 公开产物的草稿泄漏 ： draft: true 的文章绝不能出现在 search-index.json 、 graph-data.json 、RSS、sitemap、 dist/ 里。这条要作为发布门禁（对每个公开产物断言不含任一草稿 ID）： const draftSlugs = collectDraftIds(); // 从内容集合读 for (const artifact of [&apos;search-index&apos;, &apos;graph&apos;, &apos;rss&apos;, &apos;sitemap&apos;]) { const leaked = findIdsInArtifact(artifact).filter((id) =&gt; draftSlugs.has(id)); if (leaked.length) fail(artifact, leaked); } 6. 覆盖度报表 ：必填字段覆盖率、各集合数量（文章/踩坑/项目），输出成机器可读 JSON 供后台&quot;内容健康&quot;页消费——把&quot;还差多少内容达标&quot;从感觉变成数字。 分层：schema 管结构，脚本管语义 内容集合 schema（zod）适合声明式约束：类型、枚举、默认值、正则。它管不了跨文档引用和集合级统计。两级配合： zod schema（结构合法性）→ check-content.mjs（语义/引用/统计）→ 生成器（索引/图谱/RSS） 顺序很关键： 校验在生成之前 ，坏数据直接让构建失败，而不是生成出半错的索引再靠下游兜。本项目把它接进 prebuild ，任何 npm run build 自动执行。 严重度与豁免 校验脚本要区分 error （挡构建）与 warning （记录不挡）：缺标题是 error，缺 difficulty 可以只是 warning（有默认值）。全 error 策略的结局一定是有人绕过门禁，分层策略才能长期活着。临时豁免要有过期日： // 允许豁免，但必须写理由和到期日，过期自动升级为 error { file: &apos;old-post.mdx&apos;, rule: &apos;missing-updatedAt&apos;, until: &apos;2026-10-01&apos;, reason: &apos;存量迁移中&apos; } 反馈形态决定执行力 校验结果不要只丢在终端日志里。落一份 content-health.json ，后台页渲染成清单——作者改内容时看到的应该是&quot;3 篇文章缺 description&quot;这样可执行的条目，而不是一屏 grep 输出。数据、门禁、可视化三件套齐了，内容卫生才是可持续机制而不是一次性大扫除。 小结 代码有类型系统兜底，内容凭什么裸奔？把 frontmatter 当 schema、把跨文档引用当外键、把公开产物当边界测试对象，用一条 prebuild 命令把六类数据 bug 挡在构建期——这是内容站在&quot;写作自由&quot;和&quot;发布可靠&quot;之间唯一划算的折中。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>内容治理</category>
          <category>构建</category>
          <category>CI</category>
          <category>数据校验</category>
        </item>
        <item>
          <title>CSS 级联层实战：为什么 @layer 能救回你的工具类</title>
          <link>https://kairos.cn.mt/posts/css-cascade-layers-in-practice/</link>
          <guid>https://kairos.cn.mt/posts/css-cascade-layers-in-practice/</guid>
          <description>用 @layer 调停基础样式与工具类的优先级之争：未分层规则为何总是赢家，如何用 base 层修正级联，以及哪些规则应该留在层外。</description>
          <content:encoded>本文为通用技术教程，示例均可在现代浏览器中直接复现。 一个诡异的 Bug 假设站点里有这样一条再普通不过的基础样式： a { color: inherit; text-decoration: none; } 然后给某个链接加一个 Tailwind 工具类换色： 订阅 预期是文字变成亮色，实际却纹丝不动——工具类&quot;失效&quot;了。检查特异性？类选择器（0,1,0）明明高于元素选择器（0,0,1）。检查源码顺序？把工具类挪到最后也没用。 问题不在特异性，也不在顺序，而在 级联层 。 级联层规则：未分层即最高优先 CSS Cascade Layers 的裁定顺序是：先比较&quot;有没有层&quot;，再比较特异性。 所有 @layer 内的样式，彼此之间按层顺序裁定； 未分层的作者样式，无条件赢过任何层里的样式 ——无论特异性多低。 Tailwind v4 把工具类输出在 @layer utilities 中。于是： @layer utilities { .text-brand { color: var(--brand); } / 层内 / } a { color: inherit; } / 层外 / 层外的 a 直接胜出。这解释了开头的 Bug：不是工具类没生成，而是它在层里&quot;天生低人一等&quot;。 顺带一提，这个规则还会让 hover: 变体悄悄失灵—— a:hover { color: … } 若写在层外，即使特异性只有 (0,1,1) 也能压过层内的任何工具类。 修复：把元素级默认样式放进 base 层 Tailwind v4 在样式表开头声明了层顺序： @layer theme, base, components, utilities; 只要把自己的元素级默认样式放进同名 base 层， utilities 就会按声明顺序正确地覆盖它： @layer base { html { background: rgb(var(--bg)); } body { color: rgb(var(--text)); font-family: var(--font-sans); } a { color: inherit; text-decoration: none; } :focus-visible { outline: 2px solid rgb(var(--accent)); } } 修复后的裁定顺序变成： 1. 浏览器默认（user agent）； 2. base 层的元素默认； 3. utilities 层的工具类——赢家。 反过来：哪些规则应该留在层外 层外规则是&quot;终极王牌&quot;，要克制地使用。适合留在层外的只有两类： 1. Token 定义 ： :root { --accent-primary: … } 和主题覆盖 html[data-accent=&apos;…&apos;] 。它们是整套样式的地基，必须稳赢任何组件层的意外干扰。 2. 刻意的兜底/重置 ：确信要压过一切工具类的历史包袱修复，配上注释说明原因。 一个判断标准：这条规则如果被某个工具类覆盖，是&quot;坏了&quot;还是&quot;对了&quot;？答案是&quot;对了&quot;，就该进层。 排查工具 遇到&quot;类写了却不生效&quot;，按这个顺序查： 1. DevTools 的 Styles 面板：看规则是否出现。出现了但被划掉，看划掉它的规则来自哪一层（面板会标注 layer ）。 2. 控制台验证匹配： document.querySelector(sel).matches(&apos;.text-\\[var\\(--x\\)\\]&apos;) 。 3. 检查层归属： getComputedStyle 不显示层级，最直观的方式是在 Styles 面板看每条规则旁边的 layer 徽标。 小结 级联层把&quot;谁赢&quot;从特异性博弈简化成了声明顺序：theme → base → components → utilities。基础样式进 base 层，工具类在 utilities 层稳赢；token 留在层外当地基。理解这一条，整个设计系统的层叠就再也不靠运气。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>CSS</category>
          <category>级联层</category>
          <category>设计系统</category>
        </item>
        <item>
          <title>容器查询落地：让卡片在自己的容器里决定布局</title>
          <link>https://kairos.cn.mt/posts/css-container-queries-in-practice/</link>
          <guid>https://kairos.cn.mt/posts/css-container-queries-in-practice/</guid>
          <description>@container 把&quot;响应式&quot;从视口级细化到组件级。以文章卡片在单列/双列/侧栏多场景复用为例，讲查询条件、回退策略与迁移边界。</description>
          <content:encoded>媒体查询回答&quot;屏幕多宽&quot;，容器查询回答&quot;我这块地方多大&quot;。对组件库来说，后者才是它真正的痛点：一张卡片被放进全宽列表、双列网格、三列网格、窄侧栏时，它的理想内部布局各不相同，但屏幕宽度却可能完全一样。 最小语法 / 声明容器 / .card-slot { container-type: inline-size; container-name: card; } / 子元素按容器宽度切换 / @container card (min-width: 40rem) { .card { display: grid; grid-template-columns: 8rem 1fr; } } container-type: inline-size 让容器按行内尺寸（逻辑上的&quot;宽度&quot;）提供查询基准；子树内任何元素都能 @container 查询它，不必是直接子级。 一个真实的复用场景 文章推荐卡需要出现在三种环境：全宽（单列+横排大缩略区）、双列网格（竖排）、侧栏（竖排紧凑）。纯媒体查询方案要为每种页面断点写一遍覆盖，而容器查询只需卡片自己声明： .slot { container-type: inline-size; } @container (min-width: 30rem) { .post-card { grid-template-columns: 1fr; } / 够宽：标题描述并排余裕 / .post-card excerpt { -webkit-line-clamp: 3; } / 多展示一行 / } @container (max-width: 20rem) { .post-card meta { flex-direction: column; } / 很窄：meta 换行堆叠 / } 卡片被放进哪个容器，就按那个容器说话。新增页面布局时零成本——这正是组件级响应式的复利。 边界与坑 1. container-type 会让容器成为包含块 ，内部 position: absolute 的定位基准随之改变；含浮出元素（tooltip、下拉）的容器要先验证。 2. 只能按行内尺寸和样式查询 （ inline-size / size / style() ），容器查询不能基于内容高度触发——那仍是 JS/ IntersectionObserver 的领域（ container-type: size 会要求块方向尺寸也固定，实践中很少用）。 3. 回退 ：不支持的浏览器直接忽略 @container 块，样式安全退化到基线布局。给基线一套&quot;哪个宽度都不算错&quot;的默认样式即可，无需 JS 检测。 4. 不要滥用 ：视口级的全局布局（页眉、主栏）仍该用媒体查询；容器查询解决的是&quot;同一组件多环境&quot;，两者不是替代关系。 与工具类的组合 Tailwind v4 已内置容器查询变体（ @container 、 @md/container: 一类），工程上更常见的落地是：给插槽类加 @container ，组件用变体写差异，避免手维护容器名。 小结 容器查询的真正价值不是新语法，而是 关注点归位 ：布局决策从&quot;页面知道组件在哪&quot;退回到&quot;组件知道自己多大&quot;。一张卡片学会看容器下菜碟之后，你在十个新场景里复用它都不需要再写第十套断点。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>CSS</category>
          <category>响应式</category>
          <category>组件设计</category>
        </item>
        <item>
          <title>CSS 自定义属性做主题系统：一套变量驱动全站换肤</title>
          <link>https://kairos.cn.mt/posts/css-custom-property-theme-tokens/</link>
          <guid>https://kairos.cn.mt/posts/css-custom-property-theme-tokens/</guid>
          <description>用 RGB 三元组自定义属性构建分层主题变量，实现零重排的整站强调色切换，并把用户选择持久化到本地。</description>
          <content:encoded>本文为通用技术教程，示例均可在任意现代浏览器中复现。 目标：换的不只是颜色，是&quot;角色&quot; 主题系统常见的做法是给每个组件写一套 .theme-dark .card { ... } 覆盖。组件一多就会失控。更好的抽象是把颜色拆成 角色（role） 而不是 位置 ： --accent-primary ：主强调色（链接、主按钮、聚焦环） --accent-primary-hover / --accent-primary-strong ：交互态 --surface- / --text- / --border- ：中性层 组件永远只消费角色 token。换主题时，换的是角色的取值，而不是任何组件样式。 关键技巧：RGB 三元组 + alpha 组装 如果直接存 #38e8c6 ，透明度变体（hover 蒙层、发光、软背景）就没法复用。把颜色存成 R G B 三个数字，使用时再组装透明度： :root { --accent-primary: 56 232 198; --accent-primary-soft: rgb(var(--accent-primary) / 0.12); --accent-primary-glow: rgb(var(--accent-primary) / 0.32); } .button { border-color: rgb(var(--accent-primary) / 0.55); box-shadow: 0 0 48px rgb(var(--accent-primary) / 0.09); } 这样一套 token 就覆盖了实色、软背景、光晕、边框等所有透明度层级，主题切换只需要覆盖一个三元组。 换肤：属性覆盖 + 级联 在 html 元素上用 data- 属性承载当前主题，CSS 里按属性选择器覆盖角色 token： html[data-accent=&apos;coral&apos;] { --accent-primary: 255 122 144; --accent-primary-hover: 255 158 174; --accent-primary-strong: 214 74 98; } 注意两个细节： 1. 只覆盖角色，不覆盖派生值 。 --accent-primary-soft 这类派生 token 引用了 --accent-primary ，自定义属性是运行时求值的，基础值变了派生值自动跟随——这正是分层 token 的意义。 2. 选择器挂在 html 上而不是 :root 之外的元素 ，因为 :root 就是 html ，属性选择器与它同级特异性更高，且随 DOM 属性切换即时生效。 JavaScript 侧只需一行： document.documentElement.dataset.accent = &apos;coral&apos;; 没有重排、没有组件遍历、没有 CSSOM 注入。Canvas 里也能吃到同一套颜色： const triplet = getComputedStyle(document.documentElement) .getPropertyValue(&apos;--accent-primary&apos;) .trim(); ctx.fillStyle = rgb(${triplet} / 0.8) ; 首帧不闪烁 从 localStorage 恢复主题的脚本必须 在首帧绘制之前 执行，否则用户会看到一次默认色闪现。把内联脚本放在 顶部、任何样式表生效之前： (() =&gt; { try { const { theme } = JSON.parse( localStorage.getItem(&apos;theme-prefs&apos;) || &apos;{}&apos;, ); if ([&apos;coral&apos;, &apos;cream&apos;, &apos;green&apos;].includes(theme)) { document.documentElement.dataset.accent = theme; } } catch { / 保持默认 / } })(); 使用 SPA 路由（或 Astro View Transitions）时还有一个隐藏利好： document.documentElement 跨导航持续存在， data-accent 属性不会因为换页丢失，无需在每个页面重复恢复。 无障碍校验 换肤改变了强调色，等于改变了对比度。上线前对每套主题过一遍： 主色上的正文/按钮文字（浅色主配深字时确认对比度 ≥ 4.5:1）。 彩色文字与其背景（提示：深底上的强调色文字看&quot;亮度&quot;而非&quot;色相&quot;）。 :focus-visible 轮廓在所有主题下清晰可辨。 四套主题值一起校验，比上线后再逐个修快得多。 小结 角色化 token + RGB 三元组 + 属性覆盖，三层叠加之后，&quot;全站换肤&quot;从一场组件大改造退化成三行 CSS。Canvas、Web Component、第三方样式都能通过同一套 token 对齐——这就是设计系统里&quot;单一事实来源&quot;的实际形状。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>CSS</category>
          <category>设计系统</category>
          <category>主题</category>
          <category>可访问性</category>
        </item>
        <item>
          <title>暗色主题下的对比度：为什么&quot;看着还行&quot;往往不合格</title>
          <link>https://kairos.cn.mt/posts/dark-theme-contrast-pitfalls/</link>
          <guid>https://kairos.cn.mt/posts/dark-theme-contrast-pitfalls/</guid>
          <description>暗色 UI 里低对比文字、彩色强调按钮、以及级联层导致的样式覆盖如何悄悄违反 WCAG 对比度，附定位与量化修复。</description>
          <content:encoded>暗色主题好看，但它是无障碍对比度的高发区。很多配色在编辑器里&quot;看着挺清楚&quot;，用审计工具一测却全线不及格——而且不及格的方式很隐蔽。 标准是什么 WCAG 对比度要求（相对亮度比值）： 正文（&lt; 18pt / 常规字重）：至少 4.5:1 ； 大文本（≥ 18pt 或 14pt 粗体）：至少 3:1 ； 图标、边框、表单控件等&quot;非文本 UI&quot;：至少 3:1 。 暗色主题最常见的失分点是&quot;低透明度灰字&quot;： rgba(255,255,255,0.5) 叠在 #0b0e14 上看着柔和，实测常只有 6.x:1 尚可；但一旦叠加在彩色卡片、或透明度再降一档到 0.4 ，很容易跌破 4.5。而 text/70 、 text/50 这类 Tailwind 半透明写法在深底上尤其危险。 一个隐蔽杀手：级联层覆盖 一次真实的暗色站审计里发现：&quot;订阅&quot;按钮文字对比度只有 1.73:1 （严重不达标），但组件源码写的是明确的 text-white 。根因是一条未分层的裸样式： a { color: inherit; } / 未分层 → 特异性 + 层级双高压过所有 utilities / 在 Tailwind 的 @layer utilities 下，一条 未分层 的 a { color: inherit } 会赢过分层工具类，让所有 静默变成继承来的低对比灰。修复不是加 !important ，而是把 html/body/a/:focus-visible 这类基础样式正确放进 @layer base ，让 utilities 层能正常覆盖它们： @layer base { a { color: inherit; } :root { / token 定义刻意保持未分层，确保组件能读取变量 / } } 暗色主题 + 半透明文字 + 级联层冲突 三者叠加，能制造&quot;肉眼觉得还行、工具判不合格、且不知道为什么&quot;的三重坑。 量化，而不是目测 不要凭眼睛。把计算式对比度纳入流程： function contrast(rgb1, rgb2) { const lum = ([r, g, b]) =&gt; { const a = [r, g, b].map((v) =&gt; { v /= 255; return v &lt;= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 a[0] + 0.7152 a[1] + 0.0722 a[2]; }; const [l1, l2] = [lum(rgb1), lum(rgb2)].sort((a, b) =&gt; b - a); return (l1 + 0.05) / (l2 + 0.05); } 更省事的是直接拿计算样式喂审计： const cs = getComputedStyle(el); // 解析 cs.color 与逐层上溯的不透明背景，算对比度；或用 axe-core 的 color-contrast 规则 Lighthouse / axe 的 color-contrast 规则能自动化这件事，但前提是你真的去跑——很多&quot;暗色主题上线即不合格&quot;只是因为从没跑过。 暗色配色的实操建议 正文别用纯白 #fff （深底上刺眼），用 #e6e6e6 一类高灰阶，既舒适又轻松过 4.5:1； 强调色做 双档 ：一个用于大面积（对比适中），一个用于文字/图标的&quot;加强版&quot;（保证 ≥4.5，图标 ≥3）； 半透明文字设下限： muted 不低于 0.6 透明度，低于它改用实心低饱和灰； 边框/分隔线属于非文本 UI，≥3:1， rgba(255,255,255,0.08) 这种几乎不可见的分隔线其实不合规，需要提到 0.12–0.16。 小结 暗色主题的对比度不是&quot;选个深背景&quot;就完事，它要正向处理三件事：低透明度文字的量化、级联层不打架、强调色的可用性双档。把计算式对比度当成和&quot;构建通过&quot;同级的硬性门禁，&quot;看着还行&quot;就不会在真实用户（尤其低视力用户）那里变成&quot;根本看不清&quot;。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>可访问性</category>
          <category>暗色主题</category>
          <category>CSS</category>
          <category>对比度</category>
        </item>
        <item>
          <title>力导向图最小实现：在原生 Canvas 上写一个物理引擎</title>
          <link>https://kairos.cn.mt/posts/force-directed-graph-minimal-canvas/</link>
          <guid>https://kairos.cn.mt/posts/force-directed-graph-minimal-canvas/</guid>
          <description>不引库做出可拖拽的知识图谱星图：斥力、弹簧、向心力三股力量如何配平，缩放平移的坐标换算，以及 reduced-motion 下的静态降级。</description>
          <content:encoded>本文为通用技术教程，全部代码可在浏览器控制台或单页项目中复现。 为什么&quot;自己写&quot;反而更轻 提到力导向图，第一反应是引 D3-force。但处理几十到几百个节点的规模时，核心物理只有三股力，O(n²) 的两两斥力在这个量级毫无压力。自研的好处是：能精确控制何时停帧（省电）、如何降级（reduced-motion）、以及坐标系统（与缩放平移共用一套换算）。 三股力的配平 每个 tick 依次施加： // 1) 斥力：任意两节点之间，距离越近推得越狠 for (let i = 0; i &lt; nodes.length; i += 1) { for (let j = i + 1; j &lt; nodes.length; j += 1) { const dx = a.x - b.x, dy = a.y - b.y; const distSq = Math.max(1, dx dx + dy dy); if (distSq &gt; 90000) continue; // 远处的力忽略，性能与稳定双赢 const force = REPULSION / distSq; const dist = Math.sqrt(distSq); a.vx += (dx / dist) force; b.vx -= (dx / dist) force; } } // 2) 弹簧力：有边的节点被拉向理想长度 const force = (dist - SPRING LENGTH) STIFFNESS; // 3) 向心力：防止整张图飘走 node.vx -= node.x GRAVITY; 然后是积化和衰减： node.vx = DAMPING; // 阻尼，0.86 左右 node.x += node.vx alpha; 参数的量级感：斥力 2000–5000，弹簧长度 80–120，刚度 0.01–0.02，重力 0.001–0.002。斥力与弹簧的比值决定&quot;图是抱团还是散开&quot;，重力决定&quot;能不能被弹簧拉出凸包&quot;。 alpha：让图自己停下来 物理循环最大的工程问题是 CPU。解法是引入衰减因子 alpha： alpha = Math.max(0.02, alpha 0.996); 每帧位移乘以 alpha，运动幅度指数衰减；当所有节点速度足够小（或 alpha 触底）就 cancelAnimationFrame ， 循环彻底停止 。用户拖拽、缩放或改筛选时再把 alpha 抬回 0.55 重新加热——绝大多数时间 CPU 占用为零。 const frame = () =&gt; { tick(); draw(); if (alpha &lt;= 0.03 &amp;&amp; !dragging) { running = false; return; } requestAnimationFrame(frame); }; 拖拽、缩放、平移的坐标换算 Canvas 的世界坐标与屏幕坐标通过一个仿射变换关联： // 世界 → 屏幕：先平移到画布中心，再缩放 screenX = worldX scale + width / 2 + panX; // 屏幕 → 世界（命中测试用） worldX = (screenX - width / 2 - panX) / scale; 三个要点： 1. 命中测试必须用世界坐标 。把鼠标位置反算回世界系，再找半径 +6/scale 范围内最近的节点，才能保证任何缩放下手感一致。 2. 滚轮缩放要锚定光标 。缩放前后，光标下的世界点必须静止不动： const factor = Math.exp(-event.deltaY 0.0012); panX = cursorX - width / 2 - worldX nextScale; scale = nextScale; 3. 拖拽节点时把该节点标记为 pinned ，物理循环跳过它的位置积分，松手解除。节点被&quot;拎&quot;起来时，弹簧会自然把邻居拖过来——这是力导向图最有生命感的瞬间。 绘制层的两件事 DPR 适配 ： canvas.width = cssWidth devicePixelRatio ，再 setTransform(dpr, 0, 0, dpr, 0, 0) ，否则视网膜屏上全是锯齿。 视觉编码 ：半径 ∝ √degree，高连接节点画径向渐变光晕；标签只在&quot;选中、悬停、大节点、放大&quot;四种情况绘制，避免几百个文字糊成一团。 reduced-motion 与节能降级 prefers-reduced-motion 不是&quot;不画&quot;，而是&quot;不逐帧画&quot;： if (reducedMotion.matches) { for (let i = 0; i &lt; 240; i += 1) tick(); // 同步跑完物理 draw(); // 画一帧静态终态 } 拖拽、缩放、平移是直接操作，应当保留——只是没有惯性动画。另外两个节能开关： IntersectionObserver 监听画布离开视口即停帧； document.hidden 时暂停。 停止条件清单 发布前逐条自检： [ ] alpha 触底或最大速度 &lt; 阈值时，rAF 停止； [ ] 拖拽/平移期间循环保持运转，松手后重新衰减； [ ] 画布离开视口、标签页隐藏时停帧，回来时恢复； [ ] ResizeObserver 而非 window resize 适配容器尺寸； [ ] astro:before-swap （或路由卸载）时取消帧、断开所有 Observer。 小结 力导向图的&quot;高级感&quot;不来自物理公式的复杂度，而来自三件事：力量配平的参数手感、彻底停帧的工程纪律、以及直接操作（拖拽/缩放）的坐标换算。三件事都做对，一个 200 行的自研引擎就能在几十节点规模上取代整个图库。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>Canvas</category>
          <category>数据可视化</category>
          <category>JavaScript</category>
          <category>动效</category>
        </item>
        <item>
          <title>表单校验的分层：HTML 属性、JS 增强与服务端最终判定</title>
          <link>https://kairos.cn.mt/posts/form-validation-three-layers/</link>
          <guid>https://kairos.cn.mt/posts/form-validation-three-layers/</guid>
          <description>邮箱订阅、评论这类表单如何做三层校验——原生属性守底线、JS 提升体验、服务端权威判定，避免&quot;前端校验即安全&quot;的常见误区。</description>
          <content:encoded>一个提交邮箱的表单，&quot;对不对&quot;由谁说了算？正确答案是 三层各管一段 ，谁都不能替代谁。把它们混为一谈，就会同时得到糟糕的安全和糟糕的体验。 第一层：HTML 属性——零 JS 的底线 &lt;input name=&quot;email&quot; type=&quot;email&quot; required maxlength=&quot;254&quot; autocomplete=&quot;email&quot; /&gt; type=&quot;email&quot; ：浏览器原生拦截&quot;明显不是邮箱&quot;的输入，无需任何 JS； required ：空提交被表单原生阻止； maxlength ：把超长输入挡在客户端，防止无意义大包； autocomplete ：填表体验和自动填充的正确性。 这层的价值是 它在 JS 完全不可用时仍然工作 （渐进增强基线）。但它只能保证&quot;格式像&quot;，不能保证&quot;真实、合法、没滥用&quot;。 第二层：JS 增强——体验，而非安全 客户端脚本做的是&quot;更早、更准、更友好&quot;： 失焦即校验，实时红字提示（ aria-live 播报，别只靠变色）； 提交时 fetch JSON 接口，把结果渲染在行内（成功/限流/服务不可用分别给不同文案，且 透传服务端真实 message ）； 提交瞬间禁用按钮 + &quot;正在发送…&quot;，防重复提交； fetch 失败自动回退原生整页提交（增强挂了退回第一层）。 关键认知：这一层是给正常用户用的，不是防坏人用的。 任何人关掉 JS 或直接用 curl 打接口，第二层形同虚设。把&quot;邮箱格式校验&quot;只写在这一层，等于没写。 第三层：服务端——唯一权威 服务端必须假设前两层都不存在，独立完成全部判定： // 1) 再次校验格式（不信任客户端） if (!isValidEmail(email)) return respond(request, &apos;invalid&apos;, {}, 400); // 2) 限流：按 IP + 账号维度，防刷 if (await isRateLimited(...)) return respond(request, &apos;rate-limited&apos;, {...}, 429); // 3) 业务规则：topic 白名单、去重、幂等 // 4) 数据落库/投递 服务端还负责定义 错误语义 ：哪些是&quot;用户可修复&quot;（格式错、已订阅），哪些是&quot;用户不可修复&quot;（服务暂不可用）。前端要如实透传这个区分——把 503 翻译成&quot;邮箱格式不对&quot;是真实发生过的反例（详见踩坑《订阅接口 503 被前端翻译成了&quot;邮箱格式不对&quot;》）。 三层如何协作而不互相甩锅 | 关注点 | 归属层 | 反例 | | --------------- | ------------------ | ---------------------------------- | | 必填/格式像不像 | HTML | 只做 JS 校验 → 关 JS 就绕过 | | 即时友好反馈 | JS | 只在服务端返回整页错误 → 体验差 | | 真实合法性 | 服务端 | 信任客户端 valid=true → 可被伪造 | | 防滥用/限流 | 服务端 | 只做前端&quot;按钮防连点&quot; → 脚本秒绕过 | | 错误分类与文案 | 服务端定、前端透传 | 前端自己瞎猜错误原因 | 一个常被忽略的点：可访问的校验 错误提示不能只靠红色——色盲用户看不见。正确做法： 出错字段 aria-invalid=&quot;true&quot; + aria-describedby 指向错误文案； 错误文案容器 role=&quot;alert&quot; 或 aria-live=&quot;polite&quot; ，让读屏器播报； 提交失败后焦点移到第一个错误字段。 原生 :invalid 伪类也能做视觉标记，但同样要配文本，不能只变色。 小结 表单校验不是&quot;要不要加前端校验&quot;的二选一，而是三层的明确分工：HTML 守无 JS 底线、JS 提升正常用户体验、服务端做不可绕过的权威判定，并把错误语义交给前端如实透传。任何一层的缺失，都会以&quot;安全洞&quot;或&quot;体验坑&quot;的形式，在你看不到的地方兑现。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>表单</category>
          <category>校验</category>
          <category>安全</category>
          <category>UX</category>
        </item>
        <item>
          <title>一份配置，两个世界：服务端模块如何喂给内联脚本</title>
          <link>https://kairos.cn.mt/posts/json-payload-bridge-server-browser/</link>
          <guid>https://kairos.cn.mt/posts/json-payload-bridge-server-browser/</guid>
          <description>用 JSON script 载荷把服务端模块里的配置、规则表传给浏览器内联脚本，避免在 Astro 项目中把同一份数据手抄两遍。</description>
          <content:encoded>一个反复出现的漂移现场 Astro 页面有两种脚本：打包模块脚本（可 import ）与 is:inline 脚本（原样输出、不打包）。后者常见于必须在首屏前执行、或刻意避免 hydration 时序问题的场景——主题应用、防闪烁脚本、页面级增强。 麻烦从这里开始：很多运行参数其实定义在 TypeScript 模块里（动效配置、成就规则表、工具示例数据），而 is:inline 脚本碰不到模块系统。于是同一份数据出现两种实现： // src/lib/achievements.ts —— 事实 A { id: &apos;streak-3&apos;, label: &apos;三日之约&apos;, condition: { kind: &apos;streakDays&apos;, atLeast: 3 } } // 某页面 is:inline 脚本 —— 事实 B（手抄） { id: &apos;streak-3&apos;, label: &apos;三日之约&apos;, test: (s) =&gt; s.streakDays &gt;= 3 } 两处手抄迟早漂移：改了 lib 忘了脚本，或反过来。更隐蔽的是语义漂移——字段从 threshold: 3 改成 atLeast: 3 ，另一处没跟上，构建照样通过。 模式：结构化数据进 payload，函数留在消费端 服务端 setup 里 import 配置模块，把 可序列化部分 写进 JSON script 标签；浏览器端从 DOM 读取并解释。以成就规则为例，规则表在 lib 里定义成纯数据： // src/lib/achievements.ts export type AchievementCondition = | { kind: &apos;completedPosts&apos;; atLeast: number } | { kind: &apos;totalSeconds&apos;; atLeast: number } | { kind: &apos;streakDays&apos;; atLeast: number } | { kind: &apos;flag&apos;; flag: string }; export const achievementBadges = [ { id: &apos;first-read&apos;, label: &apos;初次点亮&apos;, description: &apos;完成 1 篇文章阅读&apos;, condition: { kind: &apos;completedPosts&apos;, atLeast: 1 }, }, // … ]; 页面模板注入（ set:html 对 script 内容是安全的，JSON 中已无用户输入）： &lt;script type=&quot;application/json&quot; id=&quot;achievement-badge-data&quot; is:inline set:html={JSON.stringify(achievementBadges)} 内联脚本负责解释——函数不能进 JSON，但解释器只写一份： const badgeRules = JSON.parse( document.querySelector(&apos;#achievement-badge-data&apos;)?.textContent || &apos;[]&apos;, ).map((badge) =&gt; ({ ...badge, test: (state) =&gt; evaluateCondition(badge.condition, state), })); 关键约束： payload 里是 kind/atLeast 这样的判别式数据， switch 解释器只有内联脚本一处 。lib 修改规则表（阈值、文案、新增徽章）无需动任何页面；只有新增条件种类时才需要在解释器里加一个 case——这正是一个显式、可测试的扩展点，而不是隐式的手抄同步。 与 define:vars 的区别 define:vars 是变量插值：把值 展开成源码文本 塞进脚本。它在模板阶段生成形如 const cfg = {...} 的顶层声明，在客户端路由（多次执行同一脚本体）下容易触发重复声明错误，调试时看到的产物也充满插值痕迹。JSON script 方案则把数据与代码物理分离： 脚本体是稳定的 IIFE，数据永远来自 textContent ，客户端路由下重新初始化只是重新读一次 DOM； 数据可以先在浏览器 DevTools 里直接查看，不需要在编译产物里找插值； 类型检查发生在服务端（setup 的 TS），JSON 结构错误在构建期就暴露。 两者都合理，但&quot;传数据&quot;这件事上，payload 方案在动态化页面里更稳。 工程细节清单 1. fallback 要有，且要小。 payload 节点丢失或被篡改时，脚本应回退到&quot;最保守可用&quot;的内置子集，而不是整段功能死亡。 2. 给 JSON script 固定 id 。 便于 DevTools 检查，也便于测试里断言注入成功。 3. 解释器函数保持纯函数。 上面 evaluateCondition(condition, state) =&gt; boolean 是纯函数，可直接在 Node 层为 lib 的数据驱动版本写单测。 4. 别把秘密放进 payload。 JSON script 会原样出现在 HTML 源码里，只放本就公开的数据；页面内联脚本读的是 DOM，不是运行时权限。 小结 &quot;服务端知道，浏览器也要知道&quot;的数据，优先设计成 可序列化的判别式数据 + 单一解释器 。lib 是事实源，HTML 是事实快照，页面脚本只是快照的阅读者。手抄一遍看似省五分钟，漂移一次赔一整个重构。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>前端工程</category>
          <category>数据流</category>
          <category>设计模式</category>
        </item>
        <item>
          <title>Lighthouse 分数怎么读：从类别分到可执行的修复</title>
          <link>https://kairos.cn.mt/posts/lighthouse-scores-to-fixes/</link>
          <guid>https://kairos.cn.mt/posts/lighthouse-scores-to-fixes/</guid>
          <description>Performance/A11y/Best-Practices/SEO 四个类别分背后的审计项如何映射到具体修复：对比度、表单标签、控制台错误与指标类洞察的定位方法。</description>
          <content:encoded>本文为通用技术教程，示例来自可公开验证的审计行为，不针对任何具体站点数据。 类别分是平均数，修复要看审计项 Lighthouse 的四个类别分（Performance / Accessibility / Best Practices / SEO）各自由一组加权审计项平均而来。类别分 92 意味着&quot;有少量审计项没过&quot;，而 每一个未过审计项都是一条可执行的修复指令 。 读报告的正确姿势不是盯着分数叹气，而是：按 score &lt; 1 过滤审计项，逐条把 details.items 里的元素定位出来。 可访问性：三个高频杀手 1. color-contrast（对比度） Lighthouse 用计算样式算前景/背景对比度，阈值 4.5:1（大字 3:1）。常见的三种来源： 语义 token 没接到元素上（比如按钮文字继承了父级的暗淡色，而不是使用反色 token）； 透明度叠加： opacity: 0.6 的文字会让实际对比度跌出阈值； 彩色文字落在浅色背景上，只顾了品牌色没顾可读性。 修复时注意&quot;继承链&quot;：一个按钮显示错色，问题可能出在三层之外的父元素。 2. label（表单控件无标签） 每个可交互的 / / 都需要可访问名称，来源优先级：显式 &gt; 隐式包裹 label &gt; aria-label &gt; aria-labelledby 。 Markdown 渲染的任务清单（GFM task list）是个隐蔽重灾区—— - [ ] 会渲染成 没有关联 label 的复选框 。对纯展示用途的清单，处理方式是禁用并补名称： document .querySelectorAll(&apos;.task-list input[type=&quot;checkbox&quot;]&apos;) .forEach((checkbox, index) =&gt; { checkbox.disabled = true; checkbox.setAttribute(&apos;aria-label&apos;, 清单项 ${index + 1} ); }); disabled 让它退出 Tab 序与表单提交，审计也不再检查它。 3. label-content-name-mismatch（可访问名与可见文本脱节） 给控件加 aria-label 本意是更好的无障碍描述，但如果可访问名 不包含 可见文本，语音控制用户说&quot;搜索&quot;时可能匹配不到那个名字里写的是&quot;全站检索&quot;的按钮。 规则：可访问名必须包含可见文本，额外说明放后面： 搜索 搜索 Best Practices：控制台错误 errors-in-console 常年躺在报告里没人管，因为它&quot;不影响功能&quot;。但它是四类问题的一站式集合： 资源 404（典型：favicon、懒加载图片、写错的预加载链接）； 未捕获的 Promise rejection； 已废弃 API 警告； CSP 违规报告。 修复优先级最高的往往是最无聊的：站点从未配置 favicon，每个访客的浏览器都在请求一个 404。一个 3 行的 SVG 图标加一行 就能清掉整页每访客一次的报错，同时让书签栏有脸见人。 Performance：区分&quot;指标&quot;与&quot;机会&quot; 性能部分的审计项分两类，处理方式完全不同： 指标（metrics） ：LCP、TBT、CLS、Speed Index。它们有数值阈值，修复靠优化手段——预加载关键资源、拆分长任务、给媒体预留尺寸。 洞察（insights） ：render-blocking-resources、unminified-javascript、uses-text-compression 这类&quot;机会项&quot;。它们指向确定性的工程动作： 渲染阻塞：把非关键 CSS 异步化、脚本加 defer ； 压缩：确认服务器对 JS/CSS/JSON 开了 gzip 或 brotli（自部署站点最常见的失分项是忘了给 .json 开压缩）； 压缩混淆：构建管线里关了 minify 就等于白送几百毫秒。 把审计变成回归门禁 单次跑分是行为，门禁才是习惯。Lighthouse CI 的断言机制可以把每个审计项变成构建门禁： { &quot;ci&quot;: { &quot;assert&quot;: { &quot;assertions&quot;: { &quot;categories:performance&quot;: [&quot;error&quot;, { &quot;minScore&quot;: 0.9 }], &quot;categories:accessibility&quot;: [&quot;error&quot;, { &quot;minScore&quot;: 0.9 }], &quot;cumulative-layout-shift&quot;: [&quot;error&quot;, { &quot;maxNumericValue&quot;: 0.1 }] } } } } 注意事项只有一条：跑分环境必须可复现（固定 Chrome 版本、固定视口、固定节流方式），否则分数的波动会淹没回归信号。 小结 Lighthouse 报告的价值不在那个大分数，而在 score &lt; 1 的审计项列表——每一条都是带着定位信息的修复工单。对比度看继承链，表单看可访问名，控制台 404 顺手清，性能分清&quot;指标&quot;与&quot;机会&quot;。四条走完，类别分会自己回来。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>Lighthouse</category>
          <category>性能</category>
          <category>可访问性</category>
          <category>Web 工程化</category>
        </item>
        <item>
          <title>本地优先的工具站设计：让输入永远不离开浏览器</title>
          <link>https://kairos.cn.mt/posts/local-first-tool-privacy-architecture/</link>
          <guid>https://kairos.cn.mt/posts/local-first-tool-privacy-architecture/</guid>
          <description>浏览器端开发者工具的隐私架构怎么做：无后端处理、状态留在本地存储、错误不外泄，以及如何用构建期索引替代服务端搜索。</description>
          <content:encoded>本文讨论可公开验证的通用设计方法，所有示例均为演示场景。 为什么&quot;本地处理&quot;值得当作架构承诺 开发者工具经常要处理敏感内容：一段含 token 的 JWT、一个带内网地址的配置 JSON、一条从日志里抠出来的报错。如果这类输入会被发送到服务端，工具的每一次粘贴都是一次信任消耗。 本地优先（local-first） 的承诺很简单：输入在浏览器里产生，在浏览器里处理，在浏览器里消失。它不是一句文案，而是一组可以逐条验证的工程约束。 约束清单 一个合格的本地工具页，至少要满足这五条： 1. 没有上传路径 。处理逻辑只存在于页面脚本中，页面不包含任何把用户输入写进 fetch body 的代码。 2. 持久化只针对偏好，不针对内容 。字号、主题这类偏好可以进 localStorage ；用户粘贴的内容最多进 sessionStorage （关标签即失效），或者干脆只放在内存里。 3. 日志脱敏 。前端 console 与埋点里不出现原始输入。最稳妥的做法是工具页根本没有埋点。 4. 错误信息不携带数据 。解析失败时提示&quot;无法解析&quot;而不是把输入原样打出来——错误提示本身也可能成为泄露面。 5. 可以断网使用 。这是验证前四条最直接的方式：拔掉网络，工具应该一切照常。 构建期索引：搜索也可以不出服务器 站内搜索通常需要一个&quot;服务端&quot;。但内容量在几百条以内的站点，完全可以在 构建期 把公开内容的标题、摘要、标签导出成静态 JSON： // 构建脚本（Node）：扫描内容集合 → 生成 search-index.json const items = collections.flatMap((collection) =&gt; collection.entries .filter((entry) =&gt; !entry.draft) .map((entry) =&gt; ({ type: collection.name, title: entry.title, href: entry.href, excerpt: entry.description, tags: entry.tags, })), ); await writeFile(&apos;public/search-index.json&apos;, JSON.stringify({ items })); 页面端用模糊匹配（子序列打分即可）在内存里完成检索。这样做的额外收益： 草稿天然隔离 ：只导出非草稿内容， draft: true 的条目从源头就不会进入索引，避免了&quot;草稿泄漏到搜索&quot;这一整类问题。 无查询成本 ：没有搜索服务，也就没有搜索服务的限流、日志和故障。 索引即产物 ：索引随构建生成、随部署更新，&quot;内容改了索引没更新&quot;这一类陈旧问题被流水线消灭。 需要注意的边界：索引里只能放 公开元数据 （标题、摘要、标签），不要把正文全文塞进去——索引 JSON 是任何访客都能下载的静态文件。 用 DevTools 快速复核 发布前可以做一个快速自查，在 DevTools 的 Network 面板里： 1. 打开工具页，粘贴一段测试内容，执行所有操作。 2. 过滤 XHR/Fetch 请求：除页面自身资源外，不应该出现任何携带输入内容的请求。 3. 检查 localStorage / sessionStorage ：确认只有偏好键，没有内容键。 4. 断网重测一遍核心功能。 四步都干净，&quot;输入不出浏览器&quot;才算成立。 小结 本地优先不是少写了后端，而是把信任模型改了：用户不需要相信服务器会删除数据，因为数据从未到达服务器。对个人开发者的工具站来说，这既是伦理选择，也是实打实的架构简化——没有上传路径，就没有为上传路径负责的成本。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>隐私</category>
          <category>工具设计</category>
          <category>前端架构</category>
          <category>JavaScript</category>
        </item>
        <item>
          <title>localStorage 的 schema 版本化：命名、迁移与可回退</title>
          <link>https://kairos.cn.mt/posts/localstorage-schema-versioning/</link>
          <guid>https://kairos.cn.mt/posts/localstorage-schema-versioning/</guid>
          <description>浏览器本地存储跨版本演进的最佳实践——统一命名、读写分离、迁移而非破坏、提供一键清理，避免旧用户数据变成幽灵 bug。</description>
          <content:encoded>个人站点上的&quot;本地优先&quot;功能（书签、阅读进度、主题偏好、成就）几乎都住在 localStorage 里。它没有数据库那样的迁移工具，但同样需要版本策略——否则老访客的旧数据会在新版本里制造无法复现的 bug。 一、统一命名：前缀 + 命名空间 + 版本 一个站点的所有 key 应当有共同前缀，冒号分层，末尾带 schema 版本： myblog:reading-achievements:v1 myblog:accent-theme:v1 myblog:bookmark: 对比反面教材：同一站点里混用 myblog-bookmark-favorites （连字符、无版本）和 myblog:recent-searches:v1 。前者难 grep、难批量清理、无法判断格式版本。统一命名看似小事，却决定了&quot;一键清除本站数据&quot;&quot;跨标签同步&quot;&quot;旧数据迁移&quot;这些能力能不能廉价实现。 二、迁移而非破坏：读时归一化 不要在用户下次访问时直接丢弃或崩溃于旧格式。最稳的是 读时迁移 ： const NEW KEY = &apos;myblog:bookmark-favorites:v1&apos;; const OLD KEY = &apos;myblog-bookmark-favorites&apos;; function readFavorites() { try { // 旧 key 存在且新 key 未写 → 一次性搬迁，再删旧 key if (!localStorage.getItem(NEW KEY) &amp;&amp; localStorage.getItem(OLD KEY)) { localStorage.setItem(NEW KEY, localStorage.getItem(OLD KEY)); localStorage.removeItem(OLD KEY); } const raw = localStorage.getItem(NEW KEY) || &apos;[]&apos;; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; // 隐私模式/配额/坏数据：降级为空，绝不抛穿到 UI } } 要点： 迁移是 幂等 的（新 key 已存在就跳过），重复执行安全； 解析失败一律 catch 到默认值—— localStorage 在隐私模式可能抛异常，坏 JSON 可能来自半次写入； 结构升级（v1→v2）用同样套路：读 v1，转换，写 v2，保留 v1 一个版本周期以便回滚。 三、写操作要有超时与配额意识 localStorage.setItem 在超出配额（多数浏览器约 5–10MB）时抛 QuotaExceededError 。任何&quot;保存&quot;都应包在 try/catch 里，失败时至少静默降级、最好给用户可见提示。把持久化视为 best-effort，功能正确性不能依赖它成功。 四、必须提供&quot;清除我的数据&quot;入口 本地存储对用户不可见，一个诚实的站点应提供一键清除。实现很直接——遍历删除带统一前缀的 key： function wipeAll(prefix = &apos;myblog&apos;) { const doomed = []; for (let i = 0; i &lt; localStorage.length; i += 1) { const k = localStorage.key(i); if (k &amp;&amp; k.startsWith(prefix)) doomed.push(k); } doomed.forEach((k) =&gt; localStorage.removeItem(k)); } 统一前缀在这里显出价值：没有它，你无法安全地&quot;只清本站数据&quot;。配合二次确认弹窗，构成对个人数据的最低尊重。 五、跨标签页一致性 localStorage 改动会触发其他同源标签页的 storage 事件。主题、偏好类状态应监听它，保证多标签不各说各话： window.addEventListener(&apos;storage&apos;, (e) =&gt; { if (e.key === &apos;myblog:accent-theme:v1&apos;) applyAccent(e.newValue); }); 注意 storage 事件 不在改动的那个标签页自身触发 ，别把它当本标签页的更新通知用。 小结 localStorage 的&quot;本地优先&quot;是隐私与离线体验的红利，但也把数据治理责任从服务端搬到了浏览器。一条命名规范、一次读时迁移、一个清除入口、一个跨标签同步，就构成了个人站本地存储最小的可持续性方案——它保证三年后你改了这个存储结构，老访客回来时看到的是无缝升级，而不是丢失的书签和一个&quot;只有我能复现&quot;的 bug。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>localStorage</category>
          <category>前端工程</category>
          <category>数据迁移</category>
          <category>可用性</category>
        </item>
        <item>
          <title>长内容换行三件套：inline code、nowrap 胶囊与时间线轨道</title>
          <link>https://kairos.cn.mt/posts/mobile-overflow-wrap-triad/</link>
          <guid>https://kairos.cn.mt/posts/mobile-overflow-wrap-triad/</guid>
          <description>移动端横向溢出常由三类&quot;固有宽度&quot;内容撑破容器——无空格长代码、white-space:nowrap 标签、未设 minmax(0) 的网格轨道，逐一给出修复。</description>
          <content:encoded>移动端&quot;页面能横向拖&quot;几乎从不是布局本身错了，而是某个叶子内容拒绝收缩，把固定宽度一路上溯到根。本节内容是对一次全站横向溢出扫描（101 条路由、360px 视口）中定位到的三类真实成因的归纳。 一、无空格的长 inline code 正文里行内代码 document.querySelector(sel).match(...) 或 [&apos;pending&apos;,&apos;confirmed&apos;,&apos;invalid&apos;,...] 这类 没有可断点 的长串， word-break 默认按词处理、英文词内部不折行，直接顶破容器。 修复：只对&quot;非块级代码&quot;开启收缩，避免误伤代码块本身的横向滚动。 .article-content :not(pre) &gt; code { overflow-wrap: anywhere; / 允许在任意字符处断行 / word-break: break-word; } 块级 pre 保留 overflow-x: auto （代码要横向滚动而非折行），二者策略相反，所以选择器必须区分： :not(pre) &gt; code 。 二、white-space: nowrap 的胶囊/徽章 时间线、标签云里的&quot;上一篇：某某标题…&quot;链接常写 white-space: nowrap 配省略号。但 省略号生效的前提是元素能被压缩 ——flex/grid 项默认 min-width: auto ，等于&quot;不许小于内容宽度&quot;，nowrap + auto 最小尺寸 = 永不触发 ellipsis，反而撑宽整行。 .series-nav-link { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; min-width: 0; / 关键：允许收缩，ellipsis 才会真正生效 / } 配合容器 grid-template-columns: minmax(0, 1fr) ，胶囊在窄屏自然截断成 下一篇：Astro 内容系统如… 。 三、网格轨道没设 minmax(0) grid 单列若写成隐式列（或直接 display:grid 只给 gap ），其列宽最小值默认 auto ，会被任一子项的固有宽度顶开——上一节 select 撑破整页即此类。通用防御： .container { grid-template-columns: minmax(0, 1fr); } 凡是&quot;子项可能含不可收缩内容&quot;（select、长 code、nowrap 标签、图片、表格）的网格/flex，轨道一律 minmax(0, …) ，子项一律补 min-w-0 。 定位方法：谁在撑？ 不要靠肉眼。程序化扫描三步： // 1) 测页面是否横向溢出 const overflow = document.documentElement.scrollWidth - window.innerWidth; // 2) 若溢出，找&quot;右侧越界且祖先无裁剪&quot;的最内层元素 document.querySelectorAll(&apos;body &apos;).forEach((el) =&gt; { const r = el.getBoundingClientRect(); if (r.right &lt;= window.innerWidth + 4) return; let a = el.parentElement, clipped = false; while (a) { const ox = getComputedStyle(a).overflowX; if ([&apos;auto&apos;, &apos;scroll&apos;, &apos;hidden&apos;, &apos;clip&apos;].includes(ox)) { clipped = true; break; } a = a.parentElement; } if ( !clipped &amp;&amp; ![...el.children].some( (c) =&gt; c.getBoundingClientRect().right &gt; window.innerWidth + 4, ) ) { console.log(&apos;元凶:&apos;, el); // 叶子 + 未被祖先裁剪 = 真凶 } }); &quot;祖先裁剪&quot;的过滤很关键—— pre 本身 overflow-x:auto ，其内代码右边界超屏是 正常滚动 不是溢出；只有既越界、祖先又全不裁剪的最内层元素，才是需要修的点。 小结 横向溢出的修复从不是&quot;加个 overflow:hidden 盖住&quot;，而是找到那个拒绝收缩的叶子，给它一条明确的收缩契约：允许断字、允许压窄、轨道留下限。三类成因（长 code / nowrap 标签 / 默认网格轨道）覆盖了绝大多数线上所见即所得的&quot;手机能横着拖&quot;。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>CSS</category>
          <category>响应式</category>
          <category>溢出</category>
          <category>排版</category>
        </item>
        <item>
          <title>渐进增强不是口号：把&quot;关掉 JS 也能用&quot;落成检查项</title>
          <link>https://kairos.cn.mt/posts/progressive-enhancement-checklist/</link>
          <guid>https://kairos.cn.mt/posts/progressive-enhancement-checklist/</guid>
          <description>以静态优先博客为例，给出每个交互组件的无 JS 基线设计法——原生表单、真实链接、服务端渲染内容，把渐进增强从信念变成可验收的清单。</description>
          <content:encoded>&quot;渐进增强&quot;常被当作装饰性口号：说了，但没人验收。它的可操作定义其实很简单—— 禁用 JavaScript 后，站点的核心任务是否仍可完成？ 本文把它拆成一份能逐条勾选的检查清单，以内容站为例。 基线：无 JS 时，什么必须仍然成立 1. 所有内容可读 ：正文由服务端渲染进 HTML，不靠前端 fetch 填充（用 fetch 自取数据再注入 DOM 的做法，JS 一关就是白页——这是&quot;SSR 页面 fetch 自己静态文件&quot;踩坑的孪生问题）。 2. 导航可用 ：站内跳转是真实的 ，不是 onclick 里写 router.push 。增强脚本可以在点击时拦截走 SPA，但 href 本身必须是有效目标 。 3. 表单可提交 ：搜索、订阅、评论用原生 提交到服务端端点，JS 只是把体验升级成&quot;行内反馈&quot;。服务端端点要能独立处理并返回结果页。 4. 状态可见 ：收藏、进度这类增强若依赖 JS，无 JS 时应退化为&quot;该功能不显示&quot;，而不是&quot;显示一个坏掉的交互&quot;。 把增强做成&quot;加法&quot;而非&quot;替换&quot; 一个正确的订阅表单是这样分层的： 发送确认邮件 // 增强：拦截提交，改成 fetch + 行内反馈，失败则回退原生提交 form.addEventListener(&apos;submit&apos;, async (e) =&gt; { e.preventDefault(); try { const res = await fetch(form.action, { method: &apos;POST&apos;, headers: {...}, body: ... }); // 渲染成功/错误到 aria-live 区 } catch { form.submit(); // 网络/payload 失败：退回原生整页流程 } }); 关键在最后那行 form.submit() —— 增强路径失败时，交回基线路径 ，而不是把用户困在一个&quot;点了没反应&quot;的按钮上。渐进增强的精髓不是&quot;有 JS 更好&quot;，而是&quot;JS 挂了的瞬间自动降级回可用态&quot;。 内容渲染的&quot;服务端为准&quot; 静态优先框架（如 Astro）默认把内容构建进 HTML，天然满足基线。风险来自&quot;过度客户端化&quot;：为了动效把整页数据塞进 fetch + useState ，等于主动放弃无 JS 基线。判断标准—— 内容属于 信息 （文章、列表、导航）→ 服务端渲染，客户端只加动效； 内容属于 即时交互态 （输入校验反馈、拖拽结果）→ 才交给客户端。 把第一条守住的站点，JS 全关仍能当文档站用；把第二条误当第一条的站点，JS 一挂就白屏。 验收：一份可勾选的清单 每个交互组件上线前，在 DevTools 禁用 JS 逐条验证： [ ] 页面主体内容显示完整，无空白占位区 [ ] 每个导航项是可回车跳转的真实链接 [ ] 每个表单能用原生提交得到一个正确结果页 [ ] 报错路径不依赖 JS 才能看到提示（服务端渲染的错误页存在） [ ] 关键 SEO 文本（标题/描述/正文）在 view-source 里可见，非 JS 注入 这份清单跑通，&quot;渐进增强&quot;就从信念变成了可回归的工程事实。 小结 真正的健壮不是&quot;什么都能用&quot;，而是&quot;最坏情况下仍能用&quot;。把无 JS 基线设成验收项，你同时赚到的还有：搜索引擎友好、无障碍友好、弱网友好、以及&quot;某个脚本 404 了全站不崩&quot;的韧性。渐进增强从来不是怀旧，是用最便宜的方式买最贵的保险。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>渐进增强</category>
          <category>无脚本</category>
          <category>SSR</category>
          <category>可访问性</category>
        </item>
        <item>
          <title>让前端&quot;看得懂&quot;限流：429/503 的退避与文案分工</title>
          <link>https://kairos.cn.mt/posts/rate-limit-aware-frontend/</link>
          <guid>https://kairos.cn.mt/posts/rate-limit-aware-frontend/</guid>
          <description>当接口返回 429 或 503，前端该重试、等待还是提示？讲清按状态码分流、指数退避、Retry-After 利用与&quot;用户可修复 vs 不可修复&quot;的文案设计。</description>
          <content:encoded>个人站点的评论、点赞、订阅接口都会限流。多数前端把它们一律渲染成&quot;操作失败，请重试&quot;，于是用户对着一个&quot;再试也没用&quot;的按钮反复点击。限流响应的正确处理，是按状态码分流，而不是统一报错。 先看服务端给了什么信号 一个设计良好的接口，不同失败会给不同状态码 + 可选的重试提示： | 状态码 | 含义 | 前端应做 | | ------ | ---------------------------- | ---------------------------------------------------- | | 400 | 输入无效（用户可修复） | 提示改输入， 不 自动重试 | | 429 | 触发限流（等待可恢复） | 读 Retry-After ，禁用提交并倒计时，可选自动退避重试 | | 503 | 服务暂不可用（用户无法修复） | 提示稍后再试 / 换渠道， 不 误导用户改输入 | Retry-After 头（429/503 常带）是服务端明确告诉你&quot;多久后再来&quot;： HTTP/1.1 429 Too Many Requests Retry-After: 30 分流处理 async function submit(payload) { const res = await fetch(&apos;/api/subscribe&apos;, { method: &apos;POST&apos;, headers: { &apos;content-type&apos;: &apos;application/json&apos; }, body: JSON.stringify(payload), signal: AbortSignal.timeout(8000), // 关键：超时兜底，别让它无限挂着 }); const data = await res.json().catch(() =&gt; ({})); if (res.status === 429) { const wait = parseInt(res.headers.get(&apos;Retry-After&apos;) || &apos;30&apos;, 10); startCountdown(wait); // 禁用按钮 + 倒计时，防继续点 return show(&apos;操作太频繁，请 &apos; + wait + &apos; 秒后再试。&apos;); } if (res.status &gt;= 500) { return show(data.message || &apos;服务暂时不可用，请稍后重试。&apos;); // 不背锅给用户输入 } if (!res.ok) { return show(data.message || &apos;提交的内容似乎有问题，请检查。&apos;); // 4xx：用户可修复 } return ok(data); } 要点： 1. signal: AbortSignal.timeout(...) 是第一道防线 ——没有它，接口一卡，按钮永久停在&quot;正在提交…&quot;，比任何文案错误都伤体验； 2. 透传服务端 message 优先于前端硬编码 （服务端最懂真实原因）； 3. 429 要消费 Retry-After ，禁用 + 倒计时，把&quot;你别再点了&quot;变成可见的确定信息； 4. 5xx 不翻译成输入错误 ——这是真实踩过的坑（详见踩坑《订阅接口 503 被前端翻译成了&quot;邮箱格式不对&quot;》）。 要不要自动重试？ 幂等请求（GET 拉评论数）+ 5xx/网络错：可 指数退避 重试，上限 2–3 次； async function withBackoff(fn, retries = 3) { for (let i = 0; i &lt; retries; i += 1) { try { return await fn(); } catch (e) { if (i === retries - 1) throw e; await new Promise((r) =&gt; setTimeout(r, 300 2 i + Math.random() 100), ); // 加抖动 } } } 非幂等提交（POST 点赞/订阅）： 不要盲目自动重试 ，除非服务端支持幂等键。重复点击 + 自动重试 = 重复提交。宁可用户手点一次明确的&quot;重试&quot;。 退避要加 抖动 （jitter），否则多客户端会在同一时刻同步重试，把刚恢复的服务再打倒。 文案的三分法 给用户的错误信息按&quot;下一步能做什么&quot;分类，而不是按技术原因分类： 你能修的 ：说清楚改哪里（&quot;昵称不能为空&quot;），聚焦到出错字段； 等会儿就行的 ：给确定时间锚点（&quot;30 秒后可再试&quot;），别写&quot;稍后再试&quot;这种无信息量话； 你修不了、也不该你扛的 ：诚实说明是服务端问题 + 给替代渠道（&quot;也可用 RSS 关注&quot;），绝不暗示是用户操作失误。 小结 限流不是一个错误码，是一组需要区别对待的信号：超时要有 AbortSignal 、429 要读 Retry-After 并禁用倒计时、5xx 要如实归因、幂等才可退避重试。把&quot;操作失败请重试&quot;拆成这三类可执行提示，接口的限流机制才第一次真正为用户所用，而不是只服务于服务端自保。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>API</category>
          <category>前端</category>
          <category>错误处理</category>
          <category>限流</category>
        </item>
        <item>
          <title>发布前的最后一道程序：用脚本自动化&quot;人工走查&quot;</title>
          <link>https://kairos.cn.mt/posts/release-runtime-gate-automation/</link>
          <guid>https://kairos.cn.mt/posts/release-runtime-gate-automation/</guid>
          <description>把死链扫描、禁用术语检查、关键路由冒烟固化成可在 CI 与发布前重复执行的运行时门禁脚本，替代不可靠的手工点击。</description>
          <content:encoded>手工验收的悖论：你知道哪些页面值得点开看，所以你每次都只看那些页面；而出问题的从来是另一些。把发布前走查写成脚本之后，它每天看的页面比人多，而且从不疲劳。 什么样的检查适合写进&quot;运行时门禁&quot; 单元测试证明函数正确，构建证明编译可行，但还有一类问题只有 跑起来的站点 才能回答： 1. 每个 URL 是否返回预期状态 （导航目标、sitemap 条目、索引里的 href）； 2. 公开页面是否泄露内部词汇 （工程术语、阶段状态、密钥样式的字符串）； 3. 站内链接是否存在死链 （渲染后的 HTML 里抽 href，逐个请求）； 4. 核心接口冒烟 （搜索索引 JSON 可读、Feed 合法、健康检查 200）。 这些检查的共同点：规则简单、覆盖面越大越值钱、人工执行必漏。 骨架：从枚举到断言 一个可复用的运行时门禁脚本大致四层： // 1) 目标来源：sitemap + 页面内链 + 搜索索引 href，合并去重 const targets = collectFromSitemap(); targets.push(...extractHrefs(htmlOf(&apos;/&apos;))); // 首页是最大入口分发器 for (const url of new Set(targets)) { const res = await fetch(BASE + url, { redirect: &apos;manual&apos; }); if (![200, 301, 302, 307].includes(res.status)) report.dead.push([url, res.status]); } // 2) 禁用词扫描：公开路由的正文/标题/meta 命中即失败 const forbiddenTerms = [/MVP/i, /token/i, /内部阶段/, /staging 密钥/]; for (const route of publicRoutes) { const html = await getHtml(route); const hits = matchForbidden(html, { skipCode: true }); ... } // 3) 行为断言：关键路由必须含某些结构 expectHtml(&apos;/subscribe/status&apos;, /查询订阅处理结果/); // idle 态 expectHtml(&apos;/posts/&apos;, /文章/); // 4) 产物一致性：索引条目的每个 href 都在站点可达集合内 三个容易写歪的地方 其一：扫描范围里，&quot;渲染后的 HTML&quot;才算数。 直接读 public/ .json 或源码字符串会漏掉模板拼接出来的内容；起服务、发请求、解析响应体，才覆盖真实产物。本项目在 CI 与发布机各跑一次：CI 跑构建产物预览，发布机跑真实域名。 其二：禁用词扫描要有排除层，但不能有静默层。 命中 token 在代码块里是正常的（本文自己就出现过多次），所以扫描要跳过 / 内容；但 标题、摘要、导航文案命中即失败 ——这条曾经真实拦下过：新文章标题撞上了禁用术语，改的是文章，不是放宽规则。规则可以讨论粒度，不可以为了绿灯整体关闭。 其三：链接提取器要认识&quot;活的 URL&quot;。 现代页面里有大量 JS 注入的跳转（命令面板选项在插入 DOM 后才 setAttribute(&apos;href&apos;, …) ），把脚本源码里的字面量当链接会误报死链。两种收敛方式：只提取渲染后 DOM 的 a[href] （需要真实浏览器），或者像本项目一样把注入写法改成&quot;先插入后赋值&quot;，让静态提取天然看不到占位符。 让门禁可复跑、可追溯 失败输出结构化： { deadLinks, publicCopyHits, routeFailures, pass } ，一份 JSON 同时给人看和给 CI 断言； 报告落盘并归档（ /tmp 或 artifact 目录），复盘时能回答&quot;当时到底绿没绿&quot;； 退出码诚实：任何 fail 都非零退出；环境缺依赖时明确打印阻塞原因，而不是 || true 吞掉。 边界：门禁不能替你判断的事 脚本回答&quot;有没有死链/有没有违禁词/接口通不通&quot;，不回答&quot;这页好不好看、这篇文案有没有说服力&quot;。视觉走查、内容审核、交互手感仍然是人的工作——自动化的意义恰恰是把机械部分压到零，让人的注意力只花在机器做不了的事上。 小结 把发布前走查写成四件套：枚举 URL → 探状态 → 扫词汇 → 验索引一致性。它拦不住所有问题，但它拦住的每一个，都是你&quot;本来不会去点的那页&quot;。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>CI</category>
          <category>质量保障</category>
          <category>发布流程</category>
          <category>自动化测试</category>
        </item>
        <item>
          <title>自部署站点的响应头底线：HSTS、CSP 与缓存分层怎么配</title>
          <link>https://kairos.cn.mt/posts/security-headers-self-hosted-checklist/</link>
          <guid>https://kairos.cn.mt/posts/security-headers-self-hosted-checklist/</guid>
          <description>面向 Node standalone + Nginx 反代的个人站点，梳理一份可落地的安全响应头清单：各头的作用、顺序、坑与验证方法。</description>
          <content:encoded>安全响应头是最便宜的安全投资：一次配置，全站生效，且完全可以在无侵入的前提下逐步收紧。本文以&quot;Node 应用 + Nginx 反向代理&quot;的个人站点拓扑为例，给出一份经过验证的头清单与配置顺序。 分层原则：谁产生内容，谁负责应用级头；边缘负责协议级头 应用（Node/中间件） ：与内容相关的策略——CSP（是否允许 inline script）、缓存语义（no-store 的接口）、X-Content-Type-Options。 边缘（Nginx） ：与传输相关的策略——HSTS、HTTPS 重定向、点击劫持兜底、指纹屏蔽。 两边都可以设的头（如 X-Frame-Options ），以边缘为准、应用为辅， 不要在两层设置互相冲突的值 。 逐项清单 Strict-Transport-Security（HSTS） ——只在确认 HTTPS 稳定后启用，从小 max-age 开始： add header Strict-Transport-Security &quot;max-age=300; includeSubDomains&quot; always; 预检通过后再升到 max-age=31536000 。一旦上大 max-age，测试环境用 HTTP 将被浏览器强制跳 HTTPS 难以回退；若要让浏览器可预加载进内置列表，需要 ≥1 年 + includeSubDomains + preload 三条件齐备，慎之又慎。 Content-Security-Policy ——对个人站最常见的折中（Astro 会大量产出 inline 运行时脚本）： default-src &apos;self&apos;; script-src &apos;self&apos; &apos;unsafe-inline&apos;; style-src &apos;self&apos; &apos;unsafe-inline&apos;; img-src &apos;self&apos; data: https:; connect-src &apos;self&apos;; frame-ancestors &apos;none&apos;; base-uri &apos;self&apos;; form-action &apos;self&apos;; &apos;unsafe-inline&apos; 是务实起点而非终点：后续可用 nonce/hash 收紧脚本层。关键是先把 frame-ancestors 、 base-uri 、 form-action 这类 零副作用 的指令拉满——它们不关心 inline 与否，收益立现。 X-Frame-Options: DENY ——老浏览器的点击劫持兜底，与 frame-ancestors 并存不冲突。 X-Content-Type-Options: nosniff ——防止浏览器把响应猜成错误类型。零成本，必开。 Referrer-Policy: strict-origin-when-cross-origin ——对外链只泄露 origin，站内保留完整路径。 Permissions-Policy ——按实际功能关闭不需要的能力： camera=(), microphone=(), geolocation=(), payment=() Cache-Control 分层 ——静态产物长缓存要配内容哈希文件名；SSR 页面 public, max-age=0, must-revalidate ；API 响应（尤其健康检查、订阅状态）一律 no-store ，否则 401/429 会被缓存住造成&quot;幽灵故障&quot;。 顺序陷阱与验证 1. 重定向链上的头 ： add header 默认只挂在 2xx/3xx 的部分响应上，Nginx 里给跳转响应带头要用 always 参数，否则 HTTP→HTTPS 的 301 上没有 HSTS（浏览器此时还收不到 HSTS——这正确，HSTS 首次生效本来就要求一次成功 HTTPS）。 2. 中间件写头 vs 不可变响应 ：若应用框架给响应对象加头（如 Astro middleware 里 response.headers.set(...) ），注意某些运行时里 Response 头是不可变的，需要克隆后修改，否则线上直接 500——这类错误只在真实流量触发中间件时出现，构建期毫无征兆。 3. 验证三件套 ： curl -sI https://… 逐头核对；Mozilla Observatory 打分回归；上线前用 staging 域名试 HSTS（别拿主域直调 max-age）。 小结 响应头没有高深技巧，只有&quot;清单 + 分层 + 渐进收紧 + 每次都验证&quot;。它保护不了所有攻击面，但它消灭的是一整类最低级也最常见的洞——而且账单只有几行配置。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>安全</category>
          <category>HTTP</category>
          <category>Nginx</category>
          <category>自部署</category>
        </item>
        <item>
          <title>一个该进 sitemap、一个不该进：结果页与详情页的收录边界</title>
          <link>https://kairos.cn.mt/posts/sitemap-indexable-boundary/</link>
          <guid>https://kairos.cn.mt/posts/sitemap-indexable-boundary/</guid>
          <description>哪些 URL 该进 sitemap、哪些不该——从参数化结果页、SSR 借数据、noindex 策略三条线，讲清个人站的收录卫生。</description>
          <content:encoded>sitemap 不是&quot;站点所有路由的清单&quot;，而是&quot;希望搜索引擎当作文档来抓的清单&quot;。把两者混为一谈，会往索引里灌进一堆&quot;每次抓都是不同空页&quot;的噪音。 该进的：内容页 有独立信息价值、参数无关、稳定可重现的页面：文章详情、项目、标签、系列、工具页、关于。它们的判据是—— 同一个 URL 任何时候抓取，内容基本一致，且对用户有意义 。 不该进的第一类：参数化结果页 一次真实修复： /subscribe/status/ 被收录进 sitemap，但它本质是&quot;订阅操作的结果回跳页&quot;——内容完全由查询参数决定，无参数的抓取永远得到&quot;链接无效&quot;（见踩坑《&quot;查看状态页&quot;点进去永远显示链接无效》）。爬虫每抓一次都是一个不同的错误页，白白消耗抓取预算、还可能把&quot;链接无效&quot;当成正文索引。 判据： 去掉参数就没有意义的 URL 不进 sitemap 。若这类页确实需要存在（确认/退订回跳），给它 noindex ，或只在有参数时返回正常、无参数时返回引导态。 不该进的第二类：借数据但无独立价值的 SSR 路由 有一类路由技术上能访问，但对读者零价值：健康检查、内部预览、管理入口。它们要么 noindex ，要么本就不在 sitemap 里。 能访问 ≠ 该收录 。 本项目处理过一例： /weekly-digest （周报预览）本属后台工具，早期挂在公开路由，既无公开入口、又链向一个受保护的接口（访客点开得到裸 401 JSON），还带着大量工程术语——典型的&quot;误当作公开页&quot;。最终整体迁入 /admin/ 加会话守卫，公开路径改 404， noindex 也不再需要（整页不再是公开文档）。 noindex 与 sitemap 的分工 不想被收录 ： （告诉爬虫别索引）； 主动告诉爬虫哪些值得来 ：sitemap（ 列表）。 两者要一致：一个页面既 noindex 又出现在 sitemap 里是 自相矛盾 的信号（等于一边说&quot;别收我&quot;一边递名片）。健康检查页、结果回跳页若确需 noindex ，就要同时从 sitemap 移除。 一个务实的 sitemap 卫生清单 [ ] 只列内容页与真正公开的工具页 [ ] 排除：带参才有意义的结果页、健康检查、管理预览、内部 JSON/接口 [ ] 每个静态路由都对应一个真实可渲染页面（无参数访问不报错） [ ] noindex 集合与 sitemap 集合无交集 [ ] 详情类条目带 lastmod （内容更新触发重抓） [ ] 死链扫描纳入 release 门禁——sitemap 指向的每条都要 200 抓取预算：个人站也会踩 小站谈不上&quot;预算耗尽&quot;，但把无意义参数页喂给爬虫，换来的是一堆&quot;低质/重复内容&quot;抓取记录，稀释真正内容页的抓取频率，极端情况下让搜索引擎觉得站点信号混乱。卫生的 sitemap 是一种礼貌：你把&quot;值得看的页&quot;划清楚，爬虫就把力气花在那儿。 小结 sitemap 的每个条目都是一句&quot;这个 URL 值得被当作文档对待&quot;。参数化结果页、借数据路由、后台预览——它们要么不该收、要么该先&quot;去参数化才有意义&quot;的结构问题。判断口诀： 能脱离查询参数独立成立、且对读者有信息价值的页面，才配进 sitemap。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>SEO</category>
          <category>sitemap</category>
          <category>收录</category>
          <category>爬虫</category>
        </item>
        <item>
          <title>SSR 页面为什么不该向自己发 HTTP 请求：回环依赖与文件直读</title>
          <link>https://kairos.cn.mt/posts/ssr-self-fetch-loopback-risk/</link>
          <guid>https://kairos.cn.mt/posts/ssr-self-fetch-loopback-risk/</guid>
          <description>分析 SSR 页面在运行时 fetch 自身站点静态文件的隐性风险，给出按候选根目录直读 public 产物、HTTP 兜底并区分空数据与数据不可用的实现模式。</description>
          <content:encoded>问题模式：请求时向自己借数据 不少 SSR 站点有这类代码：页面在服务端渲染时需要一份构建期生成的 JSON（搜索索引、图谱数据、链接体检报告），于是直接在 setup 里写： const response = await fetch(new URL(&apos;/graph-data.json&apos;, Astro.url).toString()); const data = await response.json(); 它在本地开发几乎总能工作：dev server 正在运行， Astro.url 指向的就是它自己。但在真实部署链路里，这个请求要完整走一遍 nginx → Node 进程 → （可能再回到 nginx） 的路径，等于让服务端进程在响应请求的同时向自己发起新的请求。常见故障场景： 1. HTTPS 强制 + HSTS 回环 ：nginx 对 HTTP 一律 301 到 HTTPS，回环请求跟随后可能撞上自签证书或 localhost 不匹配 SNI 的握手失败。 2. 网络策略禁止 self-connect ：容器网络、防火墙或 systemd 沙箱（ IPAddressDeny 、 ProtectSystem ）可能直接拒绝进程连接自己的监听端口。 3. 无超时的悬挂 ： fetch 没有 signal ，链路任何一环卡住，整个页面渲染就卡住，最终由上游超时给出白屏或 502。 4. 错误伪装成空态 ： catch 后把数据置空，用户看到&quot;暂无数据&quot;，运维看日志毫无线索——这是最难排查的一类问题： 失败被渲染成了合法的空 。 修复模式：磁盘直读为主，HTTP 兜底 构建期生成物（ public/graph-data.json 等）在部署后就是磁盘上的静态文件， dist/client/ 里还有一份构建拷贝。服务端渲染要读它们，最直接的路径就是 fs.readFile ： // src/lib/public-data.ts export type PublicJsonResult = { data: T | null; ok: boolean }; export const readPublicJson = async ( fileName: string, selfUrl?: URL | string, ): Promise &gt; =&gt; { const roots = [ join(process.cwd(), &apos;public&apos;), join(process.cwd(), &apos;dist&apos;, &apos;client&apos;), ]; for (const root of roots) { try { const raw = await readFile(join(root, fileName), &apos;utf8&apos;); return { data: JSON.parse(raw) as T, ok: true }; } catch { // 换下一个候选根目录 } } if (selfUrl) { try { const response = await fetch(new URL( /${fileName} , selfUrl), { signal: AbortSignal.timeout(2500), cache: &apos;no-store&apos;, }); if (response.ok) return { data: (await response.json()) as T, ok: true }; } catch { // 自请求只是兜底，失败不再升级 } } return { data: null, ok: false }; }; 三个要点： 候选根目录按部署布局排列 。PM2 / standalone 通常以仓库根为 cwd ，此时 public/ 可直接命中；如果未来改为只发布 dist/ 的精简布局， dist/client 候选仍然成立。 HTTP 兜底必须带超时 。用 AbortSignal.timeout 给回环路径一个明确的生命周期，防止兜底本身成为悬挂源。 返回值携带 ok 标志 。让调用方能区分&quot;读到了，数据本来就是空的&quot;和&quot;没读到&quot;。 页面侧：把两种&quot;空&quot;分开渲染 const result = await readPublicJson (&apos;graph-data.json&apos;, Astro.url); const unavailable = !result.ok; const graphData = result.data ?? { nodes: [], edges: [] }; {graphData.nodes.length === 0 &amp;&amp; ( {unavailable ? &apos;数据暂时不可用，请稍后刷新重试。&apos; : &apos;暂无节点数据。&apos;} )} 对读者来说这是两句话；对排障来说这是一条分界线： &quot;不可用&quot;出现在日志和页面提示里，&quot;空&quot;才是内容问题 。书签页同理——体检数据读不到时按&quot;未检查&quot;降级，并明说&quot;收藏与打开不受影响&quot;，避免一个模块的数据故障伪装成整页功能损坏。 什么时候仍然可以用自请求 不是所有回环 fetch 都错。下面这些条件同时满足时风险可控：需要的是 只有 HTTP 层才有的语义 （缓存头、真实状态码探测）、请求目标是 独立服务 而非本进程、且调用方设置了超时与失败分支。只是读一份本地产物就用 HTTP，等于给最简单的路径人为加上网络这一层失败面。 小结 SSR setup 里 fetch(Astro.url 同域路径) 是隐性回环依赖，在强制 HTTPS、网络策略、沙箱化 systemd 环境中可能稳定失败。 构建期生成物请直接从 public/ 、 dist/client/ 候选路径读磁盘；HTTP 兜底必须限时。 永远区分&quot;数据为空&quot;与&quot;数据不可用&quot;，并在 UI 文案里体现出来——这是对读者诚实，也是对未来的自己友善。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>SSR</category>
          <category>Node.js</category>
          <category>部署</category>
        </item>
        <item>
          <title>构建期搜索索引：一个 20KB 的 JSON 为什么够用了</title>
          <link>https://kairos.cn.mt/posts/static-search-index-design/</link>
          <guid>https://kairos.cn.mt/posts/static-search-index-design/</guid>
          <description>中小内容站不需要搜索引擎——讲清构建期生成扁平 JSON 索引 + 浏览器端过滤的设计：结构、字段取舍、更新链路与失效边界。</description>
          <content:encoded>几百条以内的内容站，搜索不需要 Elasticsearch，不需要 Pagefind，甚至不需要后端接口： 一份构建期生成的 JSON + 浏览器里的字符串匹配 就是完整答案。本文讲这个极简方案的设计与它的失效边界。 索引结构：为&quot;匹配&quot;而非&quot;检索&quot;设计 { &quot;generatedAt&quot;: &quot;2026-09-02T12:00:00Z&quot;, &quot;items&quot;: [ { &quot;type&quot;: &quot;posts&quot;, &quot;title&quot;: &quot;构建期搜索索引&quot;, &quot;href&quot;: &quot;/posts/static-search-index-design&quot;, &quot;excerpt&quot;: &quot;讲清构建期生成扁平 JSON 索引…&quot;, &quot;tags&quot;: [&quot;搜索&quot;, &quot;架构&quot;], &quot;date&quot;: &quot;2026-09-02&quot; } ] } 字段取舍的原则： 只放&quot;会被匹配&quot;和&quot;会被展示&quot;的字段 ——标题、摘要、标签是匹配面；正文全文不进索引（体积爆炸且浏览器过滤无收益），需要正文检索再考虑分片或换方案； type 支撑分组展示（文章/项目/踩坑…）； href 是唯一出口，不存内部 ID； generatedAt 用于调试&quot;索引是不是旧的&quot;。 一个 20 条目、全中文字段的索引约 10–20KB gzip 后 3KB 以内——比一张缩略图小，这就是&quot;够用&quot;的量化依据。 生成链路：挂在 prebuild，构建即更新 { &quot;prebuild&quot;: &quot;npm run content:check &amp;&amp; npm run search:index &amp;&amp; npm run graph:data&quot;, &quot;build&quot;: &quot;astro build&quot; } 关键决策链： 1. 数据源是内容集合本身 （frontmatter + 摘要），不是渲染后的 HTML——生成脚本直接读 markdown，零抓取； 2. 草稿过滤在生成器里做 （ draft !== true ），而不是&quot;全量生成、前端隐藏&quot;——搜索索引是公开产物，任何非公开内容都不该出现在字节里； 3. content:check 排在 search:index 之前：坏 frontmatter 先失败，不产出半截索引； 4. 索引写入 public/ ，随构建发布，天然带内容级更新（下次部署即最新）。 浏览器端：两级消费 命令面板（全局）：fetch 一次、缓存到模块变量、 includes + 逐字符模糊打分，输入防抖 120ms 内完成几百条的过滤毫无感知。 搜索页（页面）：更进一步，把索引在 服务端渲染时直接内联进 HTML （ ），省掉首屏一次网络往返——静态索引 + 静态页面，本可以是一个请求。 内联与外置的选择标准：该页专属的数据（本页图谱、本页索引）内联；跨页复用的（命令面板索引）外置 + HTTP 缓存。 失效边界：什么时候这个方案会崩 诚实地列出适用上限： | 信号 | 阈值经验 | 应对 | | ----------------------- | ------------- | --------------------------------------------------- | | 条目数 | &gt; 2,000–5,000 | 索引体积 &gt; 500KB，分片（按类型/首字母）或上专用索引 | | 需要正文全文 | 任何规模 | 构建期分词（Pagefind 类）或服务端检索 | | 需要相关性排序/拼写纠错 | 视体验要求 | 换专用方案，别手写打分 | | 多语言词形变化 | — | 简单 includes 天然不支持，需分词器 | 中小站常年碰不到这些边界，而达到之前的每一分复杂度都是纯负债——这就是&quot;20KB JSON 够用&quot;的真正含义： 它不是将就，是这个规模下的正确架构。 一个必做的护栏 索引是&quot;第二份事实&quot;，会和渲染产物漂移（改了 frontmatter 没重跑生成）。防线两条：prebuild 强制重跑（构建即最新）；release 门禁里校验&quot;索引条目的每个 href 都存在于站点可达路由&quot;——本项目正是这条断言抓出过索引残留。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>搜索</category>
          <category>架构</category>
          <category>构建</category>
          <category>性能</category>
        </item>
        <item>
          <title>把状态放进地址栏：搜索、筛选与可分享 URL 的最小实现</title>
          <link>https://kairos.cn.mt/posts/url-as-state-search-sync/</link>
          <guid>https://kairos.cn.mt/posts/url-as-state-search-sync/</guid>
          <description>用 URLSearchParams 与 history.replaceState 给纯前端检索加可分享、可回退、可刷新恢复的状态，避免后退丢词与空白输入框的经典缺陷。</description>
          <content:encoded>纯前端检索的三种经典断点 一个把全部数据内联到页面、在浏览器里过滤的搜索/筛选页很常见。没有后端参与时，它通常有三个共通的断点： 1. 无法分享 ：&quot;看看这个搜索结果&quot;只能靠口头描述关键词。 2. 刷新即失忆 ：F5 之后输入框和结果列表回到初始状态。 3. 回退语义断裂 ：从结果点进详情页再按浏览器后退，列表页重新初始化，刚才的查询不见了——地址栏却仍然只写着 /search 。 根因只有一个： 查询状态只活在 DOM 里，没有同步到 URL 。 最小实现：读一次，写每次 页面初始化时从地址栏恢复状态： const initialQuery = new URLSearchParams(window.location.search).get(&apos;q&apos;)?.trim() || &apos;&apos;; if (initialQuery) { input.value = initialQuery; } render(initialQuery); 每次渲染时把状态写回去。注意这里用 replaceState 而不是 pushState ：用户连续敲字产生的中间态不该逐个进入历史栈，否则一次后退只会退回&quot;上一个没打完的词&quot;： const syncUrl = (query) =&gt; { const url = new URL(window.location.href); if (query) { url.searchParams.set(&apos;q&apos;, query); } else { url.searchParams.delete(&apos;q&apos;); } const target = ${url.pathname}${url.search} ; if (target !== ${window.location.pathname}${window.location.search} ) { window.history.replaceState(null, &apos;&apos;, target); } }; 在防抖后的 render(query) 入口调用 syncUrl(query.trim()) ，三行接入完毕。写入前先比较 pathname + search ，避免把同值 replaceState 变成无意义的噪音操作。 与客户端路由共存的两个坑 坑一：View Transitions / SPA 路由会缓存页面 DOM。 以 Astro 的 为例，站内导航默认缓存当前页 DOM，后退命中缓存时 不会重新执行页面脚本 。如果只写&quot;初始化时读一次 URL&quot;，从详情页后退回来时状态其实还在（DOM 没销毁），看起来正常；但若你的页面脚本会在每次 astro:page-load 重新初始化，就必须确认重初始化路径同样从 URL 读状态，而不是拿 DOM 的残留值当真值。两种生命周期模型下， &quot;URL 是唯一事实来源&quot; 都成立，只是恢复时机不同。 坑二： replaceState 不影响 popstate 。 它不会制造历史条目，因此回退行为保持干净：从详情页后退时命中列表页缓存，前进/后退跨查询时走正常的页面加载并重新解析 URL。 顺手补上的可访问性收益 状态进 URL 后，还有两个免费的好处： 结果计数容器加上 role=&quot;status&quot; aria-live=&quot;polite&quot; ，屏幕阅读器会在查询结果数变化时播报&quot;8 条结果&quot;； 浏览器自动补全开始认识你的查询词，地址栏输入站点名就能直达带参数的历史 URL。 边界与取舍 只同步值得分享的状态。 视图切换、滚动位置这类个人偏好放 localStorage 更合适；查询词、筛选组合放 URL。全部塞进地址栏会让 URL 难以阅读。 保持参数极简。 一个 q 足矣；把高亮位置、拼音开关等实现细节暴露为查询参数，等于给未来的重构挖坑。 空态也同步。 用户删光关键词时记得 delete(&apos;q&apos;) ，否则留下一个 /search?q= 的尾巴，看起来像坏了的分享链接。 小结 把&quot;当前在搜什么&quot;写进地址栏，本质是选择 URL 而非组件内部状态 作为事实来源。实现只需初始化时读、渲染时 replaceState 写，换来分享、刷新恢复与回退语义三项完整能力——这可能是所有前端页面里性价比最高的一次升级。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>前端工程</category>
          <category>History API</category>
          <category>搜索</category>
          <category>可用性</category>
        </item>
        <item>
          <title>View Transitions 在多模板博客里的实战分层</title>
          <link>https://kairos.cn.mt/posts/view-transitions-layered-motion/</link>
          <guid>https://kairos.cn.mt/posts/view-transitions-layered-motion/</guid>
          <description>用 Astro ClientRouter 做页面过渡时，如何按&quot;全局底座 / 页面专属 / 组件内部&quot;三层管理动画生命周期，避免监听器泄漏和动画叠加。</description>
          <content:encoded>以下内容为可公开验证的通用工程方法，示例均为&quot;假设 / 演示&quot;场景，不涉及真实生产数据。 一个容易被忽略的事实 Astro 的 开启后，页面切换不再触发完整的浏览器加载，而是走 View Transitions API（不支持的浏览器自动回退为 MPA 行为）。这带来一个经典问题： 同一个 不会因为&quot;看起来重新渲染了&quot;而重新执行。 内联脚本（ is:inline ）：随新页面 DOM 一起被换入，会再次执行。 打包模块脚本（普通 ）：ES Module 只执行一次，第二次进入页面只有事件不监听、DOM 是新的。 所以多模板博客的动效代码必须先回答一个问题： 这段代码应该在哪一层运行、由谁清理？ 三层分层模型 推荐把全站动效代码拆成三层，每层有明确的所有权： | 层 | 归属 | 生命周期 | 典型内容 | | -------- | -------- | ---------------------------- | ---------------------------- | | 全局底座 | 基础布局 | 首次加载执行一次，跨页面存活 | 滚动揭示、涟漪反馈、命令面板 | | 页面专属 | 单个页面 | 进入页面执行，离开时清理 | 星图物理模拟、文章专注模式 | | 组件内部 | 单个组件 | 组件挂载后自管 | 计时器、Observer、浮层 | 全局底座：用&quot;清理指针&quot;覆盖旧实例 全局脚本必须能在 DOM 被替换后重建与当前 DOM 的联系。一个可靠的模式是把清理函数挂在 window 上，每次执行先调用旧的： window. dsScrollRevealCleanup?.(); const observer = new IntersectionObserver((entries) =&gt; { // ... }); document.addEventListener(&apos;astro:before-swap&apos;, cleanup); window. dsScrollRevealCleanup = cleanup; 关键点有三个： 1. 脚本开头先执行上一次留下的 cleanup ，保证旧 Observer、旧计时器全部断开。 2. astro:before-swap 是最后的清理窗口，此时旧 DOM 还在，移除文档级监听器最安全。 3. 清理函数里既要 disconnect() Observer，也要 removeEventListener ，缺一都会在长会话中累积。 页面专属： is:inline + 清理指针的组合 页面专属动效（比如力导向图）建议使用 is:inline 脚本：它随页面换入而重新执行，等于每次进入都得到全新实例。配合清理指针，可以覆盖掉上一次的循环： window. graphExplorerCleanup?.(); // ... 初始化画布、物理循环、监听器 ... window. graphExplorerCleanup = () =&gt; { window.cancelAnimationFrame(frame); observer.disconnect(); document.removeEventListener(&apos;astro:before-swap&apos;, cleanup); }; document.addEventListener(&apos;astro:before-swap&apos;, cleanup); 不这样做的话，一个正在 requestAnimationFrame 循环里跑的物理模拟，会在离开页面后继续烧 CPU——画面没了，计算还在。 组件内部：谁注册，谁注销 组件脚本最常见的泄漏是把匿名函数同时用作注册和注销： // ❌ 这行什么都不做：箭头函数是新的引用 toggle.removeEventListener(&apos;click&apos;, () =&gt; setMode(false)); // ✅ 保存引用再注销 const handler = () =&gt; setMode(false); toggle.addEventListener(&apos;click&apos;, handler); // ... toggle.removeEventListener(&apos;click&apos;, handler); reduced-motion 不是开关，是分支 很多人把 prefers-reduced-motion 当成一个 CSS 媒体查询一刀切。对涉及 JS 驱动的动画，它是三条不同的代码路径： 1. CSS 部分 ：用 token 化的 duration/easing，在媒体查询里归零。 2. JS 循环部分 ：物理模拟、粒子、打字机这类逐帧逻辑，应该直接不启动 rAF，改为一次性计算终态（比如把力导向模拟同步跑 N 次迭代后画一帧）。 3. 交互语义 ：拖拽、缩放、平移属于直接操作而非装饰动画，reduced-motion 用户仍然需要它们，只是不要&quot;惯性滑动&quot;式的持续运动。 第三点最容易被做错：把所有动画都关掉，等于把功能也关掉了。 自检清单 发布前可以按这个顺序过一遍： [ ] 每个 requestAnimationFrame 循环都有停止条件（alpha 衰减、离开视口、页面隐藏）。 [ ] 每个全局脚本都有 window. xxxCleanup 指针，并在开头调用。 [ ] 页面隐藏（ document.hidden ）时动画循环暂停。 [ ] astro:before-swap 里注销了所有文档级监听器。 [ ] Canvas 尺寸跟随容器（ ResizeObserver 而不是只监听 window.resize ）。 [ ] reduced-motion 下逐帧动画不启动，但拖拽/缩放/点击功能完整。 小结 View Transitions 时代的前端动效，核心工程问题不是&quot;怎么让它动&quot;，而是&quot;它由谁拥有、什么时候停止&quot;。三层分层 + 清理指针 + 三分支的 reduced-motion 处理，可以让全站动画在长会话里保持稳定，也让每个页面的专属效果都有明确的归属。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>动效</category>
          <category>View Transitions</category>
          <category>JavaScript</category>
        </item>
        <item>
          <title>字体加载的取舍：CLS、FOUT 与&quot;其实你不需要自定义字体&quot;</title>
          <link>https://kairos.cn.mt/posts/web-font-loading-tradeoffs/</link>
          <guid>https://kairos.cn.mt/posts/web-font-loading-tradeoffs/</guid>
          <description>Web 字体如何造成布局偏移与文本闪烁，font-display/size-adjust/预加载的组合拳，以及系统字体栈在内容站上的合理性论证。</description>
          <content:encoded>字体是品牌感的重要来源，也是首屏性能与布局稳定性的经典重灾区。本文把这条链路的取舍讲透，最后给出一个可能反直觉的结论。 问题怎么发生 HTML 到达 → CSS 解析 → 发现 @font-face → 发起字体请求 → 下载中… → 就绪 → 替换 下载期浏览器按 font-display 描述符的行为分三种： block ：最多约 3s 不可见文本（FOIT），页面有字但看不见——体验最差； swap ：先用回退字体立刻渲染（FOUT），字体到达后 替换 ——可见闪烁； optional ：若网络快于约 100ms 用之，否则本次放弃——最克制。 真正的性能债在 swap 替换的那一刻 ：自定义字体与回退字体的字形宽度/高度不同，替换导致所有文本重排。发生在 LCP 元素上，直接吃 CLS。 组合拳：把 swap 的伤害降到接近无 1. 度量对齐（size-adjust） ——让回退字体假装自己是自定义字体的宽高： @font-face { font-family: &apos;Headings Fallback&apos;; src: local(&apos;Arial&apos;); size-adjust: 104.5%; / 匹配目标字体的平均字宽 / ascent-override: 90%; / 匹配行高度量 / descent-override: 20%; line-gap-override: 0%; } 回退与真实字体度量一致时，swap 前后布局不再偏移——现代在线工具（或 fontaine 库）可自动算出这些值。 2. 提前发现 —— 只用于 首屏关键 的单个字重；给五个字重全 preload 等于用带宽换焦虑。 3. 子集化 ——展示型标题字体只用到几十个字符时，unicode-range 子集能把 300KB 砍到 20KB。 4. 缓存 ——字体文件名带哈希、 Cache-Control: immutable ，回访用户零开销。 回到那个反直觉问题：你真的需要它吗 对个人内容站的冷静成本收益： 正文 ：系统字体栈（ -apple-system, Segoe UI, Roboto, system-ui + 中文回退 PingFang SC, Microsoft YaHei ）就是用户设备上已下载、已针对屏幕调校过的最优解。零请求、零 CLS、跨平台只是略有差异——而正文跨平台一致性对阅读体验并不关键。 代码字体 ： ui-monospace, SFMono-Regular, Menlo, Consolas, monospace 覆盖现代系统，无需下载。 标题/品牌字 ：这是唯一值得权衡的位置——一个子集化的展示字体，若品牌收益真实，配好 size-adjust 后其代价约等于一次 20KB 请求。 一个常见决定（也是许多内容站最终收敛的方案）： 正文与代码全系统栈，标题可选一款子集化展示字体 ——首屏只剩 HTML/CSS 本身，字体链路从关键路径上整个消失。这不是没有审美，是把审美预算花在刀刃上。 决策清单 [ ] 这个字体是&quot;品牌必需&quot;还是&quot;审美惯性&quot;？ [ ] 正文用系统栈是否完全可接受？（通常是） [ ] 必下的字体有没有做子集化 + size-adjust 回退配对？ [ ] font-display 是否明确设过，而不是跟着默认 auto 赌运气？ [ ] CLS 报告里有没有字体替换贡献的偏移？（DevTools → Performance → Layout Shift 记录可归因） 小结 字体优化的公式：能不下就不下（系统栈）；必须下就小（子集化）+ 快（预加载单个关键款）+ 稳（size-adjust 对齐度量 + swap）。内容站的性能洁癖最终大多收敛到同一个选择：把字体请求从&quot;性能问题&quot;降级成&quot;品牌问题&quot;——只有后者才值得付那几 KB。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>性能</category>
          <category>字体</category>
          <category>排版</category>
          <category>CLS</category>
        </item>
        <item>
          <title>aria-modal 拦不住 Tab：命令面板的焦点逃逸</title>
          <link>https://kairos.cn.mt/pitfalls/aria-modal-tab-escape/</link>
          <guid>https://kairos.cn.mt/pitfalls/aria-modal-tab-escape/</guid>
          <description>给 dialog 加了 aria-modal=true 就以为模态完成了，键盘用户仍能 Tab 进遮罩后的页面；命令面板焦点陷阱缺失的定位与补齐复盘。</description>
          <content:encoded>现象 命令面板做得很完整： Ctrl+K 唤起、上下键选择、Enter 跳转、Esc 关闭、 role=&quot;dialog&quot; aria-modal=&quot;true&quot; aria-labelledby=… 一个不落。无障碍审计工具也给了高分。但一位键盘测试者反馈：&quot;面板打开后我按 Tab，焦点跑到面板后面的导航栏上了，然后我迷失在一个看不见的面板外面。&quot; 根因 一个常见误解： aria-modal=&quot;true&quot; 只是给屏幕阅读器的语义声明，它不会阻止真实键盘焦点移出对话框 。原生 才有浏览器级焦点圈闭；用 role=&quot;dialog&quot; 手写的弹层，Tab 顺序仍按 DOM 全局遍历，面板后面的整个页面都在 Tab 序列里。 命令面板 DOM 通常只覆盖页面前部（挂在 body 末尾的浮层），其后紧跟的隐藏内容一旦可聚焦，Tab 就逃逸。同时面板若没把背景内容设 inert / aria-hidden ，读屏器用户也会读到&quot;面板 + 整个背景页&quot;。 修复：显式焦点圈闭 监听 keydown ，在面板打开时手动把 Tab 兜回内部： if (event.key === &apos;Tab&apos;) { const focusable = [ ...root.querySelectorAll( &apos;button:not([disabled]), a[href], input, [tabindex]:not([tabindex=&quot;-1&quot;])&apos;, ), ].filter((el) =&gt; el.offsetParent !== null); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = document.activeElement; if (event.shiftKey &amp;&amp; (active === first || !root.contains(active))) { event.preventDefault(); last.focus(); } else if (!event.shiftKey &amp;&amp; (active === last || !root.contains(active))) { event.preventDefault(); first.focus(); } } 要点： 每次动态查询可聚焦元素（结果列表会随输入重绘），不要缓存； 处理&quot;焦点已不在 root 内&quot;的兜底（Tab 到边缘时 document.activeElement 可能已逃逸到 body）； 换页/关闭时移除该 keydown 监听，避免 SPA 路由下重复叠加。 更彻底的方案 原生 + showModal() 自带焦点圈闭、背景 inert、Esc 关闭，是现代浏览器下更省心的选择。用 role=&quot;dialog&quot; 手写时，除了焦点陷阱还应把触发器所在的背景容器加 inert （或 aria-hidden=&quot;true&quot; + 禁用内部 tabindex），三件事齐全才算&quot;模态完成&quot;。 验证 自动化断言：打开面板 → 连按 12 次 Tab → 断言 document.activeElement.closest(&apos;#command-palette&apos;) 非空。任何一次逃逸都会被抓到。这个检查已固化进本项目的交互回归套件。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>可访问性</category>
          <category>键盘导航</category>
          <category>ARIA</category>
          <category>模态</category>
        </item>
        <item>
          <title>define:vars 的顶层 const 让客户端换页后工具页集体死亡</title>
          <link>https://kairos.cn.mt/pitfalls/define-vars-top-level-const-redeclare/</link>
          <guid>https://kairos.cn.mt/pitfalls/define-vars-top-level-const-redeclare/</guid>
          <description>Astro 内联脚本配合 define:vars 生成顶层声明，在 ClientRouter 下重复执行触发 SyntaxError，八个工具页交互一夜全灭的复盘。</description>
          <content:encoded>现象 站点启用 View Transitions 客户端路由后，实验室工具页首次访问一切正常；但从首页点进任一工具，或工具 A 切到工具 B 之后，页面渲染完整、样式正常，唯独交互全部失灵——按钮无响应、输入无反馈，控制台里躺着一条刺眼的 SyntaxError: Identifier &apos;config&apos; has already been declared 。 更迷惑的是复现条件：手动刷新（F5）立刻恢复；站内导航过去才复发。 排查 问题脚本都长这样： const config = toolConfig; // 后续几百行都依赖 config define:vars 的展开方式是把变量以 顶层 const 声明 插进脚本头部。View Transitions 的客户端换页不重建 document ，脚本体会在新页面里 重新执行 ——第二次执行时，上一个页面留下的同名顶层绑定仍在同一全局作用域，重复 const 直接抛 SyntaxError，整个脚本块从第一行起死亡，后面的逻辑一行都没跑。 这解释了全部症状：首次直访 = 首次声明，正常；站内导航 = 重复声明，抛错后整段脚本被跳过。 根因与修复 根因是 把脚本当模块用，却忽略了内联脚本共享同一个全局作用域 。修复分两步： 1. 数据与代码分离：配置改为 JSON script 标签注入，脚本体内不再有任何顶层 const 依赖外部插值： &lt;script type=&quot;application/json&quot; id=&quot;lab-config&quot; set:html={JSON.stringify(toolConfig)} (() =&gt; { const config = JSON.parse( document.querySelector(&apos;#lab-config&apos;)?.textContent || &apos;{}&apos;, ); // … })(); 2. IIFE 包裹一切：顶层作用域不再产生绑定，重复执行天然安全。 教训 客户端路由会把&quot;页面脚本只会执行一次&quot;的假设全部作废。 引入 时，应把所有内联脚本当作&quot;可能执行 N 次&quot;来审计。 define:vars 适合读一次就用的场景（内联样式变量、单一字符串），一旦脚本体在 SPA 导航中重复执行，它生成的顶层声明就是地雷。 验证手段：浏览器里点击站内导航切换同类页面，观察控制台是否出现 already been declared ；CI 中可用 Playwright 做跨导航冒烟测试——本项目后来把&quot;lab 跨工具导航无控制台错误&quot;固化为常规回归项。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>View Transitions</category>
          <category>调试</category>
        </item>
        <item>
          <title>订阅页承诺了&quot;踩坑频道&quot;，RSS 里却只有文章</title>
          <link>https://kairos.cn.mt/pitfalls/feed-channel-contract-drift/</link>
          <guid>https://kairos.cn.mt/pitfalls/feed-channel-contract-drift/</guid>
          <description>订阅表单提供多主题频道，但 RSS/JSON Feed 生成器只遍历文章集合，导致按频道订阅的读者永远收不到非文章内容的一次契约漂移复盘。</description>
          <content:encoded>现象 订阅页把频道列为&quot;文章更新 / 项目动态 / 踩坑记录 / 全部内容&quot;，页脚也引导读者&quot;不想用邮箱可以走 RSS&quot;。然而检查 RSS 输出时发现： rss.xml 和 feed.json 的 items 只来自 getPublishedPosts() ，完全不含已发布的踩坑——尽管踩坑数量当时已远不止零。一个明确承诺了&quot;多渠道内容&quot;的订阅入口，背后却只有一条内容管道。 根因 这是典型的&quot;多数据源契约漂移&quot;： rss.xml.ts 与 feed.json.ts 各自独立遍历 posts 集合（历史原因，文章最早存在）； 订阅频道列表定义在另一处（前端硬编码 channels ）； 没有任何机制保证&quot;频道承诺 ⊆ Feed 实际覆盖&quot;。 两边各自演进，契约悄悄错位——单测不会报，页面不会坏，只有用户发现&quot;订阅了踩坑却一条都收不到&quot;。 修复 统一两个 Feed 生成器的数据源为&quot;全部公开内容&quot;：合并 posts + pitfalls，按 updatedAt ?? publishedAt 排序。RSS 条目 指向 /pitfalls/ ， 用标签。修复后条目从 17 增至 22。 教训 &quot;频道&quot;是一个对用户的承诺 ，要么 Feed 包含它，要么订阅页删掉它——两者不一致比只做一个更糟。 多数据源 Feed 应该由一个共享选择器导出（例如 getPublicFeedEntries() ），避免每个协议文件手写一次。 契约检查最好变成自动化：订阅页的 channels 与 Feed 实际覆盖的类型集合应在构建期做一次一致性扫描（本项目把 Feed 纳入 release-gate 的产物检查项，后续可加这一步断言）。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>RSS</category>
          <category>Feed</category>
          <category>数据契约</category>
          <category>订阅</category>
        </item>
        <item>
          <title>一个 select 撑破整页：长 option 与网格轨道的 min-content 陷阱</title>
          <link>https://kairos.cn.mt/pitfalls/grid-select-min-content-blowout/</link>
          <guid>https://kairos.cn.mt/pitfalls/grid-select-min-content-blowout/</guid>
          <description>原生 select 的固有宽度等于最长 option 文本，作为网格项时会撑大整条隐式轨道，360px 下页面横向溢出 154px 的复盘。</description>
          <content:encoded>现象 探索地图页在桌面端一切正常；数据增长到 107 个节点后，360px 视口出现约 150px 的横向滚动。页面其余部分（星图画布、筛选 chips、节点列表）都做了换行与收缩处理，溢出元素探针却指向一个看似无辜的下拉框区域。 排查 用&quot;从 document.documentElement.scrollWidth 反查最右子叶元素&quot;的定位法，命中链落在路径起点/终点选择器上： 一个很长的中文标题… 三个 CSS 事实叠加成了事故： 1. 原生 select 的固有宽度由最长 option 决定 ，且没有可靠的截断样式（ text-overflow 对下拉框本体不生效）； 2. 网格的隐式轨道是 minmax(auto, 1fr) —— auto 的最小值就是子项的 min-content 宽度； 3. 子项（aside）未声明 min-width: 0 ，于是 select 的 498px min-content 一路向上传播，把本应 328px 的轨道撑到 498px。 lg 断点前它是单列，撑破的恰恰是最不该溢出的移动端。 修复 三处最小改动，缺一不可： / 轨道：显式允许收缩 / .series-timeline { grid-template-columns: minmax(0, 1fr); } … 下拉弹层里 option 文本过长仍由浏览器原生处理（弹层可滚动/超出），但页面级布局不再被绑架。 泛化清单 同类&quot;固有宽度炸弹&quot;还有几个惯犯： 长默认值、 nowrap 的胶囊标签、含长 URL 的 、 缺 max-width 。防御是成对出现的： 网格/flex 的每一层链路上，容器轨道用 minmax(0,1fr) ，子项配 min-width: 0 ，叶子控件自带 w-full max-w-full 。只要链条断一层，min-content 就会穿过去。 验证方式 比&quot;肉眼拖窗口&quot;更可靠的是程序化审计：遍历 sitemap，在 360px 下测 scrollWidth - innerWidth ，对超阈值页面反查&quot;右侧越界且祖先无 overflow 裁剪&quot;的最右叶元素——本项目把这一检查固化成了全量路由扫描，一轮就抓出了这个 select 和另外两处同源问题。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>CSS</category>
          <category>响应式</category>
          <category>布局</category>
          <category>表单</category>
        </item>
        <item>
          <title>window.location.href 悄悄旁路了你的客户端路由</title>
          <link>https://kairos.cn.mt/pitfalls/location-href-bypasses-client-router/</link>
          <guid>https://kairos.cn.mt/pitfalls/location-href-bypasses-client-router/</guid>
          <description>在启用 View Transitions/SPA 路由的站点里，命令面板与终端用 window.location.href 跳转会强制整页刷新，丢失过渡与内存态的复盘与锚点点击修复法。</description>
          <content:encoded>现象 命令面板（Ctrl/Cmd+K）、彩蛋终端的 open 、首页可交互终端——这些&quot;输入即跳转&quot;的交互，视觉上工作正常，但体验上有一处不对劲： 点击跳转时页面整页重载 。表现是 View Transitions 的淡入淡出动画没有出现，某些存在内存里的临时状态（比如本会话已展开的面板）被清空。 根因 这些跳转统一用了： window.location.href = target; 在 Astro （或任何 History-API 驱动的 SPA 路由）里，路由拦截器监听的是 click 事件与 popstate ，而不是 location.href 的赋值。直接改 location.href 属于&quot;编程式整页导航&quot;，浏览器会老老实实卸载当前文档、重新请求目标——路由的增量渲染层根本没机会介入。于是 SPA 退化成 MPA。 修复：合成锚点点击 让跳转&quot;看起来像用户点了链接&quot;，路由拦截器自然接管： const navigateTo = (href) =&gt; { if (!href) return; const link = document.createElement(&apos;a&apos;); link.href = href; document.body.append(link); link.click(); link.remove(); }; 点击一个同域 会触发路由的 astro:link-click /拦截逻辑，走增量换页；而 如果路由不可用 （首屏未水合、浏览器不支持），这次点击自动降级为普通导航，不会比 location.href 更差。零额外依赖。 对需要绝对 URL 的场景（如接口返回的 statusUrl ）同样适用，浏览器会解析 link.href 。 何时仍该用 location.href 不是所有跳转都要 SPA 化。这些情况 故意 整页刷新更合理： 跳转到站外/不同子域（本就该脱离路由）； 强制重载以拉取新构建产物（发布预览）； 登录/登出后需要清空所有内存态。 区分标准是&quot;目标是否在同一应用壳内&quot;。站内常规导航交给路由，跨边界或需要重置的场景保留 location.href 。 验证 一个便宜的回归断言：跳转前在页面 window. spaMarker = 1 ，跳转后读回——若仍是 1 说明文档没被卸载，走了 SPA 路径；若变 undefined 说明整页刷新了。本项目命令面板与终端跳转的回归测试就用了这个标记法。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>View Transitions</category>
          <category>导航</category>
          <category>调试</category>
        </item>
        <item>
          <title>「查看状态页」点进去永远显示链接无效：无参数路由的状态页陷阱</title>
          <link>https://kairos.cn.mt/pitfalls/parameterless-status-page-invalid-trap/</link>
          <guid>https://kairos.cn.mt/pitfalls/parameterless-status-page-invalid-trap/</guid>
          <description>状态结果页只按查询参数渲染，直接访问或从无参数入口进入时必然落入 invalid 分支，还被 sitemap 收录放大问题的一次复盘。</description>
          <content:encoded>现象 订阅页上有两个&quot;查看状态页&quot;入口，链接指向 /subscribe/status 。但状态页的实现是： const state = isSubscriptionState(urlState) ? urlState : &apos;invalid&apos;; 不带 ?state= 的直接访问全部落入 invalid 分支，标题赫然是 &quot;链接无效 / 无法处理&quot; 。也就是说，站点主动引导用户去点一个必然报错的页面。更糟的是这个无参数路径被收录进了 sitemap——搜索引擎抓回来的每一页都是错误提示。 根因 把&quot;操作结果页&quot;当成了&quot;状态查询页&quot;来设计。这个页面真实的语义只有一个： 渲染某次订阅操作的结果 ，结果信息全部来自重定向携带的查询参数。它没有&quot;查询当前订阅状态&quot;的能力（那需要后端凭 token 或邮箱，属于另一个功能的范畴），但入口文案&quot;查看状态页&quot;承诺的恰恰是后者。语义与承诺错位，实现忠实于语义，于是入口一碰就碎。 修复 新增 idle 引导态 ： state 参数缺失时不再判死，渲染使用说明——告诉访客这个页面会在校验邮箱、确认、退订后自动到达，并提供&quot;前往订阅 / RSS / 返回首页&quot;三个出口。 invalid 回归本义：参数存在但无法识别。 sitemap 移除该路由 ：带参数才有意义、且每次访问内容都不同的结果页，不是可索引文档。 顺带清理了同类问题：一个挂在公开路由的管理预览页（含受保护 API 链接，访客点击得到裸 401 JSON）整体迁入后台并加会话守卫。 教训 每一个能导航到达的 URL，都要回答&quot;用户不带任何上下文直接访问时，看到什么&quot;。 答案不应该是错误页——除非它真的是 404。 结果页 / 回跳页默认不进 sitemap；能进 sitemap 的页面必须是无参数可稳定渲染的。 状态映射建议至少留三态：缺参（idle 引导）、坏参（invalid 纠错）、好参（正常渲染）。二元判断是这类事故的温床。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>信息架构</category>
          <category>SEO</category>
          <category>可用性</category>
        </item>
        <item>
          <title>滚动进场动画把内容永久藏起来了：IntersectionObserver 的三个降级洞</title>
          <link>https://kairos.cn.mt/pitfalls/scroll-reveal-permanent-hide/</link>
          <guid>https://kairos.cn.mt/pitfalls/scroll-reveal-permanent-hide/</guid>
          <description>用 IntersectionObserver 做渐入揭示时，打印、脚本禁用、观察者漏触发三种情况会让内容永久不可见，配自愈与打印兜底的解法。</description>
          <content:encoded>现象 一个用 [data-scroll-reveal] 做&quot;进入视口渐入&quot;的列表页：正常浏览一切优雅。但三个场景暴露了问题—— 1. 用户 Ctrl+P 打印 或某些 PDF 快照工具抓取时，未滚动到的下方区块 整块空白 ； 2. JS 被扩展或网络问题中断时，元素停在 CSS 初始的 opacity:0 ，读者看到一片&quot;有字但看不见&quot;的区域； 3. 个别元素因布局时机（图片撑高后已全在视口外上方）永远不触发 IO 回调，永久隐藏。 根因是同一个： 进场动画默认把&quot;未揭示&quot;设成了视觉不可见，却没有为&quot;揭示逻辑没跑成&quot;留后路 。 修复三件套 一、打印兜底 ——打印媒体里强制全部可见： @media print { [data-scroll-reveal] { opacity: 1 !important; transform: none !important; } } 二、自愈定时器 ——任何环境下，超时未揭示就直接标记为已揭示，绝不永久隐藏： window.setTimeout(() =&gt; { document .querySelectorAll(&apos;[data-scroll-reveal]:not([data-revealed])&apos;) .forEach((el) =&gt; markRevealed(el)); }, 6000); 三、reduced-motion / 无 IO 直接静态可见 ——初始化时判定，不注册观察者： const prefersReduced = window.matchMedia( &apos;(prefers-reduced-motion: reduce)&apos;, ).matches; if (prefersReduced || !(&apos;IntersectionObserver&apos; in window)) { document.querySelectorAll(&apos;[data-scroll-reveal]&apos;).forEach(markRevealed); return; } 原则：动效是增强，不是内容显示的前提 一条通用铁律—— 内容的最终可见性不能依赖任何 JS 回调成功执行 。CSS 的&quot;初始隐藏态&quot;必须同时满足： 打印/PDF：覆盖为可见； JS 关闭/报错：要么初始就可见（渐进增强），要么有 或超时兜底； 减弱动效偏好：直接可见； 观察器异常：自愈超时。 把进场动画想成&quot;锦上添花的延迟出现&quot;，而不是&quot;内容的开关&quot;。开关永远握在无 JS 也能成立的路径里。 验证 浏览器开发面板切换 prefers-reduced-motion ，确认全部区块立即可见； 模拟 JS 抛错（控制台临时 throw），确认内容仍显示； 打印预览确认无空白块。这三条都能用 Playwright 固化（本项目在 QA 轮做了 6 页 reduced-motion 免滚动检查，即基于此）。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>动效</category>
          <category>IntersectionObserver</category>
          <category>降级</category>
          <category>可访问性</category>
        </item>
        <item>
          <title>SSR 页面 fetch 自己的静态文件，图谱在部署环境安静地空了</title>
          <link>https://kairos.cn.mt/pitfalls/ssr-self-fetch-empty-state-mask/</link>
          <guid>https://kairos.cn.mt/pitfalls/ssr-self-fetch-empty-state-mask/</guid>
          <description>服务端渲染时向自身 origin 发起回环请求读构建产物，被 HTTPS 重写拦截后错误落入 catch，页面把&quot;取数失败&quot;伪装成&quot;暂无数据&quot;的复盘。</description>
          <content:encoded>现象 图谱页在本地开发显示几十个节点，部署到 nginx + PM2 的服务器后变成&quot;暂无节点数据&quot;。构建全绿， public/graph-data.json 确实存在且内容正常，浏览器 Network 里却完全看不到这个文件的请求——因为读取发生在服务端，失败点藏在 SSR 里。 排查 页面 setup 里最初的写法： const response = await fetch(new URL(&apos;/graph-data.json&apos;, Astro.url).toString()); 这是让 Node 进程 向自己的站点发起 HTTP 请求 。链路上任何一环都可能让它失败：nginx 对 HTTP 一律 301 到 HTTPS 后回环证书校验不过、宿主网络策略禁止进程 self-connect、上游代理超时。而外层 catch 只有一行&quot;把数据置空&quot;，于是所有失败都呈现为同一种体面但致命的状态—— 空数据 。日志里没有错误，页面上没有异常，只有内容悄悄消失。 定位手法：在 SSR 进程里手动打同一条 URL 的日志与 NODE DEBUG=http 输出对比，再在 catch 里临时记录 error 名称，很快看到 unable to verify the first certificate ——HTTPS 强制回环撞上证书链校验。 修复 改为文件系统直读，HTTP 路径仅作带超时的兜底，并且把两种&quot;空&quot;分开渲染： // 候选根目录：开发态命中 public/，精简部署命中 dist/client/ const result = await readPublicJson (&apos;graph-data.json&apos;, Astro.url); const unavailable = !result.ok; UI 层： unavailable 显示&quot;数据暂时不可用，请稍后刷新&quot;，真空数据显示&quot;暂无节点数据&quot;。书签页的链接体检报告同步改造。 教训 永远不要让 SSR 进程通过自己的对外 URL 取本地文件 。磁盘直读没有网络这一层失败面。 catch 后置空是最危险的降级 ：它把故障渲染成合法状态，让监控、日志、用户三双眼睛同时失明。降级可以，但必须带上&quot;这是降级&quot;的可见信号。 凡是&quot;本地正常、线上空了&quot;的数据问题，优先怀疑取数路径在部署链路里多了什么环节（协议重写、证书、代理、防火墙）。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>SSR</category>
          <category>部署</category>
          <category>Nginx</category>
        </item>
        <item>
          <title>订阅接口 503 被前端翻译成了&quot;邮箱格式不对&quot;</title>
          <link>https://kairos.cn.mt/pitfalls/subscribe-503-misclassified-as-invalid/</link>
          <guid>https://kairos.cn.mt/pitfalls/subscribe-503-misclassified-as-invalid/</guid>
          <description>后端在数据库故障时复用了 invalid 状态码，前端按 state 映射文案，服务不可用被误导为输入错误的跨层契约复盘。</description>
          <content:encoded>现象 一次例行代码走查中发现：订阅接口的 catch 分支在数据库写入失败时返回 state: &apos;invalid&apos; + HTTP 503 + message: &apos;订阅服务暂时不可用，请稍后重试。&apos; 。而前端只按 state 映射两种文案： rate-limited → &quot;尝试次数过多&quot;，其余一律 → &quot;邮箱格式似乎不对，请检查后重新提交。&quot; ——服务端精心准备的 message 被整块丢弃。 后果：服务真挂了的时候，用户反复修改正确的邮箱地址，越改越困惑；&quot;稍后再试&quot;的引导完全没机会触达。 根因 两层各自的问题叠在一起： 1. 服务端状态复用 ： invalid 本意是&quot;输入无效&quot;，故障分支图省事也塞进了 invalid ，真实原因只活在没人读的 message 字段里。状态枚举 [&apos;pending&apos;,&apos;confirmed&apos;,&apos;invalid&apos;,...] 里没有一个表达&quot;服务暂不可用&quot;的取值。 2. 前端把映射写死成二元 ： state === &apos;rate-limited&apos; ? A : B ，隐含假设&quot;失败必是输入问题&quot;。 修复 前端： payload.message 存在时优先透传，否则按 response.status 兜底分桶（429 限流文案、≥500 服务不可用文案、其余输入文案）。 状态页：当服务端通过 redirect 带上 message 查询参数时同样优先展示，保证无 JS 的原生表单流也看到真实原因。 服务端（遗留建议）：为服务故障引入独立的 unavailable 状态值属于契约变更，按变更控制流程登记后再做。 教训 面向用户的文案要透传，不要在客户端重新发明。 服务端最清楚失败原因，前端的职责是渲染而非猜测。 错误状态枚举要能区分&quot;用户可修复&quot;与&quot;用户不可修复&quot;两类，前者引导改输入，后者引导等待或换渠道（RSS）。 走查这种问题最有效的手段：把接口的每一个失败分支在本地用 mock 打出来，对照前端实际显示——本项目随后用 Playwright 路由拦截把&quot;503 透传&quot;固化成了回归用例。</content:encoded>
          <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
          <category>API 设计</category>
          <category>错误处理</category>
          <category>订阅</category>
        </item>
        <item>
          <title>Astro 内容系统如何承载长期写作</title>
          <link>https://kairos.cn.mt/posts/astro-content-system/</link>
          <guid>https://kairos.cn.mt/posts/astro-content-system/</guid>
          <description>用 Content Collections 把文章元数据、草稿状态和构建期路由统一管理。</description>
          <content:encoded>为什么先做内容系统 个人博客最核心的资产不是页面壳，而是可长期积累的内容。Astro 的 Content Collections 适合把 Markdown 或 MDX 内容变成类型安全的数据源。 当前项目先把文章集合、列表页、详情页和归档页串起来，形成最小可用闭环。 元数据边界 文章 Frontmatter 需要稳定，后续 RSS、搜索、站点地图和知识图谱都会依赖这些字段。 title 用于页面标题和列表展示。 description 用于 SEO 摘要和卡片摘要。 publishedAt 控制排序和归档。 tags 支撑标签筛选和相关文章推荐。 draft 决定内容是否公开分发。 下一步 内容系统跑通后，可以继续补标签页、系列页、RSS、sitemap 和 Pagefind 搜索索引。</content:encoded>
          <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>内容系统</category>
          <category>TypeScript</category>
        </item>
        <item>
          <title>Astro 内容工作台</title>
          <link>https://kairos.cn.mt/projects/astro-content-workbench/</link>
          <guid>https://kairos.cn.mt/projects/astro-content-workbench/</guid>
          <description>围绕文章、笔记、项目、踩坑记录和搜索索引构建的内容生产辅助模块。</description>
          <content:encoded>Astro 内容工作台负责把博客的内容入口统一起来：文章提供深度沉淀，笔记记录短内容，项目承载工程履历，踩坑记录则用于复盘问题根因。 核心能力 使用内容集合 schema 校验 Frontmatter，减少发布时的元数据遗漏。 通过构建脚本生成搜索索引，让公开内容可以被站内搜索消费。 通过 sitemap、RSS 和 JSON Feed 提供搜索引擎与订阅分发入口。 通过项目关联文章字段，把项目复盘和技术文章连接起来。 适用场景 这个模块适合持续写作型博客：内容数量增加后，站点仍然能靠标签、系列、归档、搜索和项目关联关系保持可浏览。 后续计划 后续会把内容质量检查结果、项目复盘和知识图谱静态数据进一步打通，让内容从“能发布”升级为“可维护、可回溯、可探索”。</content:encoded>
          <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>内容系统</category>
          <category>SEO</category>
        </item>
        <item>
          <title>程序员奇趣博客</title>
          <link>https://kairos.cn.mt/projects/myblog-platform/</link>
          <guid>https://kairos.cn.mt/projects/myblog-platform/</guid>
          <description>基于 Astro 的个人博客、知识工作台和开发者实验室。</description>
          <content:encoded>这个项目会先完成内容系统、搜索、SEO 和订阅能力，再逐步加入评论、统计、后台、实验室工具和知识图谱。 当前阶段重点是让文章写作和阅读链路先稳定下来。 建设目标 让文章、笔记、项目和踩坑记录都由 Astro Content Collections 提供类型校验。 让公开页面优先静态生成，RSS、JSON Feed、sitemap 和搜索索引在构建期完成。 让实验室工具在浏览器本地运行，避免把用户输入上传到服务端。 让宝塔、PM2、Nginx 和 Node Adapter 的部署路径保持清晰。 当前状态 内容系统、标签、系列、归档、搜索、Feed、项目墙和实验室 P0-P1 工具已经形成基础闭环。下一阶段会继续接入动态互动、后台管理和知识图谱能力。 复盘重点 这个项目的关键不是把功能一次性做满，而是把每个模块做成能被构建、搜索、订阅和后续后台复用的稳定数据源。项目页会继续承载每个阶段的工程决策和踩坑记录。</content:encoded>
          <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>TypeScript</category>
          <category>自部署</category>
        </item>
        <item>
          <title>内容平台 MVP 闭环</title>
          <link>https://kairos.cn.mt/changelog/content-platform-mvp/</link>
          <guid>https://kairos.cn.mt/changelog/content-platform-mvp/</guid>
          <description>完成文章、标签、系列、归档、搜索、Feed、项目墙和实验室基础能力，站点进入可持续迭代阶段。</description>
          <content:encoded>发布摘要 站点已经从基础博客骨架推进到内容平台 MVP。公开内容可以通过文章列表、标签、系列、归档、搜索、Feed 和 sitemap 被访问和分发。 本次价值 写作内容有了类型校验和统一元数据。 读者可以通过多种入口发现文章、项目和工具。 后续动态互动、后台管理和知识图谱可以复用现有内容结构继续扩展。 下一步 继续推进不依赖服务端状态的静态能力，例如更新日志、书签导航、踩坑地图和知识图谱静态数据。</content:encoded>
          <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>内容系统</category>
          <category>站点建设</category>
        </item>
        <item>
          <title>静态更新日志上线</title>
          <link>https://kairos.cn.mt/changelog/static-changelog-launch/</link>
          <guid>https://kairos.cn.mt/changelog/static-changelog-launch/</guid>
          <description>新增 changelog 内容集合和更新日志页面，用于记录站点功能发布、文章重要更新和阶段成果。</description>
          <content:encoded>发布摘要 更新日志作为独立内容类型上线，后续每次功能发布、内容结构调整和重要文章更新都可以记录在这里。 本次价值 项目迭代过程有了公开、可追溯的时间线。 更新记录默认进入站点地图和搜索索引。 开发路线图中的阶段成果可以和真实页面互相校验。</content:encoded>
          <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
          <category>Changelog</category>
          <category>内容集合</category>
          <category>产品迭代</category>
        </item>
        <item>
          <title>第一条开发笔记</title>
          <link>https://kairos.cn.mt/notes/#first-note</link>
          <guid>https://kairos.cn.mt/notes/#first-note</guid>
          <description>用短笔记记录博客开发过程中的小决策和待办线索。</description>
          <content:encoded>短笔记适合保存还没有扩展成长文的想法，例如一次构建问题、一个组件命名、一次部署参数调整。 后续可以把高频出现的短笔记整理成正式文章或踩坑记录。</content:encoded>
          <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
          <category>笔记</category>
          <category>开发记录</category>
        </item>
        <item>
          <title>开发者实验室的工具优先级</title>
          <link>https://kairos.cn.mt/posts/developer-lab-roadmap/</link>
          <guid>https://kairos.cn.mt/posts/developer-lab-roadmap/</guid>
          <description>从 JSON、时间戳、URL 和 Base64 开始，先覆盖高频本地工具场景。</description>
          <content:encoded>工具页的判断标准 实验室不是功能堆叠，而是给日常开发提供低摩擦入口。第一批工具优先选择无需服务端参与、不会上传隐私数据、可以纯浏览器运行的能力。 P0 工具 JSON 格式化和压缩。 时间戳秒和毫秒识别。 URL 编解码。 Base64 文本编解码。 体验原则 工具页面应该启动快、反馈明确、错误信息可读。后续实现时会优先保留输入内容，避免一次错误导致上下文丢失。</content:encoded>
          <pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate>
          <category>实验室</category>
          <category>工具</category>
          <category>产品设计</category>
        </item>
        <item>
          <title>面向宝塔部署的 Astro SSR 思路</title>
          <link>https://kairos.cn.mt/posts/self-hosted-deploy-notes/</link>
          <guid>https://kairos.cn.mt/posts/self-hosted-deploy-notes/</guid>
          <description>Astro Node Adapter、PM2 和 Nginx 反向代理可以组成清晰的自部署路径。</description>
          <content:encoded>部署目标 这个博客不是纯静态演示站，而是计划支持评论、统计、后台和订阅能力。因此项目采用 Astro SSR 输出，并使用 Node Adapter 生成服务端入口。 运行链路 生产环境可以由 Nginx 接收公网流量，再反向代理到本地 Astro Node 服务。PM2 负责进程守护、开机自启和日志管理。 配置重点 SITE URL 决定 canonical、RSS 和 sitemap 的绝对地址。 MySQL 和 Redis 只通过环境变量传入。 构建后由 node ./dist/server/entry.mjs 启动服务。 这条路线和宝塔常见 Node 项目管理方式兼容，也方便后续加入健康检查和回滚流程。</content:encoded>
          <pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate>
          <category>Astro</category>
          <category>部署</category>
          <category>宝塔</category>
        </item>
        <item>
          <title>内容健康检查应该覆盖什么</title>
          <link>https://kairos.cn.mt/posts/content-health-checklist/</link>
          <guid>https://kairos.cn.mt/posts/content-health-checklist/</guid>
          <description>SEO、草稿泄漏、重复 slug 和图片 alt 是博客公开发布前的基础门禁。</description>
          <content:encoded>检查价值 内容站长期维护时，问题往往不是代码突然坏掉，而是文章元数据缺失、链接失效、草稿误发布或图片缺少替代文本。 第一批规则 公开文章必须有标题、摘要、发布时间、标签和分类。 草稿不得进入列表、搜索、RSS 和 sitemap。 slug 使用小写短横线，避免中文路径带来的分享兼容问题。 图片需要明确 alt 文本。 集成方式 内容健康检查适合先做成脚本，再接入 CI 和后台只读面板。error 阻断发布，warning 输出报告。</content:encoded>
          <pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate>
          <category>SEO</category>
          <category>内容质量</category>
          <category>CI</category>
        </item>
        <item>
          <title>程序员博客的写作工作流</title>
          <link>https://kairos.cn.mt/posts/writing-workflow-for-programmers/</link>
          <guid>https://kairos.cn.mt/posts/writing-workflow-for-programmers/</guid>
          <description>把技术文章、踩坑记录和项目复盘拆成不同节奏，降低长期更新成本。</description>
          <content:encoded>内容类型要分层 程序员博客很容易因为文章标准过高而停更。更稳的方式是把内容分成不同颗粒度：长文、短笔记、踩坑记录和项目复盘。 推荐节奏 技术文章适合沉淀完整思路。 短笔记适合记录当天解决的问题。 踩坑记录保留现象、根因和修复方案。 项目复盘展示工程判断和取舍。 和站点能力结合 当内容类型稳定后，标签、系列、归档、搜索和知识图谱才能真正发挥作用。站点结构应该服务写作习惯，而不是反过来增加维护负担。</content:encoded>
          <pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate>
          <category>写作</category>
          <category>知识管理</category>
          <category>博客</category>
        </item>
  </channel>
</rss>