ENGINEERING
// BLOG · 长期主义
ENGINEERING

// 2026-07-11 · 10min · MiraHikari
文章正文加载完成

这是博客的第一篇文章,所以不妨先让这座站点介绍它自己——不是用形容词,而是用它真正做过的决定。

它不是一次“挑个模板、换上内容”的搭建,也不是为了证明一个页面能塞进多少特效。它更像一次翻译:把一份有明确性格的视觉设计,翻译成可以被访问、被搜索、被维护、并且能继续生长的网页。下面这篇,就是这次翻译里那些值得记下来的选择。

先有连续,再谈效果

我希望这里看起来不像一份被切成栏目的网页,而像同一个空间里的不同房间。你从首页走到项目、技能或博客,背景、文字和水印会变化,但站点不会突然换上另一副面孔。

所以实现时最重要的问题,从来不是“这个动画怎么写”,而是:哪些东西必须始终连续? 答案落在四个层面:

  • 视觉上,水印、背景与内容共享一套统一的进退场顺序;
  • 导航上,每个分区既能在站内无缝切换,也拥有可直达、可分享的 URL;
  • 内容上,博客要能被搜索引擎读懂,也要在站内像应用一样即时打开;
  • 工程上,一条规则只定义一次,而不是散落在十几个组件里各写一遍。
7个内容分区
3篇正文的常驻缓存上限
5拍完成一次分区转场

一副静态骨架,一颗交互的心

站点用 Astro 生成页面,用 React 承担需要持续状态的交互。整个前端只有一个 React 岛:首页在构建时就把内容准备好,再挂载这个客户端应用;与此同时,博客列表和每一篇文章仍然各自拥有独立的静态路由。

这形成了一个有意为之的“双重结构”——同一个 App 组件,被三种入口以不同姿势挂载:

访问方式 服务端交付什么 为什么这样分
直接打开分区 URL 该分区的静态页面 每个房间都能被直达、被收藏
直接打开文章 URL 完整的服务端 HTML 正文 对搜索、分享与首屏阅读友好
在站内移动 只补上需要的那一块 React 保持状态,转场不重启

为了让这颗“交互的心”不至于太重,同一时刻真正渲染进 DOM 的分区最多只有三个——当前分区,以及它前后各一个。其余分区被移出渲染、标记为 inert 且对辅助技术隐藏。你以为整站都在页面里,其实它一直在悄悄卸下你看不见的房间。

那些偏重的分区(3D、着色器背景、命令面板)都用 React.lazy 延后加载;唯独博客列表和文章视图是急切引入的——因为它们的 HTML 必须被服务端渲染进静态产物里,搜索引擎才读得到。这是一处明确写在代码注释里的取舍:SEO 优先的内容不能藏在懒加载后面。

Astro 把可读的页面先交到读者手里,React 负责让这个空间保持连续。

双路正文:既要被搜索读懂,也要像应用一样打开

如果只能挑一处“最花心思”的实现,是文章正文的取用方式。

同一篇文章的正文,有两条来路。直接打开 /blog/某文章 时,正文是服务端渲染好的 HTML,作为 React 的子节点直接就位,首屏不需要任何额外请求。从站内点进同一篇时,应用改为去取一份预渲染好的静态 HTML 片段,注入到阅读视图里——这样就不必把整套页面外壳重新加载一遍。

支撑这条路的是一个很小的正文缓存,它的规则值得说清楚:

  • 缓存最多只留三篇正文,超出就淘汰;
  • “读过”的永远比“预取的”优先级高:真正打开的文章会被记住,只是鼠标划过而预取来的会先被清掉;
  • 悬停或聚焦上一篇 / 下一篇链接时,正文会被以有限的并发提前取好;真的点进去,那份预取会当场“转正”为一次正式阅读。

转场是导航的语法,不是装饰

一次完整的分区切换,大致经历五拍:

  1. 水印擦除;
  2. 当前内容退场;
  3. 背景换色、并在此刻完成内容替换;
  4. 新内容进场;
  5. 新水印重新打字浮现。

每一拍都有明确的时长,而不是凭手感堆叠。过渡期间会锁住重复输入:操作太快时,多出来的那次不会让几个动画互相争抢,而是被收成“一个待执行的意图”,等这轮转场结束再兑现。缓动曲线、时长与进退场姿态也集中在一处维护——当节奏需要调整,只改一个文件,整站一起改变呼吸。

还有一个容易被忽略的细节:进入阅读模式后,翻页手势会整体让位。 滚轮、方向键、触摸滑动在读文章时都不再触发分区切换,你不会因为滚到底部就被“弹”去下一个分区;离开一篇文章,永远是一次明确的动作。

