knife

AI重构个人主页

目录

我之前的个人博客是通Hexo来进行搭建的,用了一个landscape-jr0cket主题,用了10多年了,当初搭建的记录还在:通过Hexo在GitHub搭站全记录。不过我现在不喜欢了,我也不想继续用Hexo来创建静态的站点内容了,里面我修改的书签导航栏自适应也不好,对我来说CSS布局这些看着头疼。于是让「Claude-Opus-5」帮我搭建一个工具,自动生成静态站点,Pormpt如下:

我之前的个人博客是通过hexo来进行搭建的,我想你帮我重写一套基于md生成静态站点的工具,工具要简化一点,但是生成的静态站点的UI样式要符合现代的审美,简约而优雅,可以参考:https://investment.walkerdu.com;

排版的布局的要求:还是和以前一样:https://walkerdu.com/,主站左右布局,左侧是文章列表,右侧是分类,标签,年份汇总等;

静态站点需要支持disqus的评论模块,像之前hexo那样,最好有浏览数据量得PV数据;

UI布局设计

参考 investment.walkerdu.com 的设计 token 重新对齐:

Token 值 含义
--bg #F7F5F3 米白底色
--surface #FFFFFF 卡片底色
--text #2D2926 暖黑正文
--accent #D97706 琥珀橙强调
--accent-soft #FEF3C7 强调色浅版
--font-serif Georgia, 'Songti SC', ... 衬线正文
--font-sans -apple-system, 'PingFang SC', ... 元信息

最终文章页采用三栏布局:

text
┌──────────┬────────────────────┬────────────┐
│  TOC     │  正文内容          │  Sidebar   │
│  220px   │  flex: 1           │  300px     │
│  sticky  │  max-width 受 grid │            │
└──────────┴────────────────────┴────────────┘

断点逻辑:

  • > 1300px:三栏
  • 1100–1300px:TOC + 正文,sidebar 下沉到第 2 列下方
  • < 1100px:单栏,TOC 转顶部可折叠 <details>

踩坑:sticky 元素与跨列 grid 冲突

≤1300px 时 sidebar 跨列到第一列,sticky 的 TOC 会一直停留在视口,与跨列的 sidebar 视觉重叠。

修法:改 grid-column: 2 只占正文列;≤1100px TOC 隐藏后再 grid-column: 1。

css
.article-aside {
    align-self: start;   /* 把 sticky 盒子锚定在所在行顶部 */
}

第一阶段:重写生成器

架构设计

AI实现设计了一个mdsite Python工具,单包构建,零前端依赖。最终目录结构如下:

text
md_blog/
├── *.md                   文章(front-matter 控制是否发布)
├── <文章名>/              配图目录
└── homepage/              站点相关的一切
    ├── site.yml           配置
    ├── mdsite/            生成器 Python 包
    │   ├── config.py      配置加载
    │   ├── loader.py      Markdown 解析 + front-matter
    │   ├── render.py      HTML 渲染
    │   ├── builder.py     产物构建
    │   └── themes/journal/ 主题(模板 + CSS + JS)
    ├── cf-worker/         Cloudflare Workers PV 计数器
    ├── Dockerfile
    └── public/            构建产物

整个生成器只有 5 个模块、约 1800 行 Python,依赖 5 个包:

包 用途 为什么选它
markdown-it-py Markdown → HTML 严格遵循 CommonMark,插件机制干净
mdit-py-plugins front-matter / 任务列表 官方插件集
Jinja2 模板渲染 支持继承 + include,够用且无运行时
Pygments 代码高亮 构建期高亮,前端不用加载 highlight.js
PyYAML 配置与 front-matter 解析 —

Pillow 只在开启图片转换时才需要,默认关闭。

构建管线:Markdown 是怎么变成网页的

python -m mdsite build 这一条命令内部是一条五段流水线。整体数据流:

mdsite 构建管线

几个设计上值得说明的点:

配图走的是「旁路」。图片不经过 Markdown 渲染器,而是由 images.py 直接从源目录镜像到产物目录。但它必须先于 render.py 执行 —— 因为渲染器改写 <img> 标签时需要知道处理后的文件名和真实宽高。这个顺序依赖在 build() 里是显式的:

