按下发布以后:一篇 Markdown 到达浏览器的完整旅程

Chen Xi
Chen Xi

在 Pages CMS 里写完文章,点一下保存,过一会儿刷新网站,新文章就出现了。

这个过程看起来很像把一张照片发到社交平台:选择、确认、等待几秒,然后结束。但个人博客背后没有一个长期运行的后台替我处理请求。服务器上也没有 WordPress 和数据库。真正被发布出去的,只是一批 HTML、CSS、JavaScript、字体和图片文件。

那么,从一个 Markdown 文件到读者屏幕上的文字,中间究竟发生了什么?

这一次我不再写一份搭建步骤,而是沿着金猪博客当前正在使用的发布链路,从头走一遍。途中会经过 Hexo、Keep 主题、Git、GitHub Actions、Cloudflare Pages、DNS、CDN,最后才轮到浏览器。

image

起点并不是网页

博客文章最初只是 source/_posts 目录中的一个文本文件。文件上方是 YAML Front Matter,下面才是正文:

1
2
3
4
5
6
7
8
9
---
title: 按下发布以后:一篇 Markdown 到达浏览器的完整旅程
date: 2026-07-31 15:50:00
published: true
description: 文章摘要
categories: 技术
tags:
- Hexo
---

Front Matter 看起来只是几行配置,实际上决定了这篇文章会不会出现、排在什么位置、使用什么地址,以及怎样进入分类页、标签页、站点地图和搜索索引。

例如,本站配置了:

1
2
permalink: :year/:month/:day/:title/
future: false

因此,这篇文件最终对应的地址是:

1
/2026/07/31/markdown-to-browser-publishing-journey/

如果发布时间晚于当前时间,Hexo 会把它当成未来文章,不生成页面。此前我就遇到过文章已经写好、构建也没有报错,但首页上始终找不到的情况。原因不是部署失败,只是时间比构建机器快了十几分钟。

这说明发布链路的第一层并不是网络,而是内容模型。标题、日期、时区和 published 等字段只要有一个不符合预期,后面的系统即使全部正常,也只会忠实地发布一个“不包含这篇文章”的网站。

Hexo 先把内容变成数据

执行 hexo generate 后,Hexo 并不会立刻逐个复制文件。它会先读取站点配置、加载插件和主题,再解析文章及页面,把它们放进一套内部数据模型。

一篇文章在这时不再只是一段 Markdown,而是一个包含标题、正文、日期、永久链接、分类、标签等信息的对象。归档生成器可以按年份读取它,标签生成器可以把它放进相应的标签页,首页生成器则会按照日期选出最近的文章。

这也是静态站点生成器与普通“Markdown 转 HTML”工具的重要区别:后者只处理一份文本,前者需要理解整个网站。

Renderer 负责翻译,Generator 负责修路

Hexo 的构建过程里有两个容易混淆的角色。

**Renderer(渲染器)**负责把一种文件内容转换成另一种内容,例如:

1
2
3
Markdown → HTML
Stylus → CSS
EJS → HTML

本站并没有完全使用默认 Markdown 渲染流程,而是在 scripts/katex-renderer.js 中注册了基于 Markdown-it 的渲染器,同时处理 KaTeX 数学公式。此前公式显示异常,问题就发生在这一层:Markdown 本身没有丢失,但渲染器没有把公式分隔符正确变成 KaTeX HTML。

**Generator(生成器)**处理的是“网站应该有哪些地址”。它接收所有文章、页面、分类和标签,再创建路由:

1
2
3
4
5
6
index.html
archives/index.html
tags/Hexo/index.html
2026/07/31/markdown-to-browser-publishing-journey/index.html
sitemap.xml
atom.xml

Hexo官方也把二者明确分开:Renderer 处理内容转换,Generator 根据站点数据建立路由。理解这一点以后,排错会清楚很多:

  • 正文中的 Markdown 没有正确显示,先查 Renderer;
  • 文章存在但没有分类页,先查 Generator 或 Front Matter;
  • 页面结构正确但样式不对,继续向主题和 CSS 查。

Keep 主题把内容放进页面

渲染后的正文仍然不是完整网页。它没有导航栏、页脚、文章目录、版权信息,也没有 <head> 中的 SEO 元数据。

Keep 主题会把正文放入文章模板,再把文章模板放进整页布局。主题中的 EJS 模板决定 HTML 结构,Stylus 文件经过渲染后形成最终的 css/style.css

可以把这个过程理解成三层:

1
2
3
文章内容
└─ 文章模板:标题、日期、目录、正文、上下篇
└─ 站点布局:HTML、head、导航、页脚、脚本

以前出现过一次很离谱的故障:本应参与编译的 Stylus 源码直接出现在页面上,盖住了正文。浏览器没有能力理解 Stylus 变量,自然只能把那段内容当作普通文字。那次事故看起来像“整个主题都坏了”,实际故障范围仍然在渲染与模板交界处。

