这个博客的第一篇文章,写的是这座站点自己。一个多月过去,站点长大了一圈:图片搬了家,多出一个管理后台,部署换了平台,连每个分区都拥有了自己的颜色。题记如果不跟着重写,就会变成一份过时文档——而过时文档比没有文档更误导人。
所以这是第二版。立场不变:不用形容词介绍这座站点,只用它真正做过的决定。它不是一次“挑个模板、换上内容”的搭建,也不是为了证明一个页面能塞进多少特效。它更像一次翻译:把一份有明确性格的视觉设计,翻译成可以被访问、被搜索、被维护、并且能继续生长的网页。
我希望这里看起来不像一份被切成栏目的网页,而像同一个空间里的不同房间。你从首页走到项目、技能或博客,背景、文字和水印会变化,但站点不会突然换上另一副面孔。
所以实现时最重要的问题,从来不是“这个动画怎么写”,而是:哪些东西必须始终连续? 答案落在四个层面:
分区从七个收敛为六个,也是这个月发生的事:荣誉不再是独立房间,而是并入了“关于”——旧地址 /honors 被登记为遗留别名,依然能打开,只是落在它该在的地方。房间变少了,每个房间反而更像自己。
站点用 Astro 生成页面,用 React 承担需要持续状态的交互。整个前端只有一个 React 岛:首页在构建时就把内容准备好,再挂载这个客户端应用;与此同时,博客列表和每一篇文章仍然各自拥有独立的静态路由。
这形成了一个有意为之的“双重结构”——同一个 App 组件,被三种入口以不同姿势挂载:
| 访问方式 | 服务端交付什么 | 为什么这样分 |
|---|---|---|
| 直接打开分区 URL | 该分区的静态页面 | 每个房间都能被直达、被收藏 |
| 直接打开文章 URL | 完整的服务端 HTML 正文 | 对搜索、分享与首屏阅读友好 |
| 在站内移动 | 只补上需要的那一块 | React 保持状态,转场不重启 |
为了让这颗“交互的心”不至于太重,同一时刻真正渲染进 DOM 的分区最多只有三个——当前分区,以及它前后各一个。其余分区被移出渲染、标记为 inert 且对辅助技术隐藏。你以为整站都在页面里,其实它一直在悄悄卸下你看不见的房间。
那些偏重的分区(3D、着色器背景、命令面板)都用 React.lazy 延后加载;唯独博客列表和文章视图是急切引入的——因为它们的 HTML 必须被服务端渲染进静态产物里,搜索引擎才读得到。这是一处明确写在代码注释里的取舍:SEO 优先的内容不能藏在懒加载后面。
Astro 把可读的页面先交到读者手里,React 负责让这个空间保持连续。
这一版最值得先写的新变化:分区不再只有明暗之分,而是各自拥有一个完整的颜色身份。
// src/lib/sections.ts —— 一个分区的全部身份
{ id: 'blog', en: 'BLOG', zh: '博客',
path: '/blog', watermark: 'BLOG',
bg: '#0A0A0A', isDark: true,
accent: '#4338CA', accentSoft: '#8B92F0',
veilTint: '#3730A3', mesh: [/* 三团环境渐变 */] }
一个分区的 accent 不只是装饰。它会在切换时被发布成一组 CSS 变量(--section-accent、--section-accent-soft、--section-accent-ink、--section-accent-on),随后流遍全站:左侧的章节色带与水位线、转场时横扫屏幕的墨色擦除、光标、选区、滚动条、进度圆点,全都喝同一口井。背景那层常驻的 WebGL 暗色 veil,也会朝分区的 veilTint 缓缓漂移——漂移量在 OKLCh 色彩空间里计算,清晨、正午、黄昏、午夜还会各自再轻推一点色相与浓度:同一个分区,在早上和深夜读起来有细微的不同,但你永远认得出它是谁。
新增一个分区,仍然只改这一个数组;路由、导航、命令面板里的跳转、右侧的分区圆点,全部从它派生。同样地,个人文案集中在一个 profile.json(年龄、统计数字等派生值由 profile.ts 在构建时算出,不手写维护),所有命令定义收进一张注册表,所有缓动与时长收进一个动效模块。界面只管把它们摆出来,不重复定义它们。
如果只能挑一处“最花心思”的实现,仍然是文章正文的取用方式。
同一篇文章的正文,有两条来路。直接打开 /blog/某文章 时,正文是服务端渲染好的 HTML,作为 React 的子节点直接就位,首屏不需要任何额外请求。从站内点进同一篇时,应用改为去取一份预渲染好的静态 HTML 片段(/article-fragments/<slug>/),用 DOMParser 抽出正文注入阅读视图——这样就不必把整套页面外壳重新加载一遍。
支撑这条路的是一个很小的正文缓存,它的规则值得说清楚:
一次完整的分区切换,大致经历五拍:
每一拍都有明确的时长,而不是凭手感堆叠。有个不易察觉的对位细节:内容替换发生在换色桥接的中段,而分区主题色的“正式生效”刻意再晚半拍——大约落在桥接段的 45% 处,让颜色的峰值压在内容之后抵达。过渡期间会锁住重复输入:操作太快时,多出来的那次不会让几个动画互相争抢,而是被收成“一个待执行的意图”,等这轮转场结束再兑现。缓动曲线、时长与进退场姿态集中在一个文件里维护——当节奏需要调整,只改一处,整站一起改变呼吸。
进入阅读模式后,翻页手势会整体让位:滚轮、方向键、触摸滑动在读文章时都不再触发分区切换,你不会因为滚到底部就被“弹”去下一个分区。打开一篇文章时,水印打出的不再是分区名,而是这篇文章的分类——转场语言在同一套语法里,说了更具体的句子。
好的动效不是让人注意到“这里有动画”,而是让人不必怀疑自己刚才去了哪里。
博客不是事后补上的一块富文本区域。它有内容集合、类型约束,以及一整排为“长期阅读”准备的能力:
PhotoFigure / PhotoSet 引用,构建期解析地址,客户端再把图注、原图地址、拍摄时间提升为灯箱的标题与下载入口;最花心思的仍然是文章之间的关系图,它在构建时一次算好,作为纯数据发给前端。除了你在文中写下的站内链接(正向引用)和反过来指向本文的“被引用”,还有一份加权排序的“继续阅读”:
| 关系 | 计入的分数 |
|---|---|
| 在正文里显式声明 related | +100 |
| 正文里互相引用 | 引用 +24 / 被引用 +12 |
| 同属一个分类 | +6 |
| 每一个共享标签 | +3 |
算完取前三名,并列时用发布时间打破平手。标签只是入口,文章之间真正的关联是被计算出来的。
还有一条与本文直接相关的规矩:AI 参与程度是显式标注的。每篇文章的 frontmatter 里写着 aiLevel(完全创作 / 辅助润色 / 纯人工)与参与模型名,徽章、版权块、列表卡片和 Markdown 导出共用同一份文案定义。这篇文章页眉上的“AI 创作 · Kimi K3”,就是这个体系自己在说话。
越来越多的“读者”其实是程序——搜索引擎、阅读器,以及大模型。所以这里专门留了几个机器友好的出口:
.md 原始源码(保留 YAML 前言与 MDX),而不是一份有损的纯文本近似;llms.txt 汇总了站点简介、主要入口和全部文章的源码链接,方便被整体喂给模型;这些出口很少出现在截图里,却决定了这个站点能不能被今天这批新读者好好读到。
上一版里,图片还只是“放在 public/images 里的文件”。现在它们有自己的完整生命周期:
存储在站外。 所有图片托管在 Cloudflare R2,访客通过只读域名 static.r2.gcxstudio.cn 取用;构建产物不再携带图片本体。渲染层只有一个地址解析函数 resolveImageSrc()——数据层永远写 /images/... 的站内路径,由它拼接成 CDN 完整地址,未配置时回退同域。全站图片只经 SiteImage 一个组件渲染:shimmer 骨架、加载失败回退、decoding="async" 各写一遍的时代结束了。OG 分享卡片走同一条管线;只有 favicon 和背景音乐留在同域——后者是因为 Web Audio 分析器受 CORS 限制,这是浏览器定的规矩,不是审美选择。
只保留两种尺寸。 原图(相机分辨率,供“FULL RES”查看与下载)和一张长边 1920px 的 -display.webp 展示图;不再生成独立缩略图。本地有幂等的优化脚本,后台上传时则由浏览器端 Canvas 现场生成同规格派生图,两边尺寸逻辑严格一致。
加载是分层的。 <head> 里先 preconnect 预热 CDN 源;列表卡片悬停或聚焦时预取灯箱大图;灯箱打开后预取相邻两张;列表空闲时用 requestIdleCallback 批量预取展示图,并发上限三张。所有预取共享一个去重集合,并且在省流模式或 2G 弱网下整体静默——替读者省流量,比替自己省请求重要。
灯箱本身换成了 yet-another-react-lightbox:键盘翻页、滚轮与双指缩放、下拉关闭、幻灯片播放都交给成熟的核心与官方插件,站点只定制两件事——自己的信息栏(标题、计数、FULL RES 下载),以及项目分区专属的“详情卡片”幻灯片类型。动画时长与缓动沿用全站统一的动效 token,定制皮肤不发明新节奏。
最深的变化藏在 /admin:一个不收录进任何索引的管理后台。
它是一个 client-only 的 React SPA。管理员在浏览器里填入 GitHub 细粒度 PAT(只授仓库 Contents 读写)与 R2 凭据,二者只存于浏览器 localStorage。此后的每次编辑——发文章、改项目、更新个人文案——都被翻译成一次 Git Data API 调用:新建 blob、组装 tree、生成 commit、推进 main 分支引用;若期间引用被别人移动,最多自动重试三次。提交落库即触发 EdgeOne 的自动构建,约一两分钟后线上生效。图片二进制不经过仓库,由浏览器直传 R2,元数据仍走 commit——内容和载体各走各的路。
后台的表单不是手写的页面堆叠:所有可编辑内容先在一份 schema 里定义字段,再由通用表单渲染器与各类型面板消费。新增一种可编辑内容,是先定义数据,再谈界面。它对搜索引擎三重隐身(noindex、robots 排除、站点地图过滤),对管理员则是移动优先的底部标签栏布局——因为真的会在手机上改错别字。
有些决定,读者永远不会直接看到,却是站点能否从容到第二十篇文章的关键。
字体是分工的。 拉丁正文、中文正文、代码、装饰水印与衬线标题,六个字体家族各用各的字面。中文用的 HarmonyOS Sans 自托管并做了子集化——一个 Python 脚本扫描全站真正用到的字符,只把这些字打包进最终字体,多余字形根本不会被部署。声明式字体配置交给 Astro 的 fonts API,CSS 变量统一下发。
动效是按设备分级的。 完整 / 精简 / 静态三档,随系统“减少动态效果”偏好自动落档,也可以手动锁定并持久化。深色背景那层会呼吸的 veil 本质是一段在 GPU 上逐像素运行的着色器,它的分辨率与帧率按档位缩放;首页名字那种随光标推挤而变形的效果,是在实时调节可变字体的字重轴,而不是切图。
入口是收拢的。 Cmd / Ctrl + K 唤出的命令面板,把页面、项目、标签、技能、荣誉和文章收进同一个搜索框:支持 :blog、:project 这类前缀过滤,文章搜索由 Fuse.js 按标题、摘要、标签加权打分;输入 = 能直接算一道式子;还能切换背景音乐、随机漫游、手动锁动效档位——以及一个叫“灵光模式”的彩蛋,留给愿意乱按的人。
构建是被看住的。 Vite 手动把 motion、react(-dom)、ogl、canvas-confetti 各自拆包,保持主包精简;部署到 EdgeOne 时,一个只有三十行的自定义集成专门修补“站点地图比产物晚写入”的时序问题;CI 在每次推送时完整跑一遍类型检查、模板诊断与构建。无聊,但每个深夜都值得。
个人网站最有趣的地方,是它永远不算真正完工。上一版题记里许下的愿望,已经有了着落:全文搜索进了命令面板,内容系统有了管理后台,而这篇重写的题记本身,就是“把这套系统写成文章”的兑现。接下来值得投入的,仍然不是更多孤立的特效,而是更长久的能力:
这个站点最重要的设计原则至今没变:克制,但不简陋;连续,但不封闭;技术确实存在,却不挡在内容前面。
关于本站如何保存本机偏好、以及评论等第三方服务,另见 隐私说明。
后面的文章会更具体——可能是一段转场如何被调到合适的节奏,也可能是某次重构里,被删掉的代码为什么比新增的更多。
再一次,从这里开始。
评论