python
# Assets first: the renderer needs the processed filenames and
# dimensions to rewrite <img> tags.
for p in posts:
    self._copy_post_assets(p)
self.images.flush()

for p in posts:
    render_post(p, self.cfg)

上一篇/下一篇在渲染前就串好。load_posts() 返回的列表已经按日期倒序,所以相邻关系是纯索引操作:

python
for i, p in enumerate(posts):
    p.newer = posts[i - 1] if i > 0 else None   # 更新的一篇
    p.older = posts[i + 1] if i + 1 < len(posts) else None

模板上刻意没用含糊的 prev/next,而是「← 上一篇(更新)」「下一篇(更早)→」并各自带日期,避免方向歧义。

一份 Post 列表复用九次。首页、分页、文章页、分类页、标签页、归档总览、归档年份页、RSS、sitemap、搜索索引全部从同一个 posts 列表派生,没有二次解析 Markdown。65 篇文章全量构建 2.5 秒,其中绝大部分时间花在 Pygments 高亮上。

单篇文章的七次变换

把镜头推近到一篇具体的文章,看每一步到底改了什么:

一篇 Markdown 的七次变换

对应的代码就是 render.py 里这几行,顺序不能换:

python
def render_post(post, cfg) -> None:
    html = _MD.render(post.content_md)       # ③ Markdown → HTML
    html = _inject_heading_ids(html)         # ④ 标题注入锚点 id
    html = _rewrite_images(html, post, cfg)  # ⑤ 图片路径 + 宽高 + lazy
    post.content_html = html
    post.toc_html     = _extract_toc(html) if post.toc else ""   # ⑥
    post.summary_html = _make_summary(html)                      # ⑥

为什么 TOC 必须从渲染后的 HTML 里抽、而不是从 Markdown 源码里用正则找 ##:因为代码块里可能有 # 注释,源码正则会把它误判成标题。等 Markdown 渲染完,<h2> 已经是语义明确的结构,再抽取就不会出错。

Markdown 渲染器的配置也很克制:

python
md = (MarkdownIt("commonmark", {"highlight": _highlight_code})
      .enable("table")
      .enable("strikethrough"))
front_matter_plugin(md)
tasklists_plugin(md)

只在 CommonMark 基础上开了表格、删除线、任务列表三项。代码高亮通过 highlight 回调交给 Pygments,产出的是带 <span class="k"> 的静态 HTML —— 前端一行 JS 都不用加载,这也是后面性能优化能把第三方请求压到 1 个的前提之一。

浏览器缓存陷阱

开发过程中遇到改了 CSS 但页面看起来没变化。

根因:http.server.SimpleHTTPRequestHandler 只发 Last-Modified,不发 Cache-Control,浏览器对这类响应做启发式缓存,直接复用旧 CSS,连请求都不发。

辨认技巧:旧 CSS 有 @media (prefers-color-scheme: dark) 会在暗色系统下变黑底;新 CSS 只有 [data-theme="dark"]。看到黑底就知道浏览器没取新文件。

修法:dev server 的 end_headers() 注入:

python
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")

生产侧修法:静态资源内容指纹。对 theme 下所有 css/js 算 sha256 取前 10 位,通过模板变量注入:

html
<link rel="stylesheet" href="/static/css/journal.css?v={{ asset_v }}">

第三阶段:性能诊断与优化

站点部署到 Cloudflare Pages 之后,第一感觉是「慢,但说不清慢在哪」。这一节记录的是完整的定位过程,而不只是结论 —— 因为中间有两次我自己下错了判断,都是靠数据纠回来的。

诊断的八个步骤

性能诊断的八个步骤

这个顺序不是事后整理出来的「最佳实践」,而是真实的推进路径,每一步都由上一步的异常数据驱动。

步骤 1–2 的经历值得单独记:第一次 curl 测出 DNS=0.0001s / TCP=0.0006s,感觉很快,第二步才发现环境里注入了 HTTP_PROXY,测到的根本不是真实链路。如果不查环境,整个后续诊断都会基于虚假数据。

