文章
用纯前端做一个「动态+文章」的多语系 Blog
我把自己的网站做成「动态+文章」的多语系 Blog:内容用 Markdown 管理,发文时完成翻译,建置时产生静态页面,浏览器负责阅读与切换语言。这篇拆解内容模型、共用管线、按需载入与 SEO 的取舍,以及纯前端到底省掉了什么。
· 6 分钟阅读
我希望自己的网站可以随手记一段想法,也能把一个技术问题完整写清楚。短的放「动态」,长的放「文章」,两者可以互相连结,而且访客切换语言时,内容也要一起切换。
这个需求看起来很普通,但如果一开始就加资料库、后台、内容 API,再处理每个语系的版本,很快就会变成另一个需要长期维运的产品。
我选择先把需求收敛到自己真的会用的范围:**一个作者、内容进 Git、发文后重建网站,阅读端只需要静态档案与浏览器。 ** 这就是现在 stephen.taipei 的「动态+文章」架构。
纯前端,指的是阅读端#
先把边界讲清楚。这个专案使用 React、Vite 与 Tailwind,内容处理、图片转换和建置脚本仍然需要 Node.js;翻译也会在发文阶段呼叫外部服务。部署后由 Nginx 提供静态档案。
所以我说的纯前端,是读者打开网站时,不需要一个专门的 Blog 后端查资料、组页面或即时翻译。它有建置流程,也有主机,但没有为了阅读文章而常驻的应用程式 API 或资料库。
把工作提前做完,阅读端就可以简单很多。
动态和文章,共用来源,各自有自己的模型#
内容集中在 content/,原文都是 Markdown。两种内容没有硬塞成同一个格式:
content/
tags.yaml
tags.locales.yaml
notes/2026/20261004-0944.md
notes/2026/20261004-0944.locales.yaml
articles/frontend-only-multilingual-blog/
index.md
locales.yaml
cover.webp
动态是短卡片,档名用台北时间的分钟当作 id,可以标记想法、学到、上线、踩坑或阅读。只写一段文字就能成立,不必每次都想标题。超过 500 字会提醒,超过 1000 字就该写成文章。
文章则有固定的 slug、标题、摘要、日期,也可以有封面与更新时间。网址公开后不再改,避免原本分享出去的连结失效。
两者之间用 article: 关联。动态可以先记下问题,再连到完整文章;文章页会反向列出引用它的动态。短内容保留当下的起点,长内容负责把推导写完,不需要在两边贴一样的全文。
让内容规则在建置前挡下错误#
如果 Markdown 只是被丢进画面,很多问题要到上线后才会被看到:日期打错、文章连不到、图片没有 alt,或同一主题被写成三种近似标签。
我把这些规则放进共用内容管线。 loadContent 读取档案,以 Zod 检查栏位,再处理 Markdown、图片、语系与内容关联。 CLI 的检查和 Vite 的建置都使用这套管线,减少「本机说可以、正式环境却不行」的落差。
标签只接受 tags.yaml 已登记的 key;动态引用的文章和其他动态必须存在;本机预览可以读草稿,正式建置会排除草稿。这些规则不是靠发文时记得,而是让检查真的挡住。
Markdown 也有明确的输出边界:raw HTML 不会直接输出,生成的 HTML 会经过清理,代码块在构建时完成语法高亮。本地相对路径引用的内容图片会转换为最大宽度 1600px 的 WebP,并移除包括 GPS 信息在内的 EXIF 元数据;文章、卡片和分享预览共用处理后的图片。远程图片和使用根目录绝对路径引用的图片则不会经过这套本地转换流程。
多语系的成本,放在发文时付#
现在网站支援 22 个语系。繁中原文写一次,其他 21 个语系的标题、摘要、正文与图片描述,存进旁边的 locales.yaml。
这里有一个我很在意的决定:**读者不必等翻译。 ** content:localize 在发文时产生译文,正式 build 只读已经存在的档案,不会每次部署都重新呼叫翻译服务。
原文与译文之间用 source digest 对照。原文改了,旧译文就不能继续被当作最新版本;少一个语系或译文过期,content:check 就会失败。翻译过程会保护程式码、inline code 与连结目标,避免把技术识别字或网址一起翻掉。
这不代表自动译文就有人工编辑的品质。它解决的是完整性与更新一致性,专业用语和语气仍然需要校对。未变更的原文会保留既有译文,人工修过的文字也不会因为重跑而被覆盖。
列表读摘要,点进去才载入文章#
内容增加之后,如果首页就把每篇文章、每个语系的全文全部载进来,纯前端也会很慢。
Vite plugin 将内容拆分为三个入口:首页使用的 virtual:content/latest、内容页使用的 virtual:content/index,以及每篇文章各自的 virtual:content/article/<slug>。首页会取最新三条动态和三张文章卡片。动态数据仍包含全文和译文,只是在卡片上截断显示。长文章卡片只带摘要等数据,完整正文则通过 dynamic import 按篇加载。
React 的 Suspense 负责等待正文,外层的错误边界负责显示载入失败讯息。正文请求失败时,标题、摘要和页面导览仍然留着。
这里也有取舍:目前是一篇文章一个正文 chunk,里面包含各语系译文,还没有拆到一个语系一个 chunk。动态全文与译文则进入内容索引。内容量持续增加时,索引大小和多语系正文的下载量,就是下一步要量测的地方。
有前端切换,也要有静态阅读的入口#
只做 SPA,分享出去时常常只看到首页的标题与图片。对文章来说,这不够。
所以建置时,每个动态、文章与标签网址都有自己的 HTML shell,里面写入 title、description、canonical、Open Graph 和 JSON-LD,并在 noscript 放入可阅读的原文。即使不执行 JavaScript,也能取得这篇文章的内容与专属封面。
同一份内容还会产生 feed.xml、sitemap-content.xml、文章的 Markdown 版本,以及 llms.txt 的文章索引。这些都是建置产物,不是另一套需要人工同步的内容。
但多语系 SEO 的边界也要讲清楚:**目前动态与文章在各语系共用同一个网址,语言由前端切换;静态内容页、feed 与 Markdown 版本维持原文。 ** 首页另有语系与亮暗模式的预渲染,不能因此说每篇文章也都有 22 份可独立索引的语言页面。
如果未来需要让每个语系各自被搜寻引擎收录,就要增加语系路由、对应的静态页面与 hreflang。那是另一个需求,现在这套还没有做到。
发文也是一条交付流程#
我的发文流程是:写原文、产生译文、检查内容、提交 Git,再合并到 main。既有的 GitHub Actions 会重建网站并部署。
npm run content:localize
npm run content:check
npm run test:content
npm run build
多语言还没齐全,就先补齐。文章还是草稿时,就留在本地预览。正式输出还会检查草稿页面不存在,并扫描 feed、sitemap、首页和 JavaScript bundle 等主要公开产物中的草稿标记。内容和代码一样有 diff 和版本记录,也可以一起回滚。
这种方式牺牲了即时更新:改一段文字,也要等一次建置部署。但对自己的技术网站而言,我愿意用这个等待,换取少一套后台、少一个执行期依赖,以及更容易追查的变更纪录。
什么时候适合这样做#
如果是一个作者、发文频率可接受重建、内容以文字为主,而且已经习惯 Git,这套架构很适合。短动态降低写作门槛,文章保留完整推导,多语系让同一份内容可以被更多人阅读。
如果需求变成多人即时协作、权限管理、读者留言、登入或订阅,就要重新评估 CMS 与后端。纯前端不会让这些需求自动消失,只是目前我没有把它们放进这个 Blog 的范围。
这次的设计原则,和我在多布局与多主题的架构设计模式里写的很接近:先把责任分清楚,再让共用规则守住一致性。
对这个网站来说,重点是让我能持续写,也能知道每一次发布,实际送出了哪些内容。