← 关于世界的规则

字节的跃动

棱镜2026.6.17  ·  日志

建一个网站,就是在物理定律、网络协议和浏览器工作原理共同构成的边界里,找到可以走的路。有些边界是柔软的——改一行代码即可跨越。有些边界是坚硬的——光在光纤中的速度不会为你加快。这本手记记录的,不是每一次选择的结果,而是每一次判断的推理过程。

一、这个世界的边界——托管与域名

1.1 访问一个网站时发生了什么

在讨论技术选择之前,需要先理解用户访问 www.tumanydays.com 时到底发生了什么。这个过程涉及三个独立但协作的系统:DNS 解析、HTTP 请求、TLS 加密。

DNS 解析将域名翻译成 IP 地址——一台计算机在互联网上的门牌号。这个过程是逐级查询的:浏览器先翻自己的缓存(Chrome 的记忆大约保留 1 分钟),没找到就问操作系统。操作系统再查自己的记忆和 hosts 文件,也没找到,就把问题交给配置的 DNS 递归解析器——通常是 ISP 自动分配的,或者手动指定的公共 DNS(如 8.8.8.8)。

递归解析器从根域名服务器开始,一层一层往下问:根服务器(.)→ TLD 服务器(.com)→ 权威 nameserver → 最终返回 A 记录(IPv4 地址)或 AAAA 记录(IPv6 地址)。这条链路上的每一个环节都有自己的缓存和过期时间(TTL)——这就是为什么 DNS 变更不能即时生效。你改了设置,不等于全世界都立刻知道了。

HTTP 请求建立在 IP 地址之上。浏览器先要敲三次门(TCP 三次握手:SYN → SYN-ACK → ACK,耗时 1 个 RTT),然后在 TCP 之上再进行 TLS 握手(TLS 1.3 只需要 1 个 RTT),最后才能说正事——发送 HTTP 请求。对于跨太平洋的连接,单个 RTT 约 150ms。在发送第一个字节的 HTML 之前,光建立连接就需要 2-3 个 RTT——约 300-450ms。这还没算 HTML 本身的下载时间和图片、字体等资源的加载。

1.2 为什么是 GitHub Pages

GitHub Pages 的本质是一个静态文件服务器 + Git 钩子。当你 git push 到仓库的 main 分支时,GitHub 的后端触发一个构建流程(对于纯 HTML 站点不需要构建步骤,直接使用源文件),然后将文件分发到 GitHub 的 CDN 边缘节点。

关键优势不是「免费」——而是零运维。传统的网站部署需要:一台服务器(物理机或 VPS)、操作系统更新、Web 服务器软件(Nginx/Apache)的配置和维护、SSL 证书的申请和续期、日志轮转、备份策略。GitHub Pages 把所有这些缩减为一条命令:git push。服务器是否存在、Nginx 是否在运行、证书是否过期——这些问题变成了其他人的问题。

我考虑过 Vercel 和 Netlify。它们本质上是 GitHub Pages 的超集——在静态文件服务之上叠加了构建步骤(自动检测框架并运行 npm run build)、Edge Functions(在 CDN 边缘节点运行服务端代码)、分析面板等。对于一个不依赖框架、不需要构建步骤、没有任何服务端逻辑的原生 HTML 站点,这些功能是过剩的。而且每增加一层抽象就增加一个调试维度——当部署失败时,是 Git 推送的问题?构建步骤的问题?还是 CDN 分发的问题?GitHub Pages 把故障面压缩到最小。

自定义域名的机制:GitHub Pages 通过仓库根目录的 CNAME 文件来确认你对域名的控制权。这个文件的内容就是你的域名(一行文本)。每次 git push 时 GitHub 检查这个文件是否仍然存在且内容匹配。如果被删除或内容不匹配,自定义域名绑定会在几分钟内失效。这是为什么即使 DNS 已经由 Cloudflare 管理,这个文件仍然必须留在仓库中——它是 GitHub 验证域名所有权的唯一依据。

1.3 CDN 和反向代理的原理

GitHub Pages 的服务器在美国,中国大陆到美国西海岸的光纤距离约 10,000 公里。光在光纤中的速度约为 200,000 km/s(真空光速的 2/3,因为玻璃折射率约 1.5),理论最低单向延迟约 50ms,往返 100ms。但实际的网络路径不是直线——路由器跳转、队列延迟、TCP 拥塞控制将实际 RTT 推到 150-300ms。

Cloudflare 解决这个问题的方式是 Anycast 路由 + 反向代理

Anycast 是一种网络寻址技术:同一个 IP 地址同时广播到全球 330+ 个数据中心。当用户发送请求时,BGP(边界网关协议)自动将流量路由到离用户网络拓扑最近的数据中心——通常是香港、东京或新加坡节点。这不是「物理距离最近」,而是「AS 路径最短」,由 BGP 的路由表决定。

反向代理意味着 Cloudflare 的节点接收用户的 HTTPS 请求(在这里终止 TLS),然后代为向源站(GitHub Pages)发起请求。中间的流量被缓存:节点检查本地是否有该资源的有效副本——有则直接返回(毫秒级),没有则回源获取,缓存下来,再返回给用户。

关键数据:未代理时国内访问延迟 150-300ms,代理后(香港节点缓存命中)降至 50ms 以内。这个差距的本质是物理距离从 10,000 公里(跨太平洋)缩短到约 2,000 公里(中国大陆到香港)。

1.4 DNS 迁移的操作与验证

将域名从注册商的 DNS 切换到 Cloudflare,核心操作是在注册商后台修改 nameserver 记录,指向 Cloudflare 分配的 nameserver(通常是 *.ns.cloudflare.com 格式的两台服务器)。