步骤 3–4 先把「可疑但实际无辜」的项排掉:curl --noproxy 重测确认 br 压缩有 4:1,工作正常;cf-cache-status: DYNAMIC 说明 HTML 每次完整回源,但这是 Pages 静态站的正常行为,不是问题。

步骤 5–6 才到真正有信息量的部分:外部域名清单暴露了 Google Fonts 和 Disqus 带出的广告追踪链,Playwright 瀑布流确认 51 张图并发拖死了 load 事件。

步骤 7–8 回到磁盘核实量级,并做带宽对照组 —— 这一步决定了哪些能用代码修,哪些代码解决不了。

测量方法的坑

重要:AI Agent 环境注入了 HTTP_PROXY=http://127.0.0.1:63211,curl 默认走代理,测出 DNS=0.0001s / TCP=0.0006s 这种假数据。

正确做法:

bash
curl --noproxy '*' ...                            # curl 测真实延迟
chromium.launch(args=["--no-proxy-server"])       # Playwright 测真实性能

这个坑还有第二次变体:后来用 curl --compressed 抓 HTML,看到 69KB(本地产物是 128KB),一度判断「线上部署的是旧版本、页面被截断了」。实际上 --compressed 统计的是压缩后的传输大小,解压后是完整的 127KB。确认截断的正确方法:连续下载三次比对大小,并检查文件末尾是否有 </html>。

性能基线与瓶颈分布

用 Playwright 绕过代理实测(3 轮中位数):

指标 首页 文章页 truenas-sth
TTFB 2373ms 1885ms
FCP 6064ms 3368ms
DOMContentLoaded 7205ms 3822ms
load 8396ms 60s 未完成
请求数 9 33
传输量 100KB / 24.7KB(br) 125KB / 31.4KB(br)

三组数据合起来看,时间花在哪、体积堆在哪一目了然:

瓶颈分解

A 图的结论有点反直觉:首页 FCP 6 秒里,自己的代码只占 227ms,其余 98% 是「回源到洛杉矶」+「等 Google Fonts」。 B 图同样:260MB 产物里 HTML/CSS/JS 加起来只有 11.6MB,图片占 195MB,生成器自身的质量在这道题里根本不是矛盾所在。

根因分析

根因按权重排序:

1. 图片未优化(文章页决定性)

单张 jellyfin_5.png 4.2MB、jellyfin_10.png 3.6MB,Retina 截图像素冗余。全站图片 ~195MB。

更关键:<img> 标签没有 loading="lazy"、没有 width/height,导致 51 张图全部并发加载,把跨洋带宽打满。

2. Google Fonts 阻塞渲染(首页决定性)

fonts.googleapis.com 的 CSS 是 head 里的 render-blocking stylesheet,裸 curl 测 TLS 握手 10.86 秒。

好消息:CSS 字体栈本来就有 Songti SC/PingFang SC 兜底,删掉 Google Fonts 在 Apple 设备上视觉几乎无损。

3. Disqus 立即加载

文章页一打开就加载 Disqus,连带拉起:

  • c.disquscdn.com(16.7s)
  • cdn.viglink.com 广告联盟(20.1s 未返回)
  • d-code.liadm.com LiveRamp 身份追踪(21.7s)

改成 IntersectionObserver,滚动进视口才加载。

4. 跨洋链路带宽(根本瓶颈,代码解决不了)

实测吞吐量:

text
第1次  25 KB/s(60s 只传 1.5MB,超时)
第2次  失败(0 bytes)
第3次  9.8 KB/s
第4次  7.4 KB/s
第5次  23 KB/s

平均有效带宽约 15 KB/s。cf-ray 全部 -LAX 后缀,路由到洛杉矶。代码层面的任何优化都解决不了这个问题,唯一出路是换部署节点或接国内 CDN。

5. 全站 canonical 指向首页的 SEO bug

html
<link rel="canonical" href="{{ canonical | default('/') }}">

builder.py 从没传过 canonical 变量,所以全站每一页的 canonical 都是首页 URL。Google 把所有文章当首页副本,文章不被收录。

同理 page_desc 也没传,全站 description 相同。

优化实施

图片懒加载:<img> 自动加 width/height + loading="lazy"。

注意:加了 width/height 之后,CSS 必须同时有:

css
img { max-width: 100%; height: auto; }