为了防止同类问题再次混进部署产物,本站现在会专门检查文章工具栏中是否出现 $post-tool-button-width+keep-tablet() 等原始 Stylus 特征。

public 才是真正准备上线的网站

所有内容与模板处理完毕后,Hexo 把结果写入 public 目录。

在写这篇文章前,我对当前站点做了一次快照:

项目 数量或体积
文章 20 篇
HTML 页面 90 个
全部静态文件 235 个
总体积 约 5.74 MiB
CSS 约 324 KiB
JavaScript 约 119 KiB

文章只有 20 篇,为什么会生成 90 个 HTML?

因为首页、分页、归档、年份归档、分类、每个标签、About、隐私页面和 404 页面都需要自己的 HTML。静态网站所谓的“提前生成”,就是在部署前把这些可能被访问的页面全部算好。

此时即使断开 Node.js,甚至把 Hexo 从电脑中删除,public 中的网站仍然可以工作。Hexo只参与生产,不参与访问。读者打开文章时,Cloudflare不会临时运行 Hexo,也不会现场解析 Markdown。

构建通过不等于可以发布

本站的 npm run build 最终调用 hexo generate,但外面又包了一层检查:如果 Hexo 虽然以成功状态退出,日志里却出现内部渲染错误,构建脚本仍会主动失败。

生成结束后,npm run check 会遍历整个 public,检查:

  • 每页是否有唯一的标题、摘要与 canonical URL;
  • 文章摘要是否重复或长度异常;
  • JSON-LD 是否为合法数据;
  • 图片是否带有替代文本;
  • 本地图片、脚本和链接是否真实存在;
  • 标签页与分类页是否为空;
  • 数学公式是否仍含解析错误;
  • CSS 是否小得异常;
  • 主题工具栏是否混入原始 Stylus;
  • robots.txt、站点地图、RSS 和安全文件是否生成。

这些检查不保证文章内容一定优秀,但能阻止一批“构建成功、网站却坏了”的情况。

这里有一个很重要的区别:

1
2
Hexo 成功:文件生成出来了
站点检查成功:生成结果符合本站约定

只有两者都通过,发布流程才会继续。

sourcemain 是两种完全不同的东西

这个仓库保留了两个主要分支:

  • source 保存 Markdown、配置、主题覆盖、构建脚本和工作流;
  • main 保存已经生成的静态网站。

Pages CMS 修改文章后,提交进入 source。GitHub接收到新的提交,触发 deploy-hexo.yml

1
2
3
4
on:
push:
branches:
- source

工作流在一台临时的 Ubuntu 虚拟机中依次执行:

1
2
3
4
5
6
7
检出 source
→ 安装 Node.js 22
→ npm ci
→ 清理旧产物
→ 构建
→ 全站检查
→ 把 public 同步到 main

使用 npm ci 而不是随手执行 npm install,是为了严格按照锁文件安装依赖。构建机器用完即销毁,因此不能依赖我电脑里“恰好存在”的某个文件。

最后一步使用 rsync --delete 同步文件。这意味着新构建里已经删除的页面,也会从 main 中删除,避免旧文章或旧资源永久残留。同步结果形成一个新的 Git 提交,再一次性推送。

如果构建或检查失败,发布步骤不会运行,main 仍停留在上一个可用提交。这比直接在生产分支上边改边生成更容易恢复。

工作流还设置了:

1
2
3
concurrency:
group: hexo-production
cancel-in-progress: true

如果短时间内连续保存两次文章,较新的任务会取代仍在运行的旧任务。否则旧构建可能比新构建更晚完成,把刚发布的内容覆盖回去。

Cloudflare Pages 接过最后一棒

main 更新以后,Cloudflare Pages取得新的静态文件并创建部署。自定义域名 dajinzhu.cn 指向这个 Pages 项目,Cloudflare同时处理 TLS 和边缘分发。

读者访问:

1
https://dajinzhu.cn/2026/07/31/markdown-to-browser-publishing-journey/

Pages会按照路径寻找对应文件,最终返回:

1
2026/07/31/markdown-to-browser-publishing-journey/index.html

这就是地址末尾没有 index.html,网站却仍能返回页面的原因。URL是给人和搜索引擎看的路径,存储层仍然可以是一个普通文件。

Cloudflare Pages的部署不是让所有读者直接访问 GitHub。GitHub在这里负责保存与触发,Cloudflare才是实际向访客交付文件的一方。

缓存并不是一个开关

“清一下缓存”经常被当成万能答案,但一次页面访问里至少可能遇到三处缓存:

  1. 浏览器缓存;
  2. Cloudflare边缘节点缓存;
  3. Pages部署对应的静态资源版本。

我在 2026 年 7 月 31 日读取了本站公开响应头。当时首页返回:

1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=0, must-revalidate
Server: cloudflare
cf-cache-status: DYNAMIC

css/style.css 返回:

1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/css; charset=utf-8
Cache-Control: public, max-age=86400, stale-while-revalidate=604800
ETag: "9f009995061ba5495e5399fb69964132"
Server: cloudflare