这个操作有一个极易被误解的陷阱:注册商后台显示「已修改」只是表示它已经将变更提交到 TLD 注册局——但 TLD 根服务器的缓存可能还没有更新,各级递归解析器的缓存更不可能立即失效。DNS 变更的传播速度取决于各环节的 TTL 设置,短则几分钟,长则 48 小时。

正确的验证方式不是刷新注册商后台,而是直接查询 TLD 级根服务器:

# 查询 .com 的 TLD 服务器返回的 nameserver 记录
dig NS tumanydays.com @a.gtld-servers.net

# 对比 Cloudflare 分配的 nameserver
# 如果 TLD 返回的还是旧 nameserver,说明变更尚未到达根服务器

CNAME 文件和 A 记录的关系:DNS 中有两种将域名指向 IP 的方式。A 记录直接映射域名 → IP 地址。CNAME 记录将域名映射到另一个域名(规范名称),再由那个域名的 A 记录解析出 IP。GitHub Pages 的 CNAME 文件不是 DNS 中的 CNAME 记录——它是一个独立的应用层验证机制。在 Cloudflare 的 DNS 配置中,我使用的是 A 记录指向 GitHub Pages 的 IP,而非 CNAME 记录。这两个「CNAME」的区别是理解这套系统时最容易混淆的概念之一。

1.5 SSL/TLS 的全自动管理

Cloudflare 免费套餐自动为域名签发泛域名证书(*.tumanydays.com),使用 Let's Encrypt 或 Google Trust Services 作为证书颁发机构。证书的申请、部署、续期全自动——浏览器和 Cloudflare 之间是加密的,Cloudflare 和 GitHub Pages 之间也是加密的。这一层我从未手动碰过:证书在过期前 30 天自动续期,没有「忘了续期导致网站挂着小锁警告」的可能。

回头看,这件事的意义比它看起来大得多。在 Let's Encrypt 出现之前(2015 年以前),SSL 证书需要花钱购买、手动安装、到期前人工续期——这曾是个人网站搭建中最令人头疼的运维环节之一。现在它变成了一个不需要思考的默认项。这是基础设施进步的赠礼。

二、建造材料——前端技术栈

2.1 浏览器如何渲染一个页面

理解为什么选择原生 HTML + CSS + JS,需要先理解浏览器怎么把一个文本文件变成屏幕上的像素。这个过程分三步,每步都有各自的约束——而这些约束直接决定了我后面的每一个选择。

第一步:HTML → DOM。浏览器收到 HTML 的字节流后,先识别编码(根据 <meta charset> 标签),把字节转成字符。然后词法分析器(tokenizer)上场,把字符流切成一个个 token——startTag、endTag、comment、character。这些 token 被送入 tree constructor,按照 HTML5 规范定义的算法(一套包含容错规则的精确定义)构建 DOM 树。关键在于:这个过程是增量式的——浏览器不需要等整个 HTML 下载完,收到多少就解析多少。这意味着 HTML 文件的下载速度不是首屏渲染的瓶颈(只要网络没有完全中断)。

第二步:CSS → CSSOM。浏览器遇到 <link rel="stylesheet"><style> 时启动 CSS 解析,把样式表文本转成 CSSOM 树——和 DOM 类似,但每个节点携带的是计算后的样式属性。这一步有一个致命的约束:CSS 解析是渲染阻塞的。浏览器不会渲染任何像素,直到 CSSOM 构建完成。理由很充分——如果先渲染了再改样式,用户会看到无样式内容(FOUC),体验更糟。但这个约束意味着:任何拖慢 CSS 下载的因素——比如 Google Fonts 的 CDN 响应慢——会直接导致白屏。

第三步:Render Tree → Layout → Paint → Composite。DOM 和 CSSOM 合并成 Render Tree(只包含可见元素)。Layout 计算每个元素的精确位置和尺寸。Paint 把元素画到各自的图层上。Composite 由 GPU 把所有图层合成最终的屏幕像素。对我而言,这一层最关键的认知是:Layout 和 Paint 是比较昂贵的操作——频繁修改触发它们的 CSS 属性(如 width、top)会导致滚动时卡顿。这就是为什么在第六章中,阅读进度条的 scroll 事件需要用 rAF 节流。

2.2 为什么不用框架

Tumanydays 目前约 99 个 HTML 页面。核心交互行为是:阅读、点击链接、滚动页面、偶尔触发彩蛋。没有用户认证、没有实时数据更新、没有表单提交(除留言本外,而留言本也只存在于浏览器内存中)。在这种场景下,React/Vue 等框架提供的虚拟 DOM diffing、响应式状态管理、组件生命周期完全是过剩的。

更重要的是,框架引入了构建步骤——webpack/vite 将 JSX/TSX/Vue SFC 编译为浏览器可执行的 JS bundle。这个构建步骤本身就是一个复杂度放大器:当部署失败时,错误可能出现在源码层、编译层、打包层、或 CDN 分发层——排查链路变长了。而原生 HTML + CSS + JS 的模式把故障面压缩到一行:改文件 → 保存 → git push

这个选择的代价是代码复用依赖复制粘贴和共享文件引用——没有模块系统、没有类型检查、没有组件封装。但对于个人维护的项目,这个代价是可接受的:style.css 和两个共享 JS 文件提供了 80% 的复用性,格式文档体系(见第七章)覆盖了剩余的规范性问题。

2.3 CSS 变量:为什么不是 SCSS 变量

全站配色通过 style.css 的 :root {} 伪类中 13 个 CSS 自定义属性(Custom Properties)统一管理。

CSS 变量和 SCSS/Less 变量有本质区别。SCSS 变量是编译时的——在构建步骤中被替换为字面值,浏览器看到的最终 CSS 文件中不包含变量。CSS 变量是运行时的——它们存在于浏览器的 CSSOM 中,实时参与级联计算。这两者的差异直接影响了我的选择:

(1)动态性:CSS 变量可以在 :root {} 中定义,在任意后代中通过 var() 引用。如果通过 JavaScript 修改 :root 上的变量值,所有引用自动更新——这是暗色模式切换的基础架构。SCSS 变量做不到这一点,因为它们在编译后已经消失了。

(2)级联与继承:CSS 变量遵循标准的 CSS 级联规则——子元素继承父元素的值,除非被子元素重定义。实际效果是:在 :root 定义全站默认值,然后在特定区域(如枕中密室的暖色背景区域)重新定义 --bg——只有该区域内的元素使用新值,其他区域不受影响。SCSS 变量没有级联概念,同名的 SCSS 变量要么覆盖要么被覆盖,没有「局部重新定义」这种优雅的中间状态。

(3)零依赖:CSS 变量是 CSS 标准的一部分(CSS Custom Properties for Cascading Variables Module Level 1),所有现代浏览器原生支持,不需要构建工具。SCSS 需要一个编译步骤(node-sass/dart-sass),而对于原生 HTML 站点来说,引入 npm 依赖来编译 CSS 是无谓的复杂度。

当前变量清单(值见 style.css 第 58-74 行):

--bg              → 页面背景色
--text            → 正文字色
--text-title      → 标题色(比正文略深)
--text-soft       → 次要正文色
--text-muted      → 引言、柔和文字
--text-dim        → 辅助文字(footer、meta 行)
--text-faint      → 最淡文字(底标、装饰)
--border          → 分割线、卡片边框
--border-soft     → 更柔的边框(引用块左边框)
--border-light    → 最淡分割线
--card-bg         → 日志大厅卡片半透明背景
--hover           → 灰蓝色(#8a939e),链接 hover
--ink             → 褪色蓝黑墨水色(#4a6b80),阅读进度条 & 交互反馈
--code-bg         → 行内代码背景
--blockquote-bg   → 引用块背景
--code-text       → 行内代码文字色

命名的原则是按功能(--text-dim)而非视觉属性(--gray-999)。这样当需要把辅助文字从浅灰改为暖灰时,改变的只是变量值,不需要理解它在每个页面中的角色。

2.4 style.css 的中央样式表模式

style.css 是 Tumanydays 的单一事实来源(Single Source of Truth)——所有页面的共同样式集中定义在这里。它覆盖:body 的字体族和颜色、6 级文字颜色变量、3 级边框变量、h1-h6 基础样式、a 链接 hover 行为、blockquote 引用块、table 表格、hr 分隔线、.dot 小圆点分隔符、code 行内代码、.back 返回链接、.nav-bottom 底部导航、.series-nav 系列导航、.container 居中容器、wander-mode 和 progress-bar 的 CSS。

页面自己的 <style> 块只写差异项:h1/h2/h3 的具体字号(因为不同板块标题层级不同)、本页特有的布局规则(诗笺排版、图片画廊、代码块等)。

这种模式的好处不只是在「不用复制粘贴」。它强制格式一致性——新页面引用 style.css 后自动获得与全站一致的引用块、表格、链接行为,没有「忘了写 hover 样式」「表格颜色不对」的人为疏漏空间。

三、文字的形状——字体加载策略

这是全站技术史中迭代次数最多的问题。最终方案表面上是几行 HTML 代码,下面是三次方向性调整和对浏览器字体加载机制的完整理解。

3.1 CJK 字体的核心矛盾

拉丁文字(英文、法文等)的字体文件通常包含 200-500 个字形(glyph),woff2 压缩后约 30-100KB。中文不同。CJK 统一汉字基本区(U+4E00 到 U+9FFF)包含 20,902 个汉字,加上扩展区 A-H 总计超过 90,000 个码位。Noto Serif SC 覆盖了基本区的全部汉字,一个完整字重(Regular/400)的 woff2 压缩文件约 4MB。

4MB 对于现代网页来说不算「大」——一张未压缩的照片更重。但照片是内容页面里的元素,加载慢一点用户可以先看到文字。字体不同——文字在没有字体之前要么以系统字体显示(违背设计意图),要么根本不可见。而 4MB 从 GitHub Pages(美国)加载到中国大陆用户,在典型网络条件下需要 8-20 秒。这不是「慢」的问题,是「用户已经走了」的问题。

思路只有一个:不下载用户不需要的字形。一个典型的中文页面实际使用 500-2,000 个不同的汉字,而非 20,902 个。如果能做到按需下载,数据量从 4MB 降到 200-500KB——10 倍的差距。问题从「可能解决不了」变成了「可以解决,需要找对方法」。

3.2 @font-face 和 unicode-range 的机制

@font-face 声明告诉浏览器一件事:「这里有字体文件,当你需要渲染某些字符时可以用它。」

@font-face {
  font-family: 'Noto Serif SC';
  font-style: normal;
  font-weight: 400;
  src: url(fonts/NotoSerifSC-400-subset.woff2) format('woff2'),
       url(fonts/NotoSerifSC-400.woff2) format('woff2');
  unicode-range: U+4E00-9FFF;  /* 只覆盖 CJK 基本区 */
  font-display: swap;
}

unicode-range 是这个机制的核心。它告诉浏览器:这个字体文件只包含指定 Unicode 范围内的字形。浏览器在渲染文字时检查每个字符的 Unicode 码点——如果落在某个 @font-face 声明的 unicode-range 内,就下载那个字体文件;否则使用回退字体。你可以把它想象成一本字典:不必把整本字典寄给每个读者,只需要把读者实际要查的那几页复印了寄过去。

Google Fonts CDN 把这件事做到了极致:将 CJK 字体按 unicode-range 拆成 80+ 个微片段——每个约 10-50KB,只包含一个窄范围的汉字。浏览器解析完 HTML 后,计算页面实际用了哪些字符,只下载覆盖这些字符的那 2-5 个片段——总共 200-500KB。自托管做不到这一点,因为手动把字体文件拆成 80+ 个片段、再写对应的 80+ 个 @font-face 声明——维护成本远超收益。

3.3 渲染阻塞与 media 切换——问题的核心

浏览器在构建 CSSOM 之前不会渲染任何像素。原因是:如果先渲染再应用样式,用户会看到无样式内容的闪烁(Flash of Unstyled Content, FOUC);而如果把样式应用后再渲染,就可以一次性地呈现正确的外观。所以浏览器选择等待所有样式表加载完成——<link rel="stylesheet"> 是渲染阻塞资源。

Google Fonts 的 CSS 文件是一个动态生成的样式表——它包含所有 @font-face 声明。虽然文件本身不大(约 5-20KB),但它在 Google 的 CDN 上,中国大陆用户访问这个 CDN 可能慢到几秒甚至十几秒。在这段时间里,浏览器不会渲染任何内容——页面是白屏。

media 切换是绕开渲染阻塞的标准技术:

<link href="https://fonts.googleapis.com/css2?family=..." rel="stylesheet"
      media="print" onload="this.media='all'">

浏览器将 media="print" 的样式表视为只对打印媒体有效——它不参与屏幕渲染,因此 不阻塞初始渲染。样式表仍然在后台下载和解析。下载完成后,onload 事件触发,JavaScript 将 media 切换为 all——此时 CSSOM 更新,浏览器应用了新加载的 @font-face 声明,开始下载字体文件,文字随后从系统字体平滑切换为 Noto Serif SC。

时间线:

t=0ms      HTML 开始解析
t≈50ms     HTML 解析完成,DOM 就绪(99 个页面的 HTML 都很轻量)
t≈50ms     CSSOM 从 style.css 构建完成(style.css 在本地,约 6KB)
t≈50ms     浏览器开始首次渲染——文字以系统字体显示
          (Google Fonts CSS 在后台下载中……)
t≈200ms    Google Fonts CSS 下载完成,onload 触发,media 切换为 all
t≈200ms+   浏览器开始下载 unicode-range 分片(200-500KB)
t≈500ms+   Noto Serif SC 分片加载完成,文字切换

对比没有 media 切换的情况:t≈200ms 之前页面是白屏——因为浏览器在等待 Google Fonts CSS 完成才渲染。

3.4 font-display 的时间线——swap 为什么是最优解

font-display 控制字体加载期间浏览器的渲染策略。它定义了三个阶段:

font-display 值block 期swap 期超时后
auto由浏览器决定(通常 ~3s)由浏览器决定回退字体
block3s(可配置)无限
swap100ms无限
fallback100ms3s回退字体(不再切换)
optional100ms回退字体(不再切换)

在 block 期,浏览器使用不可见的占位符(用户看到空白);在 swap 期,浏览器使用回退字体(用户看到系统字体),字体加载完成后切换。

swap 的工作方式:极短的 block 期(100ms)避免用户看到空白文字,之后立即使用回退字体保证可读性,字体加载完成后平滑切换。这恰好匹配 Tumanydays 的需求——Noto Serif SC 是视觉增强,不是功能性必需。用户在 100ms 内看到系统字体,500ms 后看到正确字体——体验上没有「空白」的瞬间。

block 会将不可见的等待延长到 3 秒,对于一个可能从海外 CDN 加载 4MB 字体文件的场景来说是不可接受的。用户看到的是 3 秒的空白文字区域。

optional 在 100ms 内如果字体没到就放弃,即使字体稍后到达也不再切换。这对于「字体是装饰」的拉丁文字场景是合理的,但对于 CJK 网站来说意味着大量用户永远看不到设计意图的字体。

3.5 preconnect vs preload

首页在 <head> 最顶部添加了两行:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

preconnect 指示浏览器在页面加载的最早阶段就建立到指定域名的 TCP 连接和 TLS 握手(如果尚未建立)。正常情况下,浏览器只有在解析到 <link href="...googleapis.com..."> 时才会开始建立连接——这发生在 HTML 解析的中间阶段。preconnect 将这个时机提前到 HTML 的第一个字节被解析之后,节省了 100-200ms 的连接建立时间。

为什么不直接用 preloadpreload 不仅建立连接,还直接开始下载资源。对于 Google Fonts CSS 来说,preload 确实会更快——但代价是它绕过了 media="print" 的非阻塞能力。preload 的资源不管 media 是什么都会下载,下载完就会应用——渲染再一次被阻塞了。preconnect 只在连接层面加速,不改变资源的加载语义,这是它比 preload 更合适的原因。

3.6 CDN 与自托管的优先级竞赛

全站实际运行着两套 @font-face 声明:Google Fonts CDN 的(通过 <link> 加载)和 style.css 中的自托管声明。当两套声明都为同一个 font-family + font-weight + font-style 组合定义了字体时,浏览器如何选择?

CSS 规范规定了 @font-face 的匹配优先级:浏览器按样式表的声明顺序检查所有匹配的 @font-face 规则,选择第一个 unicode-range 覆盖目标字符、且字体文件可用的声明。Google Fonts 的 <link> 在 HTML 中出现在 style.css 的 <link> 之前,所以在正常网络条件下,CDN 的声明具有更高优先级。CDN 不可达时(CSS 文件加载失败),style.css 中的自托管声明就成为唯一的匹配项。

这就是「混合投递」方案的精髓:快速路径走 CDN 分片,慢速路径走自托管全量。不需要 JavaScript 检测网络状态,不需要手动切换——CSS 级联自动处理了优先级。

3.7 p5.js 的加载位置——同一个原理的另一个教训

p5.js(约 450KB)的加载问题属于同一类原因:渲染阻塞,但渠道不同。CSS 阻塞通过 CSSOM 依赖链;JavaScript 阻塞通过 HTML 解析器。

浏览器在解析 HTML 时,遇到 <script>(不带 asyncdefer)会暂停 HTML 解析,下载并执行脚本,然后继续解析。原因是脚本可能调用 document.write 修改 HTML 结构——浏览器必须假设最坏情况。即使脚本不调用 document.write,HTML 解析器的设计也无法提前知道这一点。

<script src="js/p5.min.js"> 放在 <head> 中的后果是:在 450KB 的脚本下载和执行完成之前,浏览器不会开始解析 <body> 中的任何内容。用户看到的是空白页面。对于中国大陆访问 GitHub Pages 的网络条件,450KB 可能需要 5-15 秒。

修复只花了一分钟:把 <script> 移到 </body> 之前。此时所有 HTML 已经被解析和渲染,脚本的下载和执行不影响用户看到内容。这个教训的代价不是修复时间——是定位时间。当你看到白屏时,你不会立刻想到「因为 script 标签的位置」。

也尝试过 CDN 动态注入方案——用 JavaScript 动态创建 <script> 标签加载 CDN 版 p5.js,设置超时后回退到本地文件。设计上很完美。现实给了另一种答案:CDN 服务器接了 TCP 连接(所以 onerror 不触发),但数据传输慢到几乎停滞。超时计时器响了,回退脚本开始加载——几乎同时 CDN 的数据终于到了。两套 p5.js 竞相初始化,谁也跑不完。这种失败模式在本地测试时不会出现(本地网络通畅),上了生产才发现。结论:对于 3 个页面使用 p5.js 的站点,本地同步加载的简单粗暴胜过任何优雅的容灾方案。

四、速度的代价——CDN 与性能

4.1 HTTP 缓存的机制

Cloudflare CDN 的性能提升依赖于缓存。理解缓存需要先理解 HTTP 缓存的两个核心响应头:

Cache-Control: max-age=86400 告诉浏览器(和中间的 CDN 节点):「这个资源在 86,400 秒(24 小时)内是新鲜的,不需要重新请求。」

ETag: "abc123" 是资源的版本标识(通常是文件内容的哈希)。当缓存过期后,浏览器发送 If-None-Match: "abc123"。服务器检查文件是否改变——如果哈希相同,返回 304 Not Modified(空 body,几字节的响应头);如果不同,返回新的内容。

Tumanydays 的缓存策略分两层。源站(GitHub Pages)不设置任何缓存头——GitHub 对 Pages 的响应使用默认策略(HTML 不缓存,静态资源适度缓存)。Cloudflare 在 CDN 层叠加自己的缓存规则:HTML 文件设置较短的缓存时间(或不缓存),确保更新及时传播;CSS/JS/字体/图片等带有版本号或内容哈希的静态资源设置更长的缓存时间(数天到数月)。

4.2 缓存清除与 purge-cf.sh

git push 后需要立即让全站看到更新时,不能等待缓存自动过期。Cloudflare 提供 API 来清除(purge)缓存——支持按 URL 前缀清除(如清除 logs/棱镜/* 下的所有缓存)或全站清除(purge everything)。Tumanydays 使用全站清除——因为站点规模小(约 99 个页面),全站清除的消耗可以忽略不计。

API 调用需要两样东西:Zone ID(Cloudflare 分配的区域标识符)和 API Token(或 API Key + Email)。这些凭证存储在一个本地 shell 脚本中——该脚本通过 .gitignore 排除,不会进入 Git 仓库。脚本的核心是 curl 调用:

curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

为什么用 API 而不是 Cloudflare 仪表盘?仪表盘是一个 GUI——成功的操作记录不可复现、不可自动化、依赖稳定的网络连接到 Cloudflare 的管理界面。API 调用是幂等的(同样的请求产生同样的结果)、可脚本化的、可追溯的(HTTP 状态码和响应 body 明确报告成功或失败原因)。对于需要频繁执行的运维操作,API 在可靠性上优于 GUI。

4.3 代码优化的物理上限

非阻塞字体加载、本地化 p5.js、CSS 变量替代硬编码、preconnect 预连接——这些代码层面的优化确实减少了页面加载时间。但它们不能解决一个物理事实:光在光纤中的速度约为 200,000 km/s,中国大陆到美国西海岸 10,000 公里,单向传播延迟最低约 50ms。加上路由器跳转、队列延迟、TCP 拥塞控制(慢启动、拥塞避免),实际 RTT 是 150-300ms。

建立 TCP 连接(1 个 RTT)+ TLS 握手(TLS 1.3 下 1 个 RTT)+ HTTP 请求-响应(至少 1 个 RTT)= 最少 3 个 RTT,即 450-900ms,在发送第一个 HTML 字节之前。这还只是连接建立时间,不包括 HTML 和资源下载时间。

Cloudflare 代理通过香港/东京节点将这条物理路径从 10,000 公里缩短到约 2,000 公里(中国大陆到香港),理论 RTT 从 150-300ms 降到约 50ms。但这不是魔法——缓存完全未命中且回源 GitHub Pages 慢时,用户的请求仍然需要排队等待回源完成。

这就是为什么有些优化「值得做」,有些「做不做都一样」。分清这两者,是性能优化的第一课。给静态资源加一年的缓存——值得做。试图压缩 HTML 来省 2KB——不值得。前者触及了问题的物理根源,后者在跟基本算术较劲。

4.4 外部 CDN 依赖的风险评估

理论上可以为 style.css、p5.min.js、字体文件各自配置独立的 CDN(jsDelivr、unpkg、Google Fonts)。我选择只对字体使用外部 CDN——因为 CJK 分片能力自托管无法实现。其他资源从 GitHub Pages + Cloudflare 加载已经足够快:style.css 约 6KB,p5.min.js 约 450KB(仅 3 个页面使用),将它们放在外部 CDN 上每增加一个故障点而收益微乎其微。

判断是否引入外部 CDN 依赖的公式:依赖不可用的概率 × 不可用时的用户体验损失 > 自托管的带宽和维护成本 时,才值得引入。对于 Google Fonts:概率低(Google 基础设施的可用性极高),损失高(用户看到系统默认字体 vs 设计字体,体验差距显著),自托管成本也高(4MB 全量字体),所以值得。对于 p5.js:自托管成本低(一个文件、450KB、放在仓库中),外部 CDN 的收益(可能快一点)小于引入的复杂度(需要管理回退逻辑),所以不值得。

五、隐藏的房间——彩蛋系统的物理与逻辑

5.1 三层结构的总体设计

彩蛋系统不是三个独立的功能——它们的排列构成了渐进的发现难度和递增的技术复杂度:

第一层(键盘暗号)最简单——输入缓冲区 + 字符串匹配。桌面端专属,没有 UI 提示。实现细节:keydown 事件监听器维护一个字符数组,每次按键时 push 到数组末尾,比较最近 N 个字符(N 为目标序列长度)是否匹配目标。为什么要用缓冲区而非逐字符匹配?因为用户可能在任意时刻开始输入,而且可能在输入序列中夹杂其他按键(如不小心碰到无需的键)。缓冲区确保只检查最近的连续输入。

第二层(假入口)是社交层的——一个外观高仿真实彩蛋的独立页面散落在站内随机位置。技术实现上它是一个普通 HTML 文件,JS 逻辑几乎与真实彩蛋一致——区别只在最终点击事件的目标 URL。

第三层(拖拽 + 弹簧物理 + 密码验证)是全站最复杂的交互系统。它涉及:DOM 操作(逐字符拆分)、物理模拟(弹簧模型)、浏览器渲染管线(requestAnimationFrame)、浏览器存储(sessionStorage)、以及页面间的状态传递。

5.2 逐字符拆分与位移分布

首页标题「Tumanydays」(10 个字符)被 JavaScript 拆分为 10 个独立的 <span class="char"> 元素,每个设置为 display: inline-block 以支持独立的 transform。容器的 white-space: nowrap 防止换行;user-select: none 防止拖拽时意外选中文字。

拖拽时每个字符的位移量由位置因子决定:

factor = (charIndex / (totalChars - 1)) ^ 1.3

为什么是指数 1.3?线性分布(指数 1)下字符之间等距拉开——绳子被均匀拉长,缺乏「力从一端传入」的物理感。二次方(指数 2)下最右端的字符几乎承担了所有位移,像一个单点被拖走而其他点不动。1.3 介于两者之间:右端字符位移最大(因子 1.0),但左端字符(因子约 0.06)也有微小位移——视觉上像一根弦被从右端拉动,力逐渐传到左端。

5.3 弹簧物理的数学原理

松手后的回弹动画不是 CSS transition,而是 JavaScript 驱动的物理模拟——原因是 CSS transition 只能做「从状态 A 平滑过渡到状态 B」,而弹簧的运动是「在平衡点附近来回振荡并逐渐衰减」,这是二阶动力学系统的行为。

物理模型——阻尼谐振荡器:

加速度 = -k × 位置  +  (-d × 速度)

第一项 -k × 位置 是胡克定律——弹簧力与偏离平衡点的位移成正比,方向总是指向平衡点(负号)。k 越大,弹簧越「硬」。第二项 -d × 速度 是阻尼力——与速度成正比、方向相反。d 越大,振荡衰减越快。

离散化——半隐式欧拉积分:物理模型是连续时间的微分方程,计算机需要将其离散化到每帧(约 16.7ms)。普通(显式)欧拉积分不稳定:

位置(t+Δt) = 位置(t) + 速度(t) × Δt
速度(t+Δt) = 速度(t) + 加速度(t) × Δt    ← 加速度用的是旧位置

半隐式欧拉积分更稳定,因为它先更新速度再用新速度更新位置:

速度(t+Δt) = 速度(t) + 加速度(t) × Δt
位置(t+Δt) = 位置(t) + 速度(t+Δt) × Δt    ← 用了新速度

动态参数:k 和 d 不是固定值——它们随拖拽距离动态调整。短距离(约 10px)时 k=0.020、d=0.12,高阻尼、快速收紧——像捏橡皮筋再松开,回弹微小(约 4%)。长距离(约 60px+)时 k=0.025、d=0.025,低阻尼、大幅振荡——像弹弓发射后的余振(约 12% 回弹,多次衰减)。

非线性回复力:标准弹簧模型在过冲峰值的停留感偏长——当位置 < 0(弹过初始位置进入反向区域),回复力需要增强来加快归位。做法是叠加一个立方项:

if (pos < 0) extraForce = -0.3 × pos³  // pos 为负,pos³ 为负,-0.3×负 = 正 → 增强回复力

立方项的选择不是任意的——偶次方(pos²)在负区会给出错误的力方向,奇次方保留符号。三次方在接近平衡点时趋近于零(不影响自然衰减),在远离平衡点时快速增长(有效消除停留感)。

5.4 smoothstep 和缓动曲线

拖拽过程中的视觉变形(skewX/skewY/颜色)不是线性跟随拖拽距离的,而是经过 smoothstep 映射:

progress = min(distance / maxDistance, 1)
eased     = progress² × (3 - 2 × progress)

这个公式来自 Hermite 插值——满足 f(0)=0, f(1)=1, f'(0)=0, f'(1)=0(两端速度为零)。在 0-50% 进度区间加速,50-100% 减速。视觉上:开始拖拽时变形响应灵敏(用户立刻看到反馈),接近阈值时变形减缓(产生「越拉越沉」的阻力感)。

5.5 requestAnimationFrame 的原理

弹簧动画和拖拽变形都通过 requestAnimationFrame(rAF)驱动,而非 setInterval。两者都能在固定间隔执行代码,但 rAF 有三重优势。

第一,帧同步。rAF 在浏览器每一帧渲染前执行回调——60Hz 屏幕每 16.7ms 一次,120Hz 屏幕每 8.3ms 一次。动画逻辑的更新节奏和屏幕刷新节奏天然一致,不会出现「算了两帧但只显示了一帧」的浪费,也不会出现「跳了一帧画面」的卡顿。

第二,自动节能。用户切到其他标签页时,rAF 自动暂停——动画不需要在看不见的地方继续运行。setInterval 缺乏这种感知能力,会在后台持续消耗 CPU。

第三,批量合成。浏览器可以把同一帧内多次 rAF 回调中的 DOM 修改合并为一次 Layout/Paint,避免布局抖动(layout thrashing)——即「读一个属性触发 Layout,写一个属性触发 Paint,反复交替」导致的性能灾难。这个优化对弹簧动画尤为重要——每帧需要更新 10 个字符的 transform,如果不能批量处理,每帧至少触发 10 次 Layout。

5.6 sessionStorage 的隔离模型

隐藏房间的入口链需要跨页面保持状态(「已验证」VS「未验证」),但不希望状态在浏览器关闭后持久化。sessionStorage 恰好匹配这个需求:

存储方式生命周期作用域容量
localStorage永久(除非手动清除)同源~5MB
sessionStorage标签页关闭时清除同源 + 同标签页~5MB
cookie可设过期时间同源(可设 path)4KB

选择 sessionStorage 的理由:密码验证的生命周期就应该是「一次会话」——关闭标签页后需要重新验证。如果用 localStorage,一旦验证通过就永久有效(除非手动清除),这削弱了隐藏房间的隐私边界。而且 sessionStorage 的标签页隔离意味着即使用户打开了多个 Tumanydays 标签页,验证状态不会在它们之间泄漏。

六、重复出现的东西——共享组件

6.1 progress-bar.js:scroll 事件与性能优化

阅读进度条通过监听 scroll 事件来计算和更新进度。原理简单:

百分比 = scrollTop / (scrollHeight - clientHeight) × 100

scroll 事件是浏览器中触发频率最高的事件之一——滚动时每帧(约 16.7ms)可以触发数十次。如果在事件回调中直接执行 DOM 操作(修改进度条宽度),每次操作触发一次 Layout/Paint——这在快速滚动时累积成显著的性能开销,表现为滚动不流畅(jank)。

进度条的更新不应该直接放在 scroll 回调中。正确的做法是 requestAnimationFrame 节流:scroll 回调只设置一个脏标记(dirty = true),不碰 DOM。rAF 每帧检查这个标记——标记为真就执行一次 DOM 更新然后清除。无论 scroll 事件触发了多少次(可能每帧几十次),DOM 更新每帧最多一次。

var dirty = false;
window.addEventListener('scroll', function() {
    dirty = true;       // 标记,不执行 DOM 操作
});
function tick() {
    if (dirty) {
        updateProgressBar();  // 更新 DOM
        dirty = false;
    }
    requestAnimationFrame(tick);
}
tick();

6.2 wander-mode.js:链接劫持与随机跳转

迷失域的核心机制是劫持所有 <a> 元素的点击事件。技术路径:激活时,页面上的事件委托(event delegation)拦截所有 click 事件,检查目标是否为链接——是则 preventDefault() 阻止默认跳转,通过 Fisher-Yates 洗牌算法从预定义的站内页面列表中随机选一个,用 window.location.href 跳转。

为什么用事件委托而非给每个 <a> 绑定事件?事件委托将监听器挂在 document 上,利用事件冒泡(event bubbling)捕获所有链接的点击。好处:不需要在激活/退出时遍历 DOM 添加/移除监听器,对后续动态添加的链接自动生效。

状态通过 sessionStorage 保存,翻页后模式保持(sessionStorage 在标签页内跨页面共享)。退出时清除标记,恢复首页标题状态(调用 window._resetWanderTitle,由首页 JS 暴露的全局函数)。

Fisher-Yates 洗牌算法是随机排列的核心——它保证每个页面被选中的概率均等(无偏)。算法的直觉:从数组末尾开始,每个位置与它之前(含自身)的随机位置交换:

for (var i = arr.length - 1; i > 0; i--) {
    var j = Math.floor(Math.random() * (i + 1));  // 0 ≤ j ≤ i
    var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
}

为什么不用更简单的 arr.sort(() => Math.random() - 0.5)?因为 sort 比较函数的随机性不保证均匀分布——某些排列的概率可能高于其他排列,取决于排序算法的实现。

6.3 为什么不再进一步组件化

ES modules(import/export)、npm 依赖管理、构建工具(webpack/vite)能为 JS 代码提供模块封装、依赖解析和 tree-shaking。但对于一个只有两个共享 JS 文件 + 少量内联脚本的站点来说,引入这套体系的维护成本(配置、更新、故障排查)远超收益。两个独立的 .js 文件,每个页面一行 <script> 引用——这是当前规模下复杂度和维护性的最优平衡点。如果未来共享组件超过 5 个,或出现跨组件的依赖关系,再考虑引入模块化不迟。

七、建造的节奏——版本管理与发布流程

7.1 Git 的内部模型——理解版本管理的底层

Git 的版本管理建立在四种对象之上。按抽象层级从低到高:blob 是文件内容的快照——Git 不存差异,存完整副本。tree 是目录的快照,包含指向 blob 和其他 tree 的指针。commit 指向一个 tree(那一刻的项目全貌)、指向前一个 commit(形成历史链)、附带作者、时间戳和提交信息。tag 是给某个 commit 的具名标签,可选附注。

理解这个模型有一个实际意义——它解释了为什么 .gitignore 不能保护已经被提交过的文件。一旦某个文件以 blob 形式写入 Git 历史,即使你后来把它加入 .gitignore 并从工作区删掉,它仍然安静地躺在所有历史 commit 的 tree 里——任何人 clone 仓库都可以从历史中恢复它。这就是含凭证的文件必须在首次 commit 之前被排除的原因。被推送过的凭证等于已经公开。

7.2 三步舞推送与 GitHub Pages 部署链路

git add [具体文件]       # 将修改加入暂存区(staging area)
git commit -m "板块:简述"  # 创建 commit 对象,保存快照
git push                 # 推送 commit 到 GitHub 远程仓库

git push 之后,GitHub Pages 的部署是自动化的:GitHub 检测到 main 分支更新 → 触发 Pages 构建(对于纯静态站点只是文件校验和分发)→ 文件被分发到 GitHub 的 CDN 边缘节点。从 push 到 GitHub Pages 源站更新通常在 10-30 秒内完成。

但用户访问的不一定是源站——Cloudflare 边缘节点的缓存可能还保留着旧版本。这就是第三步(可选的 purge-cf.sh)存在的意义。

提交信息格式使用板块前缀——「字渡:」「棱镜:」「观澜:」等——让 git log --oneline 具有可读的分类信息。

7.3 格式文档体系——比代码更重要的基础设施

Tumanydays 的格式规则由一个本地模板目录维护——包含网站格式、日志文章写作提示词、Echo 碎片策展规则、古文格式、革命叙事模板,以及版本日志。这些文档的定位不是「写完了就不用再看的说明书」,而是每次发现新规则或修改旧规则时同步更新的活文档

从技术管理的角度来看,这相当于把 Architecture Decision Records(架构决策记录)和 Style Guide(风格指南)合并到一套文档里。对于个人项目来说,分开维护 ADR 和 Style Guide 是过度的——合并减少了文档间交叉引用的维护成本。

八、已经犯过的错误

以下每一条都经过了数小时的定位和验证才被确认为错误。

8.1 CJK 字体必须分片

20,902 个汉字的全量 woff2(约 4MB/字重)在任何网络条件下都是沉重的。自托管全量字体对跨洋链路的用户来说不是「慢」,是「可能永远加载不完」——TCP 连接在下载大文件时更容易受到丢包和超时的影响。Google Fonts CDN 的 unicode-range 分片不是性能优化,是可用性门槛。

8.2 p5.js 不能放在 <head> 中

同步 <script><head> 中阻塞 HTML 解析器——在 450KB 脚本下载和执行完成之前,浏览器不会开始解析 <body>。用户看到的是 5-15 秒的白屏。解决方案:<script> 放在 </body> 之前。如果必须放在 <head> 中,使用 defer(延迟执行但保留顺序)或 async(异步执行,不保证顺序)——但前提是脚本不操作 DOM,而 p5.js 需要操作 DOM(创建 canvas)。

8.3 CDN 动态注入方案有隐蔽的失败模式

用 JavaScript 动态创建 <script> 标签加载 CDN 版 p5.js,设置超时回退本地。失败不在设计阶段暴露——CDN 服务器可以正常响应 TCP 连接(所以 onerror 不触发),但数据传输极慢。超时计时器触发时 CDN 脚本可能刚好开始执行,造成竞态条件。结论:对于小型站点,本地同步加载比 CDN+回退方案更可靠。

8.4 中国大陆访问 GitHub Pages 的瓶颈是物理网络路径

代码优化消除不掉光纤物理延迟。Cloudflare 代理是将网络路径从「用户→美国」改为「用户→就近 Cloudflare 节点→(仅回源时→)美国」的有效方案,但它不是魔法——缓存未命中且回源慢时用户仍会感受到延迟。

8.5 Cloudflare API 比 Dashboard 更可靠

通过 curl + REST API 操作 DNS、清除缓存,比仪表盘点击更可控。原因:API 有明确的 HTTP 状态码和 JSON 错误信息(仪表盘的错误提示可能不准确或缺失),可脚本化(不需要手动重复操作),幂等(同一个 API 调用产生相同结果——仪表盘的页面状态可能受浏览器缓存或登录状态影响)。

8.6 含凭证的文件必须在首次 commit 前被 .gitignore 排除

Git 的快照模型意味着:一旦文件被提交,即使后续从工作区删除并更新 .gitignore,提交历史中仍保留该文件的所有版本。从历史中彻底移除需要 git filter-branch 或 BFG Repo-Cleaner——这两者都会重写提交历史(改变 commit SHA),对协作项目有破坏性影响。而且移除历史版本的操作为时已晚——如果仓库是公开的,凭证在被推送的那一刻就已经暴露,正确的应对是立即轮换(rotate)凭证。

8.7 DNS nameserver 变更需验证根服务器

注册商后台显示「nameserver 已修改」只是注册商的数据库已更新。但全球 DNS 的生效取决于 TLD 根服务器的 NS 记录是否已更新、以及各级缓存是否过期。正确验证:

dig NS tumanydays.com @a.gtld-servers.net  # 直接查询 .com TLD 根服务器
# 返回的 nameserver 必须是 Cloudflare 分配的,否则变更未到达根服务器

8.8 不使用不适合代码编辑的工具

macOS 文本编辑(TextEdit)默认使用 RTF(富文本格式),即使保存为纯文本也可能引入智能引号(将 ' 替换为 ' 或 ')、长破折号、非标准换行符。这些字符在 HTML/CSS/JS 中会导致难以排查的语法错误。始终使用为代码编辑设计的工具(VS Code)。

待延伸线索