否则 max-width:100% 压缩宽度时高度属性不变,所有超宽图被拉伸变形。

图片转换(图片优化引擎,作为可选开关):

实测压缩收益(truenas-sth,48 张静图):

方案 原始 结果 代价
同格式重压(PNG optimize + 限宽 1600) 30.7MB 19.3MB(省 37%) URL 不变
转 WebP q82 + 限宽 1600 30.7MB 5.0MB(省 84%) img 路径变 .webp

动图的反常识:动画 WebP 在 q90 下比源 GIF 更大(248.5MB → 316.9MB)。GIF 的 256 色调色板对录屏极高效。必须单独设 animate_quality: 70,且加体积保护(输出 ≥ 源文件时直接放弃转换)。

去掉 Google Fonts:

css
--font-serif: Georgia, 'Songti SC', 'STSong', 'Source Han Serif SC',
              'Noto Serif CJK SC', 'Noto Serif SC', SimSun, serif;
--font-sans:  -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
              'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;

拉丁字体排前面让 ASCII 命中,中文回落到 CJK 族。

优化后文章页实测(本地):

指标 优化前 优化后
请求数 33 11
传输量 31MB(load 60s 超时) 0.69MB
第三方 disqus+viglink+liadm 仅 hm.baidu.com
图片 全部并发 只加载首屏 7 张

第四阶段:Cloudflare 缓存头排查

_headers 文件没有失效,是我写错了

最开始以为 _headers 文件没有被部署进去,用户截图证明这个结论是错的。

真因 1:Cloudflare Pages _headers 是所有匹配规则合并

这是最关键的发现。/feed.xml 的响应头把问题暴露得很直白:

text
cache-control: public, max-age=3600, public, max-age=0, must-revalidate
                ↑ /feed.xml 规则        ↑ /* 兜底规则被拼接上来了

我在 _headers 的 /* 兜底里写了 Cache-Control: public, max-age=0, must-revalidate,它被合并进了每一条更具体的规则。/static/css/journal.css 实际拿到的是:

text
public, max-age=31536000, immutable, must-revalidate
                          ↑ 我的    ↑ /* 污染进来的

immutable 和 must-revalidate 同时存在,等于长缓存被当场废掉。这就是为什么 cf-cache-status 一直是 REVALIDATED 而不是 HIT。

修法:/* 兜底只留安全头,不写 Cache-Control:

text
/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin

HTML 自然回落到 Pages 默认的 max-age=0, must-revalidate,正是想要的效果。

工程教训:Cloudflare Pages _headers 里给 /* 设 Cache-Control 是反模式。

真因 2:区域级 Browser Cache TTL 覆写源站 max-age

实测对比:

text
/static/css/journal.css   cf=REVALIDATED  max-age=14400  ← 被覆写
/                         cf=DYNAMIC      max-age=0      ← 原样透传
/feed.xml                 cf=DYNAMIC      max-age=3600   ← 原样透传

规律:只有走边缘缓存的响应被改成 14400(= 4小时,免费版默认值),DYNAMIC 的不受影响。

修法(需要在 Dashboard 手动操作):

text
Cloudflare Dashboard → walkerdu.com → Caching → Configuration
  → Browser Cache TTL → Respect Existing Headers

不改这项,max-age=31536000, immutable 永远只会以 4 小时生效。


第五阶段:侧边栏最近评论

设计决策

用户要求:右侧分类上面加一块最近评论,且不能是构建期拉取(新评论要等下次部署才出现是不能接受的)。

数据源选择

实测两个端点:

端点 CORS API key 结论
{shortname}.disqus.com/recent_comments_widget.js ❌ 无 不需要 采用
disqus.com/api/3.0/posts/list.json 部分 需要 支持 JSONP,留作升级

widget 端点没有 CORS 头,fetch() 走不通。它用 document.write 输出 HTML,唯一的方法是注入 <script> 并临时借用 document.write 收集输出。

安全劫持 document.write

关键实现:

javascript
const origWrite = document.write;
const buf = [];

document.write = function(str) {
    if (typeof str === 'string' && str.indexOf('dsq-widget') !== -1) {
        buf.push(str);   // 只吞自己的输出
    } else {
        origWrite.apply(document, arguments);  // 其他原样转发
    }
};

script.onload = () => settle(buf.join(''));  // 加载完成后无条件恢复
const timer = setTimeout(() => settle(null), cfg.timeout);  // 硬超时
document.head.appendChild(script);

注意:动态注入的 script 是 async,浏览器本会忽略 document.write,但我们已劫持,所以能正常收集。

hybrid 模式:比纯客户端更优

三种模式对比:

mode 新评论 Disqus 被墙的读者 HTML 构建耗时
hybrid(推荐) 实时 看到构建期快照 +1.5KB +1.3s
client 实时 整块超时后消失 +0 +0
build 要等下次部署 看到快照 +1.5KB +1.3s

hybrid = 构建期烤一份快照进 HTML(兜底) + 浏览器异步覆盖成最新。

实测:

text
无评论块基线: FCP 108ms  DCL 92ms  资源请求 7
有评论块    : FCP 108ms  DCL 90ms  资源请求 7   ← 逐项相同
hybrid 可达 : FCP 164ms  CLS 0.0000
hybrid 被墙 : FCP 108ms  CLS 0.0000(快照兜底,5 条照常显示)

对主站 FCP 和请求数零影响,通过三条机制保证:

  1. requestIdleCallback 延后发起
  2. 骨架占位与真实结构同形,替换时 CLS=0
  3. 客户端硬超时 8 秒(必须自己计时,Disqus 在被墙网络上 script.onerror 可能要等几分钟)

Python 正则解析的严重 bug

第一版 Python 端用正则解析 widget 输出:

python
_ITEM_RE = re.compile(
    r'<span class="dsq-widget-comment">(.*?)</span>',
    re.S
)

结果:原始 8 条评论只解析出 6 条,丢掉了含 <a> 链接的两条。

原因:当评论正文里有 <a>/<code> 时,body 内没有 </span>,惰性匹配 .*? 会跑到下一条 li 的 </span>,把整条评论吞掉。

而 JS 端用 DOMParser 解析,正确拿到 8 条。两边结果不一致 → hybrid 模式下快照和客户端渲染内容不同,会产生跳变(CLS)。

修法:先按 <li> 切块,块内用 str.find() 定位边界:

python
for block in flat.split('<li class="dsq-widget-item">')[1:]:
    body_at = block.find('<span class="dsq-widget-comment">')
    meta_at = block.find('<p class="dsq-widget-meta">')
    raw_body = block[body_at + len(BODY_OPEN):meta_at].rstrip()
    # ...

修复后 8/8 全出,且与 JS 端逐字一致(Playwright 验证 CLS=0,无跳变)。

教训:解析含嵌套标签的 HTML,不要用惰性正则跨整文档匹配,先切块再定位。


工程教训汇总

关于 AI 辅助开发

  1. UI 交付必须截图确认,不能只验证 HTTP 200。用 Playwright 截 7 个断点(1440/1280/768/390px × 首页/文章页)。
  2. AI 会犯错,要逼它自证。缓存头的排查过程中,AI 下了"_headers 没被包含进部署"的错误结论,用户截图反驳后才重新排查找到真因。对关键结论要求 AI 拿证据。
  3. 决策要逐条回查落地。status 自动发布这个需求被遗忘了五个版本,根源是 AI 只记住了当下在改的那一项。

关于 Cloudflare

  1. Pages _headers 是合并规则,不是"最具体优先"。给 /* 设 Cache-Control 会污染所有资源。
  2. Browser Cache TTL 会覆写源站 max-age。免费版默认 4 小时,必须改为 "Respect Existing Headers"。
  3. 免费版路由到洛杉矶。国内访问 TTFB 约 2-4 秒,这是架构问题,代码层面优化不了。

关于性能

  1. lazy loading 是治首屏的主力,而且是零成本的:<img> 加 loading="lazy" + width/height,首屏从加载 51 张图变成 7 张。
  2. 加 width/height 的同时必须有 height: auto,否则响应式布局下图片变形。
  3. 动图转 WebP 可能变大。GIF 的 256 色调色板对录屏极高效,WebP 在高质量下反而更大。需要体积保护逻辑。
  4. 测量必须绕过代理。Agent 环境的 HTTP_PROXY 会让 curl 测出假数据。

关于 Web 标准

  1. document.write 劫持是合法手段,但要无条件恢复,且只吞自己的输出。
  2. SEO 陷阱:canonical 必须逐页正确设置。模板里 {{ canonical | default('/') }} 但不传值 = 全站 canonical 指向首页 = 文章不被 Google 收录。
  3. HTML 实体转义要一次到位。摘要从渲染后 HTML 提取时已有 &amp;,如果再经过 autoescape 就变成 &amp;amp;。
  4. 简单的 HTTP dev server 不发 Cache-Control,浏览器会对这类响应做启发式缓存。开发时必须主动 no-store。

技术知识图谱

Cloudflare Pages

  • public/_headers 是控制 HTTP 响应头的标准手段
  • 所有匹配规则合并,不是最具体优先
  • cf-cache-status: REVALIDATED 表示回源验证,HIT 才是真正命中边缘缓存
  • DYNAMIC 响应不受 Browser Cache TTL 影响,REVALIDATED/HIT 会被覆写
  • Browser Cache TTL 覆写的特征:max-age 被改成固定值(14400 = 4h),且 immutable 和 must-revalidate 同时出现(语义矛盾,是 Cloudflare 的 edge 处理产物)

浏览器缓存

  • Cache-Control: immutable 告诉浏览器资源不会变,可以永久缓存而不做条件请求
  • Cache-Control: must-revalidate 告诉浏览器即使在有效期内也要验证
  • 两者同时存在是矛盾的,实际行为由浏览器决定,通常 must-revalidate 优先

Web 性能指标

  • TTFB(Time to First Byte):从请求发出到第一个字节回来的时间,衡量服务器/网络延迟
  • FCP(First Contentful Paint):首次有意义内容出现的时间,用户感知速度的主要指标
  • DCL(DOMContentLoaded):HTML 解析完成、脚本执行完成的时间
  • CLS(Cumulative Layout Shift):累积布局偏移,衡量视觉稳定性,< 0.1 为良好

Disqus

  • 评论线程以 URL 为 key,scheme(http/https)、域名、路径全部参与
  • recent_comments_widget.js 端点无 CORS,但不需要 API key
  • api/3.0/posts/list.json 需要 API key,支持 JSONP
  • widget 的 thread href 包含完整文章 URL + 评论锚点(如 #comment-6908113609)

CORS 与跨域

  • CORS 是浏览器的限制,服务端 curl 不受影响
  • fetch() 受 CORS 限制
  • <script> 标签注入不受 CORS 限制,但只能执行,不能直接读内容
  • 劫持 document.write 是绕过这个限制的方法之一

requestIdleCallback

javascript
// 在浏览器空闲时执行,timeout 是最长等待时间
requestIdleCallback(start, { timeout: 2000 });
// 兜底:不支持的浏览器用 setTimeout

CSS Grid 布局

  • align-self: start 将 sticky 元素锚定在所在行顶部,避免拉伸到整列高度
  • grid-column: 1 / -1 跨所有列(可能与 sticky 冲突)
  • sticky 的作用范围是其最近的滚动祖先容器

Jinja2 autoescape

  • 开启 autoescape 后,所有变量值都会被转义
  • 从已渲染 HTML 中提取的内容(如摘要)需要先 html.unescape(),再传给模板
  • {{ content | safe }} 可以绕过 autoescape,但要确认内容来源可信

Python 正则与 HTML

  • 对包含嵌套标签的 HTML,惰性匹配 .*? 会在找不到结束标记时跑到下一个出现的位置
  • 正确做法:先分割成独立块(.split('<li>')),再在块内查找
  • re.S 标志让 . 匹配换行符,跨行匹配时更容易出问题

最终成果

指标 重构前(Hexo) 重构后(本地实测)
首屏图片加载 51 张全并发 7 张(lazy)
第三方请求 Disqus + viglink + liadm(20s+未返回) 仅百度统计
Google Fonts render-blocking,TLS 握手 10.86s 已移除
Disqus 加载时机 页面打开立即 滚动进视口时
/static/* 缓存 max-age=14400(被区域设置覆写) 待 Dashboard 改为 Respect
canonical 全站指向首页 逐页正确
构建时间 hexo generate(Node.js 依赖) < 3 秒
侧边栏评论 无 hybrid 模式,CLS=0.0000

还没解决的:跨洋带宽问题(约 15KB/s,LAX 节点),这需要换部署位置或接国内 CDN,不是代码能解决的。


附:静态站点的 UI 是怎么组合工作的

重构完之后我自己也有个疑问:主站和每一篇文章右侧的分类、标签、归档都长得一模一样,它们是同一个页面被引用了,还是被复制了 N 份? 三栏布局又是怎么"接"在一起的?

这部分对不写前端的人来说最容易误解,单独拆开讲。

模板不是运行时引用,是构建期的宏展开

文章页的模板嵌套组装

整站的模板只有 11 个文件,关系是两层:

  • 继承:base.html 提供 <head> / <header> / <main> / <footer> 骨架,中间用 {% block main %} 挖一个洞。其余 9 个页面模板(首页、文章页、分类页、归档页、404……)全部 {% extends "base.html" %},各自往洞里填自己的内容。
  • 包含:sidebar.html 是一个独立片段,被 6 个模板 {% include %} 进去。

关键在于 Jinja2 的 extends / include 等价于 C 的 #include,是构建期的文本展开,不是运行时的动态引用:

源文件 产物 public/
sidebar.html 只有 1 份 被逐字复制进 204 个 HTML 文件
浏览器请求 — 只下载 1 个 HTML,不发起任何"子页面"请求

这跟 PHP / 模板引擎那种服务端渲染正好相反:运行时没有任何组装逻辑,全部拼接在 mdsite build 那不到 3 秒里就结束了。服务器只需要会发文件,所以 CDN 能扛全部流量,也不存在后端挂掉这回事。

验证也很直接 —— 抽 4 个不同文章页,把 <aside class="sidebar"> 整段取出来算 MD5:

text
7d41b79a3923   19552 bytes   /2016/01/11/boost-ipc-cpp-alloc-construct/
7d41b79a3923   19552 bytes   /2016/01/14/boost_ipc_pack/
7d41b79a3923   19552 bytes   /2016/01/14/hexo-construct-homepage/
7d41b79a3923   19552 bytes   /2016/03/21/nginx-conf/

哈希逐字相同,确实是复制。

这样冗余吗?算一笔账

text
HTML 文件数        204
HTML 总体积        10.53 MB
其中重复的侧栏     3.80 MB(占 36%)

单页原始           128.5 KB
单页 gzip 后        31.0 KB(压掉 76%)

36% 是纯冗余,听起来很浪费。但重复文本恰恰是压缩算法最擅长的东西 —— gzip 之后这部分几乎免费。换来的是整站零动态逻辑、可以纯 CDN 分发。

真正的代价在另一头:改一行侧栏,得重新生成全部 204 个文件。这也解释了为什么我需要那个 build --clean 开关 —— 文章下线、改 slug、改主题名之后,旧文件不会自己消失。

三栏不是"平接",是 CSS Grid 排的

CSS Grid 把三个 div 排成三列

HTML 里这三块是顺序排列的三个块级元素,按默认流会上下堆叠。横向三列完全是 CSS 给的:

css
.article-layout {
  display: grid;
  grid-template-columns: var(--toc-w) minmax(0, 1fr) var(--side-w);
  /*                     220px        剩余可伸缩      300px      */
  gap: var(--col-gap);
  align-items: start;
}

