
最近给博客做的优化
上一篇《博客重构》里,我把目标写得很明确:完成基础设施,把精力转回内容。博客确实能用了,修改却没有停。搜索框里的状态、手机上的目录、切换页面时的代码高亮,每个地方单看都不大,连起来却足够再折腾一轮。
这也不完全是闲不下来。有些问题只有放进真正的阅读过程才会出现:输入拼音时要不要立刻搜索,点完目录后面板要不要收起,页面开始切换了,代码高亮却还没准备好。组件单独看都能用,放在同一页里,仍然有不少地方需要磨合。
这一轮基本没有改变博客的用途,更多是在整理前一次重构留下的细节。先列一下范围,后面再说具体为什么改、改到哪里停下来。
| 功能 | 这一轮主要解决什么 | 最后承担的代价 |
|---|---|---|
| 内容与构建 | 统一文章、配置和派生数据的出口,明确开发与生产模式 | 构建脚本更定制,依赖内部接口 |
| 本地搜索 | 加载索引、输入法、URL 和结果状态相互协调 | 自己维护 History API 订阅,索引仍需整份下载 |
| 代码与图表 | 让正文展示少依赖浏览器临时计算 | 计算移到构建端,输出体积和构建依赖仍在 |
| 阅读与导航 | 桌面布局、手机目录、标签和图片各自适配 | 收起部分信息,承担更多交互与样式维护 |
| 页面过渡 | 协调路由提交、浏览器快照和异步内容 | 自维护过渡逻辑,放弃部分预取收益 |
| SEO | 补齐页面发现和元数据 | 外部接口与站内参数仍需统一 |
一、内容与构建:包拆开以后,职责还要继续拆
1.1 已经有数据层,但边界没有自动变清楚
Vacuum 早就采用 Monorepo,把 Markdown 处理放在独立包里,由 Next.js 负责页面。Content Collections、Zod 和 MDX 也都是既有选择。这一轮没有重新发明静态博客生成器。
这个基础很有用:文章保存在仓库里,元数据可以校验,页面不用直接理解 Markdown 的所有细节。但有了独立包,不等于数据职责就已经集中。搜索算法、标签处理、目录提取、站点配置和构建入口,仍然可能分散在不同位置。
问题会在小修改里出现:同一篇文章要出现在卡片、搜索结果和订阅源里,如果这些地方各自处理一次 Markdown,链接、行内代码和换行的处理规则就可能逐渐分叉。改正文时看不出来,等到某个入口显示异常,才发现同一份内容有几种解释。
草稿也是一样。文章列表过滤了草稿,搜索索引和订阅源却未必跟着过滤。与其要求每个页面都记住这些约定,我更希望在生成数据时就处理好,让后续入口使用同一批文章。独立包的价值,得落到这些实际约束上。
1.2 配置、文章目录和公共算法归位
博客配置迁移到了 YAML,订阅源和文章 Schema 也一起整理。YAML 在这里主要是方便维护站点资料,字段是否正确仍交给 Zod 校验;换个配置文件格式,并不会自动解决数据边界问题。
更主要的调整,是把内容集合、元数据、目录,以及搜索和标签逻辑收回 Vacuum,再拆出 client、server 两个入口。浏览器要用搜索算法,但没有必要因此接触文章编译和订阅源生成。入口分开之后,调用处也更容易看出自己依赖的是哪一部分。
现在的 PostCatalog 统一提供文章、分类、标签、搜索数据、订阅源和静态路由参数。正文格式化负责日期、摘要、路径与目录等派生信息。浏览器侧搜索需要的算法,则可以从 @vacuum/vacuum/client 引入。
页面现在主要负责决定展示什么,数据如何形成尽量留在一个地方处理。这样以后改摘要规则,不必挨个查找哪些页面又自己截取了一遍正文。
不过,数据层里也保留了不少本站自己的约定。例如分类 ID 由排序后的位置生成,文章路径来自内容文件路径。文件移动可能改变文章地址,分类顺序变化也可能影响对应关系。对于自己维护的博客,这些规则足够直接;真要重新整理目录,就得同时考虑旧链接,不能把文件移动当成一次无关紧要的重命名。我暂时没有再加一套永久 ID 和迁移机制,先保留这个限制。
1.3 开发模式从猜环境,变成显式参数
构建流程也随之调整。之前围绕生产标记、环境变量和开发模式做过几次修正,最后还是把判断写得直接一些:脚本检查 --dev,有这个参数才监听内容和打包变化,否则按生产模式生成配置。
这样至少不用再让「没有设置 NODE_ENV」同时承担几种含义。生产构建和开发监听有一个明确入口,草稿过滤也可以沿站点的生产标记执行。
构建脚本也因此需要自己维护更多细节。它直接调用 Content Collections 的 createInternalBuilder,自行接入事件、错误处理、配置生成和打包。以后升级依赖,除了看公开配置是否变化,还得确认这条内部调用链仍然成立。
目前配置文件只在脚本启动时解析一次。改文章可以触发监听,但改 YAML 之后仍要重启构建,不能因为终端里有一个 watch 进程,就以为所有输入都能热更新。
把配置监听补上当然可以,只是还要安排重新校验、产物写入,以及消费配置的包何时重建。半套热更新更容易造成误会:文件改了,页面还拿着旧值,最后反而去查页面代码。站点配置远没有正文改得频繁,所以这里先接受重启,保留一条容易确认的更新路径。
二、本地搜索:难点逐渐从匹配文章变成管理状态
2.1 一份 JSON,浏览器本地匹配
搜索继续沿用构建时生成 search.json、浏览器下载后匹配的方案。它不需要单独运行搜索服务,文章规模有限时也容易理解和排查。
妥协同样明确:首次搜索需要拿到整份索引,文章越多,传输、解析和本地扫描的成本越高。现在的匹配和权重算法仍是字符串搜索与规则排序,没有分词、语义检索或成熟搜索引擎的相关性模型。
我暂时还愿意接受这个方案。博客里的搜索,很多时候是带着一个技术名词、文章标题片段或标签回来找旧文,字符串匹配至少容易解释为什么命中。遇到同义词、拼写偏差或者只有模糊印象的查询,它就没有那么好用,这些也不是调几个权重就能补齐的。
以后如果真的慢了,还要分清慢在哪里。下载索引慢,需要看索引内容和传输体积;输入时卡顿,需要看解析、扫描和排序;结果不相关,才更接近检索算法的问题。把计算放进 Worker 可以减少对主线程的占用,却不会让索引变小。现在先把加载、输入和结果状态理顺,比一上来更换搜索引擎更贴近这一轮的问题。
2.2 先处理输入与加载
搜索页至少要分清几种情况:还没输入、索引正在加载、已经搜过但没有结果,以及索引加载失败。它们都可能表现为空白,但读者接下来应该做的事完全不同。加载中应该等,没有结果可以换个词,加载失败则需要重试。不能都用「没有找到文章」糊过去。
这轮先整理状态展示、结果层次、URL 更新防抖和标签筛选,再调整数据加载。
当前加载器同时缓存结果和进行中的 Promise。多个消费者不必各发一遍相同请求,离开再回来也可以复用已经解析的数据。失败后会清掉进行中的 Promise,后续挂载可以重试。
缓存进行中的 Promise,是为了处理「请求还没回来,另一个组件也需要数据」的情况。只缓存最终结果,挡不住这段时间内的重复请求。组件离开页面后,也只停止更新自己的状态,共享请求仍可以完成,留给其他消费者使用。
这份缓存只存在于当前页面运行期间,没有跨版本的索引更新协议。长时间不刷新页面,可能继续使用旧索引;请求失败后虽然可以在后续挂载时重试,当前 Hook 也没有暴露独立的错误状态。恢复能力还不完整,后面应当补清楚失败提示和主动重试入口。暂时没有加持久化缓存,是因为缓存得更久之后,还要回答新文章什么时候能被搜到。
输入还要尊重中文输入法的组合过程。拼音没选字时,输入框已经变化,但它未必是用户准备提交的搜索词。如果每次变化都写入 URL、重算结果,用户还在选字,页面就先搜了一遍拼音。
防抖不能完全解决这个问题。防抖只知道一段时间内有没有继续输入,不知道候选词是否确认;选字时稍微停一下,计时器一样会到期。所以组合事件和防抖各管一件事:前者判断输入是否完成,后者减少完成输入后的密集更新。英文逐字输入顺畅,并不足以说明搜索框已经做好了。
2.3 从同步多份状态,走向 URL 作为来源
加载理顺以后,另一个问题是查询条件到底听谁的。结果页做过简化,也加过 Suspense 包装,但这些调整并没有消除 URL 与组件内部状态之间的同步。输入框里有字、索引已经加载、地址栏参数已经读到,仍然是几件不同的事。
中间方案保留本地 query、tag 和就绪标记,再通过一个读取 Next.js 搜索参数的组件同步回来。它能把同步过程接起来,但也意味着同一份查询条件同时存在于 URL 和 React state 中。多维护一个状态,就要多解释一次它何时更新。
在这之后,搜索参数改为自定义订阅:通过 useSyncExternalStore 读取 window.location.search,直接派生关键词和标签,随后删除 isReady。
现在关键词使用 s,标签使用可重复的 t。例如 /search/?s=postgresql&t=PostgreSQL,既是可以分享的地址,也是页面查询条件的来源。输入框通过 effect 跟随查询词,后退时也能回到对应条件。
这里仍然允许输入框保留编辑中的内容,特别是输入法尚未确认的文字。需要消除的是两份互相同步的已生效查询条件,而不是把每一次按键都强行变成 URL。分享链接和后退恢复应该对应一次搜索,输入过程则要让人正常打字。
换来的问题是 History API 不会自动替这套订阅发完所有通知。项目包装了 pushState、replaceState,并监听 popstate,还要在取消订阅时尽量恢复原方法。包装和清理需要与其他使用 History API 的代码协调,避免互相覆盖。
当前更新查询还会调用 pushState。它保留了历史记录,但频繁更新也可能让后退经过多个搜索条件。比如搜完一篇文章之后想回到列表,却先退回少了几个字的搜索词,这就不符合直觉。
我还需要区分编辑过程和明确的导航动作:连续输入是否用 replaceState,切换标签或确认搜索是否再留下历史记录。防抖只能减少记录数量,不能决定哪些记录有意义。把查询条件集中到 URL,只是让这件事更容易修改,并没有替我做完这个判断。
三、代码与图表:先延迟计算,最后改变计算的位置
3.1 Shiki 回来了
上一篇重构文章记录过放弃 Shiki、选择 highlight.js 的过程。当时的判断围绕体积和使用成本。现在重新用回来,主要是使用方式变了。同一个引擎,放在哪一端、加载哪些语法,成本并不一样。
这一轮先把代码高亮重新接入 Shiki,通过 Mantine 的适配器使用。这个阶段采用 JavaScript 正则引擎,动态加载高亮器、主题和一组语言。
高亮器延后初始化,可以避免在入口直接完成全部工作,但用户仍可能先看到未高亮的代码,再等待语法和主题准备好。如果高亮前后文字宽度、换行或容器高度又不一致,读者已经开始看代码,页面还可能再动一下。延后初始化减少了入口的工作量,这部分工作最终仍然要在阅读过程中完成。
3.2 进一步按语言加载
随后一轮懒加载调整把高亮器初始化和语言加载分开,起初不装载任何语法,需要时再按语言加载,并维护 ts、sh、yml 等别名。
这种设计能省掉用不上的语法。例如一篇只有 Go 代码的文章,没有必要提前加载 Vue。但随之也要管理引擎是否就绪、语言是否就绪、别名对应哪个语法,以及加载失败后的显示。多个代码块使用同一种语言时,还应该复用同一个加载过程。
支持的语言越多,按需加载越有吸引力;每篇文章只需要少数几种语言时,首次遇到这些语言的等待又会更明显。单看入口包体积会觉得优化完成了,真正点开一篇文章,才轮到这些分支逐个执行。
对一个语言种类有限、正文在构建前已经确定的博客来说,这些运行时分支开始显得昂贵。读者并没有现场提交任意语言的代码,我们却在浏览器里维护一个动态高亮流程。
3.3 从安排加载时机,转向构建时高亮
按语言加载之后,还尝试过等页面过渡结束、浏览器空闲后再加载 Shiki。这说明高亮已经与页面动画发生了时序上的耦合:为了不让一项展示能力干扰另一项展示能力,又需要增加协调逻辑。
随后完成的高亮重构改变了边界。highlightCode 标记为 server-only,异步 MDX 组件先生成高亮 HTML,再交给 UI。本站采用静态导出,因此这部分工作落在构建阶段。引擎也换成 Oniguruma,使用内联 WASM,显式加载支持的语言和明暗两套主题。
这里仍然保留 Mantine 的代码组件。代码窗口、复制和展开等展示交互继续使用它,替换的是高亮计算与结果传入方式。
正文代码块现在不必再等浏览器下载 Shiki 语法并计算结果。明暗主题的颜色也一起写入输出,当前使用 light-dark() 表达两套颜色,切换配色无需重新解析语法。
不过,浏览器少下载一份引擎,不代表整篇文章的传输体积一定按同样幅度下降。原本的一段纯文本变成了带有 token 标记和颜色的 HTML,长代码块的输出也会增加。引擎可以跨文章复用,高亮结果却随文章分别输出,具体省了多少仍然要看产物。
构建端同样要承担语法解析,支持新语言也要修改列表;未知语言退回纯文本,至少不会因为一个语言标记让正文无法展示。单行代码还有独立展示分支,不能把所有代码路径都概括成经过同一个服务端函数。
我愿意做这个交换,是因为博客正文在构建前就已经确定。读者打开多少次,都没有必要重新识别一遍同样的语法。在线编辑器会不断接收新代码,运行时高亮有它的用途;本站的代码块主要用来阅读,提前算好更合适。
3.4 Mermaid 沿用静态输出,补渲染细节
Mermaid 早已通过 rehype-mermaid 在内容构建时生成 SVG。这轮没有改变这个方向,主要修正图表布局和标签裁切;当前配置使用 inline-svg,关闭 HTML 标签。
图表生成成功,只能说明语法能够被处理,不代表标签一定完整。节点尺寸、文字测量和 SVG 的可见范围要对得上,边缘的文字才不会被裁掉。尤其是图表里有长标签时,调整外层容器宽度未必能补救生成阶段就已经算错的范围。
方向与代码高亮相近:读者拿到可展示的结果,构建环境负责启动浏览器完成图表生成。代价是 Playwright 和 Chromium 进入构建依赖,图表输出也受渲染配置约束。关闭 HTML 标签减少一种布局来源,同时放弃这条路径上的 HTML 标签表现力。
这也让图表问题更依赖构建环境。浏览器能否启动、字体是否可用、依赖升级后输出是否变化,都需要在发布前发现。好处是发现一次、修好一次,读者拿到的就是修正后的结果,不必在各自设备上重新经历渲染。
但预生成 SVG 也留下了主题适配问题:页面颜色可以在打开后改变,图表里的颜色却已经在构建时确定。夜间模式为什么还没一起补上,后面单独说。
四、阅读与导航:桌面能放下,不代表手机也应该全放下
4.1 先修布局规则
阅读体验最初有一批看起来很碎的修改:移除不起作用的内容滚动,约束桌面 sticky 侧栏,对齐断点和间距,让锚点避开页头,拆开文字链接与整张卡片的链接样式,跟随系统配色,并补充悬停和焦点状态。
这些地方需要配合起来看。正文在哪个容器滚动,会影响 sticky 侧栏;页头占了多少空间,又会影响锚点定位和当前章节的判断。点目录后标题正好藏到页头下面,代码并没有报错,读者却还得手动往回挪一点。
所以先统一滚动容器、断点和间距,再调整各个组件。否则每个组件都补一个偏移值,看起来解决了眼前的问题,换个屏幕宽度又对不上。
这一轮仍然保留 Mantine,再用主题配置和 CSS Modules 收拢本站样式。它减少了从头实现基础控件的工作,也意味着要理解组件默认样式、层叠顺序和项目覆盖规则。随后改用 Mantine 的分层样式,就是在继续处理这个边界。
4.2 手机导航从抽屉走到文章目录面板
手机导航最初是把侧栏放进抽屉,后面才逐渐整理成移动操作区和文章目录面板。桌面侧栏有足够空间同时容纳站点导航和目录,手机上照搬,就容易让一次找章节的操作变成打开整套导航。
文章页的目录可以更直接一些:阅读时收起来,需要跳转时从底部展开。这样既不用长时间挤占正文宽度,操作入口也能保持在相对固定的位置。
目录组件接收构建时生成的标题数据,用作初始目录;页面加载后,再通过 Mantine 的目录组件追踪 #vc-content 内的标题。标题深度决定缩进,每深入一级增加 12px,点击时调用对应节点的 scrollIntoView 平滑定位。滚动追踪设置了 76px 的偏移,配合前面处理的标题锚点间距,让当前章节的判断与页头布局相协调。
桌面和手机复用这份目录,变化主要由 CSS 控制。桌面上目录在侧栏正常展示;手机上改为固定在底部的面板,默认向下移出视口,同时关闭指针事件并隐藏透明度。展开后恢复位置和可点击状态,再铺上遮罩。面板最高限制为 min(80dvh, 36rem),目录太长就在内部滚动;底部间距加上安全区,避免挤到手机的系统操作区域。
目录按钮只管理展开状态,并通过 aria-expanded 和 aria-controls 描述它与面板的关系。点击遮罩会关闭面板,点击目录区域也会关闭:选完章节,屏幕就重新留给正文。不过当前关闭事件挂在整个面板上,点到目录空白处同样会收起,这比只在选中章节后关闭更粗一些。
这种布局换来了正文空间,也让找章节多了一次点击。当前隐藏主要依赖样式,没有单独实现焦点转移、焦点约束或 Escape 关闭;仅有透明度和指针事件控制,也不能保证隐藏内容退出键盘焦点顺序。后续要补的也很具体:打开后键盘焦点应当能进入目录,关闭后回到触发按钮,按 Escape 可以退出。如果把它作为模态面板使用,还得避免焦点继续跑到遮罩后面的页面。这里只做了显示和点击行为,离一个完整的弹层还有距离。
目录数据本身也有妥协。当前提取器按行识别二至六级 Markdown 标题,跳过代码围栏,再生成 slug;它没有直接复用最终渲染树。对本站常见写法足够直接,但复杂 Markdown 或 MDX 写法仍可能让目录与实际标题产生差异。提取器已经用 remove-markdown 清洗标题文字,但这不能代替完整的渲染过程。例如 MDX 表达式参与标题内容时,按行提取并清洗后的文本未必就是最终显示的文字,生成的 slug 就需要额外核对。
更彻底的做法是从同一棵解析树里同时生成标题 ID 和目录。不过这要调整内容处理流程,还要确认缓存的编译产物是否一起更新。现在的初始目录已经能减少加载后的空白,暂时保留简单提取器;等到它开始限制正文写法,再把两条路径合并。
4.3 回到顶部:把滚动位置放进按钮里
目录解决「我要去哪一节」,回到顶部按钮则负责快速回到页面起点。它独立固定在右下角,手机上的目录按钮排在它上方。点击后调用 window.scrollTo({ top: 0, behavior: 'smooth' }),外圈同时显示当前页面的滚动进度。
进度的分母是整个文档高度减去视口高度,分子是 window.scrollY。页面没有可滚动空间时按零处理,结果再限制在 0 到 100 之间。这里表达的是页面滚动位置,包含正文之外的页面区域,也不能据此判断读者是否读完了文章。
外圈用两个 SVG 圆形绘制:一个作为底轨,一个显示进度。设置 pathLength="100" 后,描边长度可以按百分比理解,再用 strokeDashoffset = 100 - scrollProgress 控制露出的部分。整个圆环旋转 -90deg,让进度从正上方开始,按钮中间保留向上的箭头。
进度不能只在滚动时计算。图片加载或窗口尺寸变化,都可能改变可滚动高度。因此组件除了被动监听 scroll,还监听窗口 resize,并用 ResizeObserver 观察文档尺寸变化。事件先取消尚未执行的动画帧,再通过 requestAnimationFrame 安排更新,让密集事件的计算合并到后续一帧。组件卸载时也会取消动画帧、断开观察器并移除监听,避免留下无效更新。
这里用一个圆环就够了,没有再增加精确百分比。页面底部还有其他内容,图片尺寸也可能变化,把它显示成一个精确的「阅读完成度」反而容易误导。读者大概知道自己在页面什么位置,并且能一键回去,就已经达到目的。
这组监听仍要持续维护。尤其是尺寸变化不能漏掉,否则人没有滚动,进度却已经失真。当前还固定使用平滑滚动,后续应当结合减少动态效果的偏好调整;目录定位也要一起处理,免得两个按钮使用两套规则。
4.4 标签和图片都开始限制展示成本
文章分类与标签从 Taxonomy 拆开,经过 OverflowList,最后整理成 MetaBadges,也增加了热门标签入口。标签不多时看不出区别,一旦某篇文章挂了很多标签,卡片高度就容易被元信息撑起来,标题和摘要反而不显眼。
现在把放不下的标签收起,列表更紧凑,但被收起的标签也更难被注意到。热门标签能提供继续浏览的入口,出现频率低的主题则仍要靠搜索或文章内的链接发现。它适合作为补充,不能代替完整的标签关系。
另一个问题来自测量。标签宽度取决于文字、字体和可用空间,想准确判断一行能放几个,就得等浏览器知道这些尺寸。当前 OverflowList 的做法方便,但首次展示的稳定性也受这一步影响。这部分准备继续改,后面会说为什么需要连显示规则一起调整。
图片也先优化加载行为,随后增加 resize 参数,按尺寸生成 srcSet。当前只对本站使用的 CDN 图片生成缩放地址,普通外链不自动套用。
这是一种范围有限的适配:用现有图片服务减少不同尺寸场景的传输浪费,接受与该服务路径规则的绑定。组件默认懒加载、异步解码,具体使用处仍需判断首屏图片是否应该覆盖默认值。如果文章首屏的重要图片也等到较晚才加载,读者就会先面对一块空白。
缩放解决传输体积,预留尺寸解决布局稳定,两者还得分别处理。即使图片文件已经很小,没有宽高或宽高比,加载完成后仍可能把下面的段落推开。使用宽度描述的 srcSet 时,也要让 sizes 尽量符合实际布局,否则浏览器可能选到比显示需要更大的资源。组件提供默认值,具体场景仍需要检查。
五、页面过渡:动画越统一,越需要明确谁负责结束
5.1 从局部包装到根级协调
页面过渡也改了几轮:先收拢分散的组件,短暂引入 next-view-transitions,之后移除依赖,改为项目内实现。麻烦主要出在时机上。用户点了链接,不代表下一页已经提交;拿到新页面的数据,也不代表所有异步内容都已经显示。
现在由根级 ViewTransitions 协调浏览器快照和路由提交。链接触发跳转时启动过渡,等 React 提交后释放完成信号;浏览器后退和前进另有处理。不支持 document.startViewTransition 时,则直接导航。
这里需要一个明确的结束条件。过早结束,浏览器可能拿尚未稳定的内容做新快照,动画之后页面还要再变;一直等所有异步内容,网络慢时又会拖长过渡。我现在把协调放在路由提交这一层,正文里能够提前生成的内容尽量提前生成,减少过渡期间再补东西的情况。
原来的 PageTransition、RevealTransition 等包装目前只返回 children,不再各自创建快照。这样少了嵌套内容争相启动过渡的问题,但根级入口要理解更多路由行为。
这一点也解释了为什么不能把「去掉依赖」直接等同于「降低复杂性」。外部包从依赖表消失了,路由提交、历史导航、修饰键点击和过渡完成状态仍然需要有人负责,只是现在由仓库里的代码负责。
例如按住 Command 或 Ctrl 点击链接,本来是希望打开新标签页,不能被动画拦成当前页跳转。后退也不一定改变路径,搜索参数或锚点同样可以形成历史记录。当前历史过渡的结束主要跟随路径和 hash 变化,只有查询参数变化的场景仍需要重点验证。根级统一以后,这些问题更容易集中处理,也更不能漏。
5.2 高亮与动画的短暂联动,暴露了运行时成本
在收拢过渡逻辑的过程中,还增加过渡生命周期记录,让高亮加载等待活动过渡结束,再等浏览器空闲。随后高亮移到构建端,这条客户端等待链也随旧 Provider 一起删除。
这段尝试让我重新看了一遍高亮为什么需要留在浏览器里。等过渡结束,再等浏览器空闲,最后才能读到稳定的代码块,等待条件已经比高亮本身更难解释。既然内容在构建前就已知,提前完成它,比继续安排加载时机更省事。
5.3 关闭更多链接的预取
导航、卡片、标签云等链接陆续设为了 prefetch={false},减少用户尚未点击时的路由预取。文章列表和标签云的入口比较多,而一次访问通常只会点其中一部分。我愿意先少做一些可能用不上的加载。
代价是,点击后可能需要等待原本能够提前完成的加载。目前还缺少统一的前后对照数据。配置减少的是提前预取,实际节省了多少请求、点击后多等了多久,都还需要测量,不能据此说跳转更快。动画即使顺滑,也不能证明内容更早可读。
后面要比较的话,至少应分别看首次打开列表的请求量、点击后正文可读的时间,以及已有缓存时的表现。也不必坚持所有链接都用同一种策略:入口很多的标签云和读者很可能继续点击的分页,预取的价值可能不同。先关闭这些链接的预取,是当前取舍,后续可以按场景调整。
六、SEO:补页面发现,再面对外部规则
页面能正常阅读以后,还得让外部入口找到它。这轮补了元数据、sitemap 和 robots,也新增了独立 SEO 包,用来处理收录检查与提交。
元数据、订阅源和 sitemap 的共同价值,是让页面有可发现、可描述的入口。独立 SEO 工具进一步从 sitemap 读取地址,检查收录状态,并实现了批量提交逻辑。这些工作拆成工具以后,运行起来更方便,但服务账号、配额、外部 API 和失败处理也就成了需要维护的内容。
这里容易把几个结果混在一起:sitemap 里有一个地址,说明站点提供了这个入口;提交请求成功,说明接口接受了请求。页面是否被抓取、是否被收录、之后如何排序,还要由搜索引擎决定。工具应该把这些状态分开,否则终端里一排成功提示,很容易让人误以为事情已经办完。
不过,Google Indexing API 的用途有限。它并不是普通博客的通用收录按钮;官方将适用页面限定为 JobPosting 或嵌套于 VideoObject 的 BroadcastEvent。所以这个工具虽然实现了提交,也不能把它当作本站普通文章的加速收录通道。用途限制见 Google Indexing API 使用说明。普通文章还是先把内部链接、sitemap 和规范地址维护好。
站内还存在更具体的接缝:搜索页实际读取 s 参数,而结构化数据的搜索入口和 robots 规则仍使用 q。这会让页面能搜到、搜索入口描述是否正确、抓取规则是否覆盖,变成几个不同的问题。
这种不一致比少一个工具更值得先修。页面自己使用一套参数,对外声明另一套参数,就等于给了两种使用说明。后续要把读取参数、生成链接和抓取规则一起核对;如果旧参数已经形成外部链接,还要决定是否兼容,而不只是全局替换一个字母。
七、这轮重构留下的判断
这一轮改下来,我最愿意保留的是几处把工作提前做完的调整:统一文章派生数据,让正文代码在构建时高亮,把初始目录直接交给组件。博客的内容提前已知,更新频率又远低于阅读次数,确实适合多花一点构建时间,减少每次打开页面后的重复工作。
搜索和页面过渡则让我接管了更多浏览器行为。URL 订阅、历史记录、路由动画都更贴合本站了,但它们不会因为代码短就自然可靠。尤其是搜索与过渡同时监听历史变化时,单独验证一边还不够,得放在同一次前进、后退操作里看。
因此,现在说「再重构一次就不用管了」,我自己也不太信。更实际的做法,是先修会影响找文章、看正文和页面稳定性的问题,再安排那些主要改变实现方式或外观的尝试。下面这些想法还没做完,有些也未必最终会做,先把想解决的问题和暂时停下来的原因记清楚。
八、未来可能继续做的事
8.1 去掉 Mantine,用 StyleX 完成所有样式
我仍然想试试去掉 Mantine,改用 StyleX 管理本站样式。现在的样式覆盖已经不少,同一个间距或颜色,有时要同时考虑组件属性、主题配置和 CSS Modules。继续增加定制以后,直接维护自己的样式可能更容易理解。
但这件事目前排不到最前面。Mantine 提供的不只是默认外观,还有目录追踪、弹层行为、键盘操作和焦点管理。StyleX 负责样式,替换之后,这些行为仍要另找实现或自己维护。前面移动目录已经有需要补齐的交互,此时再全面替换组件库,会把待处理的问题扩大一圈。
所以我想先梳理实际使用的组件。正文容器、卡片和普通布局可以先动,依赖测量、焦点或弹层的控件留到后面。每替换一类,就看样式是否真的更容易调整,手机和键盘操作是否仍然正常。仅仅把依赖从列表里删掉,不能说明这次迁移值得。
还有一个顺序问题:如果标签展示方式本来就要改变,应该先确定新的规则,再实现样式。否则先用 StyleX 原样复刻现有 OverflowList,过几天又为了静态输出重做一次,省下的依赖还没看见,重复工作倒是很确定。
8.2 进一步优化 Shiki.js 的预渲染
正文高亮已经移到构建阶段,接下来想看构建时间和产物体积还有没有可以减少的部分。不过这里不能先假定每个代码块都重新创建了高亮器:当前已经把初始化 Promise 放在模块级,同一个模块实例中的调用会复用它。需要确认的是构建过程中会有多少个执行实例,以及时间到底花在初始化、语法处理还是 HTML 输出上。
如果瓶颈在重复处理相同代码块,可以考虑缓存结果。可只把代码正文当缓存键还不够,语言、主题、高亮配置和相关依赖版本都可能改变输出。代码没变,主题换了,旧缓存就应该失效;同一段文本标成不同语言,也未必得到相同结果。
缓存放在哪里同样影响收益。进程内缓存实现简单,但下一次构建就没了;持久化缓存可以跨构建复用,又需要管理失效和清理。Content Collections 的内容编译与 Next.js 构建时的代码高亮也处在不同阶段,前者命中缓存,不代表后者就不再计算。先量出各阶段的耗时,才知道该在哪一层动手。
输出体积则要另外看。明暗主题当前共用代码结构,把两套颜色写在一起;还可以检查相同的颜色组合是否大量重复,是否值得抽成共享样式。不过共享样式会增加另一种依赖关系,也未必比压缩后的内联输出更省。暂时不急着再加一层机制,等确认哪部分占得多,再决定是否值得维护。
8.3 Mermaid 图表的夜间模式,为什么还没加
页面已经可以切换明暗配色,Mermaid 图表却没有一起适配,这个缺口很显眼。夜间看文章,正文背景变暗,图表仍保留构建时的节点填充、文字和连线颜色。只把外层背景改暗,可能让原本适合浅色背景的文字或箭头更难看清。
Mermaid 本身提供 dark 主题,也允许通过主题变量配置颜色。本站的困难在于输出方式:图表在构建时已经生成静态 SVG,浏览器里没有继续运行 Mermaid,切换页面主题不会重新计算图表颜色。主题配置是在生成图表时生效的,相关能力见 Mermaid 主题配置文档。
插件也有现成的夜间模式支持,但当前使用的 rehype-mermaid 3.0.0 只在 img-svg、img-png 策略下支持 dark 选项,通过 <picture> 提供明暗版本;本站采用的 inline-svg 不支持这个选项。也就是说,不能在现有配置旁边加一行 dark 就结束,需要连输出方式或后续处理一起调整。这个限制在 rehype-mermaid 的选项说明里有注明。
一种做法是构建时分别生成明暗两份图表,页面只展示其中一份。颜色交给 Mermaid 自己生成,更容易保留各类图表的主题细节。额外的渲染和输出也随之增加,不过具体增加多少,要看图表数量、大小和压缩结果,不能直接推成整站体积翻倍。
如果继续把两份 SVG 内联进同一页,还要保证它们的 ID 不冲突。箭头、裁切区域和内部样式可能通过 ID 引用,不能只改最外层标识就算完成;隐藏的版本也应该退出阅读顺序。两份图表的尺寸还要核对,避免切换主题时正文位置跟着变化。
改成图片输出,可以利用插件现成的双主题能力,但又改变了图表与页面的关系。内联 SVG 可以直接被站内 CSS 选中,作为图片加载的 SVG 则不能照搬这套处理。另外,本站允许手动切换主题,页面的选择可能与操作系统配色不同。只依赖 prefers-color-scheme 的图片切换,还得接上站内主题状态,否则会出现正文已经切暗、图表仍跟随系统显示浅色的情况。
另一种做法是保留一份 SVG,生成后把需要切换的颜色整理成 CSS 变量。这样不用重复输出整张图,但需要认识 Mermaid 生成的内容:哪些是节点背景,哪些是标签底色,哪些是连线和箭头,哪些又是文章自己指定的颜色。流程图和时序图不一定使用同一套结构,升级 Mermaid 之后还要确认处理规则仍然有效。
自定义配色尤其不能简单覆盖。假如文章用红色标失败路径、绿色标成功路径,统一替换成主题色以后,图虽然融入了页面,原来的含义却可能丢了。给 SVG 整体加反色滤镜也有类似问题,它会改变所有颜色,无法判断哪些只是装饰,哪些承担了说明作用。
目前先不加,是因为我还没有在这两条路线里确定一个愿意长期维护的方案。双份输出的实现更直接,但要接受额外产物并统一主题选择;单份输出更紧凑,却需要维护生成结果的改写规则。为此把 Mermaid 搬回浏览器,在每次切换时重新渲染,又会把已经提前完成的工作放回阅读过程,我暂时不想这样改。
下一步适合先选几张有代表性的现有图表验证:长标签、子图、时序图,以及带自定义颜色的图。先确认文字、箭头和标签背景都能看清,再比较输出体积和主题切换时的布局。若某类图表已经影响辨认,就先修它的局部对比度;完整的双主题适配可以继续安排,不必把局部可读性也一起拖着。
8.4 调整文章卡片标签,改用服务端渲染
卡片标签的首次展示还想继续改。当前组件依赖客户端测量来决定哪些标签放得下,浏览器接管后可能重新安排显示内容,带来布局跳动。客户端组件也可以有预渲染的 HTML,难以提前确定的是,当前宽度下到底能保留几个标签。
因此,目标也不能只是去掉 use client。服务端不知道浏览器最终的容器宽度、字体和缩放比例,如果仍要求「恰好填满一行,剩余标签显示精确数量」,测量需求还在那里。对于本站的静态导出,构建时更不可能为每位读者预先算出这个答案。
我更想先放松展示规则。例如固定展示少量主要标签,把其余标签放到一个明确的展开入口;或者允许有限的换行,用 CSS 保持卡片布局稳定。固定数量容易提前生成,却可能在宽屏上留下空白;按行裁切利用空间更充分,但不能让被裁掉的链接仍然落入不可见的键盘焦点位置。方案要连访问剩余标签的方式一起确定。
当前通过悬停卡片展示折叠项,移动端和键盘下的使用也需要一起考虑。更直接的点击展开是否合适,要与标签本身的跳转行为区分开,避免想展开却跳走。
这项调整还没做,是因为显示规则尚未定下来。规则一旦不再依赖精确测量,静态输出反而容易实现。之后要比较的是首次出现和浏览器接管后的布局是否一致,以及读者是否还能方便地找到其余标签,而不是单看客户端组件少了几个。
8.5 优化主题,支持更多主题与视觉样式
除了实现方式,我也想继续调整博客的观感,增加一些配色和视觉风格。不过相比先做一个主题选择器,我更想把现有的正文排版、间距、卡片、边框和交互状态整理好。主题越多,这些基础规则不一致的问题越容易被放大。
颜色变量需要按用途组织。正文、次要文字、链接、边框、悬停背景各自有职责,同一个颜色值现在恰好出现在两处,不代表以后换主题时它们也应该始终一起变。把用途分清,才有可能换一套配色而不用逐个组件修补。
多主题还会影响代码高亮、图表和基础控件。页面背景改成另一种深色后,原来能看清的代码注释、图表连线未必仍然合适。每增加一套主题,都要用真实文章检查长段落、表格、代码块、图表,以及手机上的展示,不能只看首页卡片的截图。
用户偏好也要统一。手动选择与跟随系统如何切换,偏好存在哪里,首次绘制前怎样采用正确的颜色,都需要沿用同一套规则。如果页面先显示浅色,再等客户端读出偏好后变暗,多一个主题反而多一次打断。
这也是我暂时没有扩展主题数量的原因。先让现有明暗模式覆盖完整,再试新的配色,后续需要检查的组合会少一些。StyleX 迁移也不必成为前提,现有主题里能修好的排版和对比度,可以先修。
这些事情里,我会先处理搜索参数、移动目录的键盘操作和标签的首次布局,再继续验证图表主题。等日常阅读里这些别扭的地方少一些,再折腾组件库和更多皮肤。博客还有不少可以改的地方,不过下一次打开编辑器,也该多写几段正文了。
