我之前的个人博客是通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工具,单包构建,零前端依赖。最终目录结构如下:
textmd_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 这一条命令内部是一条五段流水线。整体数据流:
几个设计上值得说明的点:
配图走的是「旁路」。图片不经过 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() 返回的列表已经按日期倒序,所以相邻关系是纯索引操作:
pythonfor 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 高亮上。
单篇文章的七次变换
把镜头推近到一篇具体的文章,看每一步到底改了什么:
对应的代码就是 render.py 里这几行,顺序不能换:
pythondef 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 渲染器的配置也很克制:
pythonmd = (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() 注入:
pythonself.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 这种假数据。
正确做法:
bashcurl --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.comLiveRamp 身份追踪(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 必须同时有:
cssimg { 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 的响应头把问题暴露得很直白:
textcache-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 实际拿到的是:
textpublic, 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 手动操作):
textCloudflare 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
关键实现:
javascriptconst 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 和请求数零影响,通过三条机制保证:
requestIdleCallback延后发起- 骨架占位与真实结构同形,替换时 CLS=0
- 客户端硬超时 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() 定位边界:
pythonfor 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 辅助开发
- UI 交付必须截图确认,不能只验证 HTTP 200。用 Playwright 截 7 个断点(1440/1280/768/390px × 首页/文章页)。
- AI 会犯错,要逼它自证。缓存头的排查过程中,AI 下了"_headers 没被包含进部署"的错误结论,用户截图反驳后才重新排查找到真因。对关键结论要求 AI 拿证据。
- 决策要逐条回查落地。
status自动发布这个需求被遗忘了五个版本,根源是 AI 只记住了当下在改的那一项。
关于 Cloudflare
- Pages
_headers是合并规则,不是"最具体优先"。给/*设Cache-Control会污染所有资源。 - Browser Cache TTL 会覆写源站 max-age。免费版默认 4 小时,必须改为 "Respect Existing Headers"。
- 免费版路由到洛杉矶。国内访问 TTFB 约 2-4 秒,这是架构问题,代码层面优化不了。
关于性能
- lazy loading 是治首屏的主力,而且是零成本的:
<img>加loading="lazy"+width/height,首屏从加载 51 张图变成 7 张。 - 加 width/height 的同时必须有
height: auto,否则响应式布局下图片变形。 - 动图转 WebP 可能变大。GIF 的 256 色调色板对录屏极高效,WebP 在高质量下反而更大。需要体积保护逻辑。
- 测量必须绕过代理。Agent 环境的
HTTP_PROXY会让 curl 测出假数据。
关于 Web 标准
document.write劫持是合法手段,但要无条件恢复,且只吞自己的输出。- SEO 陷阱:canonical 必须逐页正确设置。模板里
{{ canonical | default('/') }}但不传值 = 全站 canonical 指向首页 = 文章不被 Google 收录。 - HTML 实体转义要一次到位。摘要从渲染后 HTML 提取时已有
&,如果再经过 autoescape 就变成&amp;。 - 简单的 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 keyapi/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:
text7d41b79a3923 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/
哈希逐字相同,确实是复制。
这样冗余吗?算一笔账
textHTML 文件数 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 排的
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 管表现」在实际项目里的样子。
完整的组合链条
textmd 源文件
│
├─ 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 源文件里:
拖一下控件,下面的代码块会同步输出当前的 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> 不被拦 |
两个写的时候必须注意的点:
- HTML 块内不能有空行。CommonMark 的 html_block 规则 6(
<div>这类容器标签)遇到空行就结束,后面的内容会被重新当成 Markdown 解析,整段就散架了。所以上面那坨 HTML 是一整块连续行。 - 配色必须用主题变量,不能硬编码。
--accent-soft/--bg-warm/--border-strong在[data-theme="dark"]下有覆写值,直接写#FEF3C7这种字面色,切暗色主题后就瞎了。
还有一个前面踩过的坑在这里也适用:render.py 的 _rewrite_images() 会用正则扫 <img> 标签去加 width/height,所以内联 HTML 里如果带 <img>,路径处理逻辑会把它当成文章配图。这个 demo 里全是 CSS 画的方块,刚好绕过了。
评论