minmax(0, 1fr) 是中间的正文列:吃掉两侧固定宽度之外的全部剩余空间。minmax(0, ...) 而不是裸 1fr,是为了防止超宽的表格或代码块把这一列顶破 —— 这是 Grid 的一个经典陷阱,1fr 的最小尺寸默认是 auto,会被内容撑开。

窄屏时我只换掉这一行:

css
@media (max-width: 1100px) {
  .article-layout { grid-template-columns: minmax(0, 1fr); }
}

三栏立刻回落成上下堆叠,HTML 一个字都不用改。这就是「HTML 管结构、CSS 管表现」在实际项目里的样子。

完整的组合链条

text
md 源文件
   │
   ├─ loader.py   解析 front-matter,筛出 status: true 的文章
   ├─ render.py   markdown-it-py 转 HTML 片段,抽 TOC,改写图片路径
   ├─ images.py   读图片尺寸,写 width/height + loading="lazy"
   │
   ├─ builder.py  Jinja2 套模板
   │                base.html 骨架
   │                  └─ post.html 内容
   │                       └─ include sidebar.html
   │
   └─ 写出一个完整独立的 index.html
          │
          ▼
   浏览器下载:1 个 HTML + 1 个 CSS + 1 个 JS
          │
          ├─ CSS Grid 把三个 div 排成三列
          └─ JS 做可选增强(目录高亮 / 代码复制 / 灯箱 / 最近评论)