两者策略不同是有意为之。

HTML包含文章列表和资源地址,更新后应尽快重新验证;CSS、JavaScript、字体和图片变化较少,可以在浏览器中保存更久。ETag相当于某一版本资源的标识。浏览器下次可以携带 If-None-Match 询问服务器:我手里的版本还是不是最新的?如果一致,服务器返回 304 Not Modified,不必重新传输文件正文。

但本站目前的 style.css 没有把内容哈希写进文件名。如果浏览器仍认为一天内的旧 CSS 可用,新 HTML又依赖刚修改的样式,就可能短暂出现“结构是新的,样式还是旧的”。缓存并没有弄错,它只是严格执行了我们给出的规则。

长期看,更稳妥的资源策略是:

1
2
3
HTML:每次重新验证
带内容哈希的静态资源:长期缓存并标记 immutable
资源内容变化:文件名也变化

这比每次出问题都让所有人强制刷新可靠得多。

浏览器拿到 HTML 后,工作才完成一半

HTTP返回 200 OK 并不代表页面已经显示。

浏览器会解析 HTML,建立 DOM;解析样式表,建立 CSSOM;再把两者组合成渲染树,计算每个元素的位置与尺寸,最后绘制成屏幕上的像素。外部 CSS通常会阻塞首次渲染,因为浏览器必须先知道样式,才能确定页面应该长什么样。

与此同时,HTML中的引用会产生新的请求:

1
2
3
4
5
6
7
HTML
├─ /css/style.css
├─ /css/seo.css
├─ /font/...
├─ /images/pig-logo.png
├─ /js/main.js
└─ 文章插图

因此,“文章页面能打开”和“文章完整可读”也是两件事。HTML返回正常但 CSS 失败,得到的是无样式页面;字体失败,排版会发生变化;图片地址错误,正文会留下空白;脚本错误,搜索、目录或懒加载可能失效。

此前猪头被拉得很长,就是浏览器布局阶段暴露出来的问题。图片文件本身没有坏,网络请求也成功了,但 CSS 给它的尺寸约束不正确。故障发生在旅程的最后几步,却会让人误以为整个部署都坏了。

同一个故障,在不同层看起来完全不同

image

排查静态网站时,我现在更愿意先判断故障属于哪一层:

层级 常见现象 优先检查
内容 文章缺失、日期错误、分类为空 Front Matter、时区、published
渲染 公式错误、源码出现在正文 Markdown渲染器、主题模板、过滤器
生成 标签页、归档或资源未产生 Generator、Hexo配置、public
CI CMS已保存但网站没有变化 source提交、Actions日志
发布 Actions成功但生产内容仍旧 main提交、Pages部署
缓存 部分人正常、部分人仍见旧样式 响应头、ETag、浏览器缓存
浏览器 图片变形、布局跳动、功能失效 CSS约束、资源请求、控制台

这种分层比反复执行“清理、生成、部署”更有效。三件套只能重新跑完整流程,不能告诉我问题发生在哪里。

我怎样确认一篇文章真的发布了

现在,一次完整发布至少要经过四个证据点:

  1. source 中存在包含文章的提交;
  2. GitHub Actions 的构建与检查全部通过;
  3. main 中存在生成后的 index.html
  4. 公开地址返回正确标题、摘要和资源引用。

少任何一个,都不能简单地说“已经上线”。

例如,Actions显示绿色,只能证明工作流按定义成功。如果工作流同步了错误目录,它仍然可以绿色;main 已有文件,也不等于自定义域名已经切换到这一版;HTML能返回,也不等于图片和 CSS 完整。

真正可靠的发布状态不是一个图标,而是一条可以逐段验证的证据链。

静态网站并不简单,只是把复杂度搬到了发布之前

WordPress一类动态网站会在访问时查询数据库、执行程序并组装页面。Hexo则把大部分工作提前到构建阶段。读者访问时只需要取得静态文件,因此运行时更轻,也更容易缓存。

但复杂度没有消失。它从“每次请求如何生成页面”,转移成了:

  • 内容怎样建模;
  • 模板怎样渲染;
  • 构建怎样保持可重复;
  • 产物怎样验证;
  • 部署怎样避免相互覆盖;
  • 缓存怎样兼顾速度与更新;
  • 浏览器怎样稳定地完成布局。

按下发布以后,一篇 Markdown 并没有直接飞到读者手机里。它先被解析成数据,渲染成正文,装进主题,扩展成整套网站,经过自动检查,进入生产分支,再由 Cloudflare分发到网络边缘。最后,浏览器重新把那些静态文件组合成我们看到的页面。

整个过程绕了一大圈,终点却仍然只是几行文字。

这也是我喜欢静态博客的原因之一:每一层都可以拆开看,每一步都有文件、有日志,也有办法验证。网站偶尔还是会坏,但至少下一次再面对一个被拉长的猪头时,我知道应该从旅程的哪一站开始找。

参考资料