好的动效不是让人注意到“这里有动画”,而是让人不必怀疑自己刚才去了哪里。

把博客当成产品来做

博客不是事后补上的一块富文本区域。它有内容集合、类型约束,以及一整排为“长期阅读”准备的能力:

  • 阅读时间是中文感知的:估算时会分别按每分钟约 300 个中文字符、约 220 个英文单词来算,而不是笼统地数字数;
  • 目录带滚动高亮,当前章节用二分查找定位,进度、高亮与顶部进度条共用同一套滚动监听,不会各自触发重渲染;
  • 代码块自动带上语言标签和复制按钮,正文配图点击即进灯箱;
  • 版本记录让“这篇文章被改过什么”本身也成为可读的内容。

最花心思的是文章之间的关系图,它在构建时一次算好,作为纯数据发给前端。除了你在文中写下的站内链接(正向引用)和反过来指向本文的“被引用”,还有一份加权排序的“继续阅读”:

关系 计入的分数
在正文里显式声明 related +100
正文里互相引用 引用 +24 / 被引用 +12
同属一个分类 +6
每一个共享标签 +3

算完取前三名,并列时用发布时间打破平手。标签只是入口,文章之间真正的关联是被计算出来的。

机器也是这个站点的读者

越来越多的“读者”其实是程序——搜索引擎、阅读器,以及大模型。所以这里专门留了几个机器友好的出口:

  • 每篇文章都能取到它的 .md 原始源码(保留 YAML 前言与 MDX),而不是一份有损的纯文本近似;
  • 一份 llms.txt 汇总了站点简介、主要入口和全部文章的源码链接,方便被整体喂给模型;
  • 常规的 RSS 与 Atom 订阅源一直都在;
  • 文章工具条上的**“发给 AI 阅读”**会替你拼好一段结构化提示词——标题、摘要、原文链接,如果你选中了某段文字,还会带上那段引文——再一键送进常用助手。

这些出口很少出现在截图里,却决定了这个站点能不能被今天这批新读者好好读到。

一次定义,处处生效

能维护得久的站点,往往赢在“同一件事只说一遍”。这里有几处刻意维持的单一事实来源:

// src/lib/sections.ts —— 一个分区的全部身份
{ id: 'blog', en: 'BLOG', zh: '博客',
  path: '/blog', watermark: 'BLOG',
  bg: '#0A0A0A', isDark: true }

新增一个分区,只改这一个数组;路由、导航、命令面板里的跳转、右侧的分区圆点,全都从它派生。同样地,所有个人文案集中在一处 profile.ts,所有命令定义收进一张注册表,所有缓动与时长收进一个动效模块。界面只管把它们摆出来,不重复定义它们。

那些不在截图里的取舍

有些决定,读者永远不会直接看到,却是站点能否从容到第二十篇文章的关键。

字体是分工的。 拉丁正文、中文正文、代码、以及那些巨大的装饰水印,各用各的字面。中文用的 HarmonyOS Sans 自托管并做了子集化——一个 Python 脚本会扫描全站真正用到的字符,只把这些字打包进最终字体,多余的字重与字形根本不会被部署。

动效是按设备分级的。 深色背景那层会呼吸的暗色veil,本质是一段在 GPU 上逐像素运行的着色器;它的分辨率、帧率与像素密度上限,会根据你的设备能力和“减少动态效果”偏好自动缩放——性能足够就完整跑,不够就退到更省的档位,开启系统的减弱动画则直接定格。首页名字那种随光标推挤而变形的效果,也是在实时调节一个可变字体的字重与倾斜轴,而不是切图。

入口是收拢的。 Cmd / Ctrl + K 唤出的命令面板,把页面、项目、标签、技能和文章收进同一个搜索框,支持 :blog:project 这类前缀过滤,甚至能直接 = 算一道式子。

它还会继续生长

个人网站最有趣的地方,是它永远不算真正完工。上一版题记里许下的一些愿望——版本记录、发给 AI 阅读、构建期的文章关系——现在都已经在线上了。接下来值得投入的,仍然不是更多孤立的特效,而是更长久的能力:

  • 记住读者的阅读位置,并在返回博客时恢复筛选与滚动的上下文;
  • 为技术文章补上可运行的示例、真实的性能数据与可访问性说明;
  • 让站内的全文搜索更聪明,而不只依赖标题与标签;
  • 把这套内容系统本身,写成后面几篇更具体的文章。

这个站点最重要的设计原则至今没变:克制,但不简陋;连续,但不封闭;技术确实存在,却不挡在内容前面。

后面的文章会更具体——可能是一段转场如何被调到合适的节奏,也可能是某次重构里,被删掉的代码为什么比新增的更多。

先从这里开始。