零前端框架

整套 UI 没有 React、没有 Vue、没有任何构建工具链,只有:

层 用了什么 规模
结构 Jinja2 模板 11 个文件
样式 手写 CSS 940 行,15 个区块
行为 原生 JS(一个 IIFE) 约 400 行
代码高亮 Pygments,构建期完成 前端零成本

不用框架不是复古情怀,是场景不匹配:静态博客没有运行时状态管理的需求,引入框架只会带来 bundle 体积和 hydration 开销,而这两样正是我这次要砍掉的东西。

JS 全部是可选增强 —— 禁用 JavaScript 之后文章照样能读、导航照样能点,只是没有目录滚动高亮和代码复制按钮。这是渐进增强(progressive enhancement)的基本要求,也是我在性能优化阶段能把 Disqus 和最近评论都改成"滚动进视口才加载"的前提:它们从一开始就不在关键渲染路径上。


附二:CSS Grid 调试器(可以直接拖)

上一节说三栏是 grid-template-columns: 220px minmax(0, 1fr) 300px 排出来的,但光看代码没什么体感。既然这套生成器的 Markdown 渲染器开了 html: true,那就干脆把一个实时调试器直接写进 md 源文件里:

1
2
3
4
5
6

拖一下控件,下面的代码块会同步输出当前的 CSS。几个值得试的点:

  • 220px minmax(0, 1fr) 300px:就是本站文章页的真实配置。minmax(0, ...) 而不是裸 1fr 的原因在上一节说过 —— 1fr 的最小尺寸默认是 auto,超宽代码块会把正文列顶破。
  • repeat(auto-fit, minmax(90px, 1fr)):把窗口拖窄,列数自己减少,一行媒体查询都不用写。注意 auto-fit 会折叠空轨道,auto-fill 会保留空轨道 —— 要撑满就用前者。
  • 勾上「1 号跨 2 列」:grid-column: span 2 让单个格子横跨两条轨道,后面的项自动往后挤。这是 Grid 相对 Flex 最实用的能力之一。

这段是怎么活下来的

值得记一笔的是:生成器一行代码都没改。能跑通靠三件事恰好对齐:

环节 为什么没拦住它
markdown-it-py MarkdownIt("commonmark") 的 html 选项默认为 True,裸 HTML 块按 CommonMark 的 html_block 规则原样透传
Jinja2 autoescape 正文走 {{ content | safe }},不会被二次转义;且模板只渲染一次,变量值里的内容不会被当成模板再解析
public/_headers 全站没有设 Content-Security-Policy,所以内联 <style> / <script> 不被拦

两个写的时候必须注意的点:

  1. HTML 块内不能有空行。CommonMark 的 html_block 规则 6(<div> 这类容器标签)遇到空行就结束,后面的内容会被重新当成 Markdown 解析,整段就散架了。所以上面那坨 HTML 是一整块连续行。
  2. 配色必须用主题变量,不能硬编码。--accent-soft / --bg-warm / --border-strong 在 [data-theme="dark"] 下有覆写值,直接写 #FEF3C7 这种字面色,切暗色主题后就瞎了。

还有一个前面踩过的坑在这里也适用:render.py 的 _rewrite_images() 会用正则扫 <img> 标签去加 width/height,所以内联 HTML 里如果带 <img>,路径处理逻辑会把它当成文章配图。这个 demo 里全是 CSS 画的方块,刚好绕过了。

评论