← 返回博客

上次迁到 Vuetify,这次又回到 Astro:第 19 次重构,把整个主站收进一个前端

Vuetify 组件库很好用,但设计语言、特殊交互和 AI 时代的内容直出逼着我重新抉择架构,最后先选了 Astro,再让 AI 为交互部分选择了 React。

上次迁到 Vuetify,这次又回到 Astro:第 19 次重构,把整个主站收进一个前端

先说结论

上一篇文章才写完「原 SSR 落幕之后,我用不到半天把主站从 React CSR 重构到 Vuetify」。结果不到两周,我又把它迁回了 Astro

这不是我突然发现 Vue 不好,也不是又一次为了换技术栈而换技术栈。恰恰相反,旧版的 Vue UI 写起来非常舒服,Vuetify 的组件基本都是开箱即用的,很多页面确实可以直接拿现成组件拼出来。但主站真正需要的,后来证明不是一个更大的 SPA,而是一层稳定的静态外壳,里面再放几个需要实时交互的产品。

所以这次的目标变成了:

  • 让首页、博客、关于、归档、公告、工具目录这些内容在构建时直接生成;
  • 让 AI 生图、账号、交互小说、追番等重交互功能继续使用 React;
  • 让它们共享同一套布局、导航、主题和发布方式;
  • 旧文章、旧入口、旧链接都要尽量接住。

现在这个站已经上线了。仓库 README 里把它称为 AcoFork 的第 19 次重构。第 19 次听起来有点夸张,但如果把 WordPress、Hexo、Hugo、Astro、Svelte、React、Vue 这些阶段都算进去,确实也差不多了。

为什么又要重构

第一个原因:好用的组件库,不等于适合现在的产品

Vuetify 的确让开发变快了,但它的 Material Design 在我看来还停留在 2018 年代的谷歌设计风格。这不是说它不能用,而是我现在想要的站点视觉已经不太想继续被这套设计语言牵着走。

而且,组件库最有价值的地方,恰恰是那些可以用标准组件表达的页面。主站里最重的两个业务——AI 生图和交互小说——并没有真正复用那一套组件库。它们的界面太特殊了:生图有角色和风格库、队列、插队、实时进度和媒体操作;小说有阅读状态、条件分支、编辑器和故事流转。这些东西最后还是必须手造。

换句话说,Vuetify 让普通页面的拼装很舒服,但核心业务并没有因此变成「拿组件拼一拼就完事」。它解决的是开发手感,不是整个站点的最终形状。

如果做的是 APP,我反而会推荐 Vuetify

这也让我重新想清楚了 Vuetify 更适合出现在哪里。如果目标是用 Electron 或 Tauri 做一个开箱即用的 APP,我反而非常推荐它。应用程序和网站的资源模型,本来就不是一回事:

  1. 应用程序:每次启动时读取的资源,通常已经下载在本地。
  2. 网站:每次进入时都取决于网络速度。网络好时,它和本地应用几乎没有区别;网络不好时,体验就完全是另一回事。

本地应用与网站的资源模型对比

你当然可以说,为什么不使用 IndexedDB 存版本号,或者用 Service Worker 做缓存控制,把网站做成一个 PWA?当然可以。只是那样做,本质上是在把网站往客户端应用的方向推。我还是觉得网站就应该是网站:内容可以滚动式更新,用户打开就拿到最新页面,而不是按照提交、版本和发版去管理一套存放在浏览器里的本地资产。

秉持这个思路,我就不打算把整套网站资产存进用户浏览器。说得难听一点,那多少有点像在用户浏览器里拉屎。等我什么时候真的想做一个 APP,再认真考虑 IndexedDB、Service Worker,以及 Vuetify 这一套更适合应用程序的方案。

最大的问题:内容站不应该先交付一个 JS 壳

更大的问题是内容直出。在我使用的那套 Vue + Vite 应用架构里,默认交付的是一个 JS 壳,浏览器要先加载脚本,再由 Vue 接管页面、请求数据、渲染内容。不是说 Vue 技术上不能做 SSR 或 SSG,而是这套默认应用形态不会自动把内容 HTML 交到访问者手里。

这对于 AI 时代的 webfetchcurl 和各种轻量抓取工具都很不友好。当 AI 通过这些工具访问我的网站时,它得到的可能只是一份冷冰冰的 JS 壳,而不是文章标题、正文和页面结构。诚然,AI 也可以绕道去爬 RSS,但让一个内容站必须依赖 RSS 才能被理解,本身就很别扭。

甚至在 AI 当时写 Vuetify、想用 webfetch 查文档时,也遇到了同样的事情:它拿到的不是文档内容,而是一份 JS 壳。AI 一度非常困惑,后来干脆启动了内置浏览器,像人一样等页面执行完再进去看。这件小事反而把问题暴露得很彻底:Vuetify 的文档首先照顾的是人类通过浏览器阅读,却没有照顾轻量抓取和 AI 理解内容的方式。在我的体验里,它的开发思路更像是把 Web 当成 App 来做——界面先接管,内容随后出现;对于组件库本身当然方便,但对于文档这种应该能被直接读取的内容,就显得很不负责。

AI 使用 WebFetch 查文档时遇到 JS 壳

我试过预渲染博客页面,也试过用 Vue Vite 粘合器把静态内容和应用拼在一起,效果都不尽如人意。说白了,Vue 更适合做一个像手机 App 一样的应用,而不是把大量内容直接交给浏览器、搜索引擎和 AI 读取的内容站。

这还牵扯到 AI 写 UI 的方式。在人类编码时代,组件库和设计规范可以减少重复劳动,让人少犯错;但现在 AI 几秒钟就可以画出一个漂亮的界面,我没有必要再让它先读一套并不熟悉的框架规范,再把所有东西塞进死板的组件格子里。过度约束 UI,反而限制了 AI 的发挥。

这次我需要的不是「组件越完整越好」,而是让内容先成为 HTML,同时给 AI 足够自由去做真正特殊的交互。

Vue JS 壳与 Astro 直出 HTML 的差异

两个现场,让我不再满足于“加一个开关”

我真正下定决心重构,还有两个很具体的现场。

ESA 刷新一次,网站直接 404

当我想给网站做分流时,计划是国内通过阿里云 ESA 提供,海外通过 Cloudflare 提供。因为两边都有 Pages 业务,我一开始觉得迁移应该非常简单:把同一个静态站部署到两个地方,再按地区分流就行了。

但迁移之后,我手动从国内访问 ESA 上的网站,第一次进去完全没问题,一旦刷新就 404。后来我才知道,ESA 需要显式把单应用程序配置成“单页应用”。

这个开关当然可以直接加上,问题也就能快速解决。但它让我意识到:我的 Vue 项目根本没有构建出一组真实的页面 HTML,它只有一个入口 HTML 和一个负责接管一切的 JS 壳。不打开这个开关,ESA 会按普通静态站去找刷新后的资产,自然找不到。

这不是 ESA 难用,而是它把我的项目真实长什么样暴露了出来。一个内容站需要依赖“单页应用”开关,才能让刷新不 404,这已经不是我想要的形态了,也正是在这里,重构的念头真正出现了。

ESA 首次访问正常但刷新 404

手机浏览器恢复时,左上角只剩一张哭脸

后来我又发现,旧站用手机浏览器访问时,退出网站、去做点别的事情,再切回浏览器,偶尔会直接白屏,左上角只剩一张哭脸。

我大概猜到了原因,再和 AI 核实了一遍:页面恢复时遇到了挂载问题。原页面并不是浏览器拿到 HTML 后就能自然恢复的文档,而是必须重新加载完整的 Vue 壳,再由 JS 重新挂载 Vue View;恢复路径和正常首次进入的路径不一样,某个 Vue View 没有正确接上,就会整页白掉。

网络不好的时候,这个问题会更明显。用户先加载一个入口,再下载 JS 壳,JS 壳里又带着一大堆 JS。在这些脚本没有下载完之前,点击什么都没有反应;但 CSS 动画还在正常执行。页面看起来活着,实际上什么都点不了,这种体验非常割裂。

我也吸取了之前做 Astro 站时踩过的坑:Astro 默认导出的是 HTML,而不是一个可以接管整站的 JS 壳,所以页内跳转、局部刷新、旧 Vue View 的卸载和新 Vue View 的挂载,都会变得麻烦。既然如此,我干脆不做这套“看起来像 SPA”的东西。

新站采用了一种很简单、甚至有点粗暴的方式:你要跳转是吗?好,旧的东西不要了,重新拉一份新的 HTML,然后渲染。它确实会破坏整个 DOM,但实际体验远比“加载入口、加载 JS 壳、挂载 Vue View、卸载 Vue View、再替换 DOM”这一整套流程更快,也更容易恢复。

手机浏览器恢复时的 Vue 挂载失败

这也是新站使用 AI“随地造轮子”之后反而变轻的地方。它不会像旧站那样,先下载 HTML 入口,再加载 JS 壳,由 JS 带动整棵 Vue View 树,最后还要在局部跳转时小心检查并卸载旧 Vue View、挂载新 Vue View。新站不强求所有页面共享一棵巨大的 Vue View 树:页面之间用清晰的 HTML 和路由连接,需要交互的地方再加载对应的 React island。

Vue View 生命周期与 Astro 全文档导航的对比

先抉择架构,再开始写站

所以重构的第一环节,我没有先列页面清单,而是先筛框架。前前后后摸过 Astro、React、Vue、SvelteKit、Solid、Next.js、React Router 等各种前端技术之后,我反而发现自己并不是在信仰或依赖某一类框架。我真正需要的,是一个让网站回归网站的东西。

真正符合这次要求的,最后只剩三个候选:

  1. 现在的 Astro:原生支持良好的 SSG,静态内容是第一等公民;在这次静态构建里,它把页面输出成 HTML,需要交互的地方再单独加载脚本。
  2. SvelteKit:预渲染能力同样很好,你可以去旧站归档里寻找它曾经留下的踪影。但我认为 AI 对 Svelte 语法的掌握还不算足够强大,所以没有选它。
  3. 原生 HTML:那当然是胡扯了。虽然大家都在调侃 AI 时代让它直接写 HTML、CSS、JavaScript 三剑客也能做出精美网站,但这会让开发周期明显拉长。我还是更倾向于让 AI 使用它最擅长的 React、Vue 这一类表达方式。

前端就应该回归前端

这也是我摸过这么多前端框架之后,对现在很多主流方案越来越不耐烦的原因。它们经常默认你有一台服务器,默认你最终要做的是一个从 PHP 迁移到现代语言的全栈项目。名字、语法和部署方式变了,但底层逻辑并没有变:前端和后端被塞进同一个巨大的项目里,所有请求、页面和业务都由这套项目一起负责。

Next.js 或 React Router 提供的 SSR 就很典型。项目早期,这种方式当然非常舒服:页面、接口、鉴权、数据读取都可以放在同一处,开发者只要顺着框架的约定往前写就行。可项目一旦变大,单一项目满足不了需求,接下来就要学习 monorepo、分布式、各种部署边界和运行时拆分。它们当然都很棒,也确实能把 Serverless 或 VPS 卖出去;但站长要付出的代价是,同时维护前端和后端的服务器,有时甚至逐渐弄不清前端和后端到底应该各自负责什么。

更重要的是,这会让你失去纯静态网站的那种简单:把构建好的文件交给全球 CDN,用户直接拿到 HTML,TTFB 可以压到非常低。一个内容站如果本来不需要服务器参与渲染,却要为了使用框架而先养一套服务器,我觉得这是一笔不必要的复杂度。

全栈单体与前后端分离的边界对比

Next.js 长期和 shadcn/ui 这类成熟、风格统一的 UI 方案一起出现,也容易让初学者产生一种错觉:网站就应该这样优雅、这样快速地从脚手架开始。于是无论要做什么,都先 npm create next-app;哪怕只是一个单页应用,或者只是想做静态导出,也要配置 output: 'export',再使用那个我并不觉得有多 Turbo 的 Turbopack。更不用说某些局部刷新场景还会生成一大堆 .txt 文件,框架称之为页面路由索引文件,但在我看来就是土爆了。

SvelteKit 反而是我认为做得最好的一个框架:它在应用能力和静态输出之间的平衡很漂亮。只不过 AI 对 Svelte 语法还不够熟悉,这次我就算了。框架好不好是一回事,AI 能不能稳定地把它写出来,又是另一回事。

我真正希望的,是让前端回归前端:前端负责显示内容和做一些简单计算;登录、鉴权、数据持久化,以及真正的业务逻辑,则放到一个彻彻底底的后端里。前端通过 API 使用它,但不需要把后端的职责重新吞进自己的项目。

现在论坛已经被我砍掉,博客是 SSG;AI 生图和交互小说虽然复杂,却也不需要服务端渲染,它们本来就是在浏览器里加载交互界面,再调用后端 API。对我来说,Astro 静态外壳、React islands 和独立后端 API 的组合,刚好把这几件事分开,也刚好适合这个站点。

这次真正需要做出的架构选择,其实只有 Astro。它很好地满足了我对“网站应该是网站”的要求:页面可以直接输出 HTML,内容先交到浏览器、搜索引擎和 AI 手里,而不是先交付一个负责接管整站的 JS 壳。

Astro、SvelteKit 与原生 HTML 的架构选型

至于 React,并不是我事先信仰或指定的答案。我的要求只是“用 Astro 搭一个有很多内容的站”,并没有规定交互层必须使用什么。AI 做到需要交互的地方时,顺手选择了它最擅长的 React。React 是实现交互的执行层选择,不是这次重构的架构目标。

这次是怎么让 AI 开始搭站的

做完架构选择,我给 AI 的第一句话非常简单:

我们要做一个站,用 Astro,并且它有很多内容,你先搭个骨架。

AI 顺理成章地选择了 React。接着,它决定用 Base UI 做基本交互,用 shadcn 做一些 UI 的管控;我只需要告诉它品牌名和大致方向,它就开始搭页面、分组件、补路由。

然后我又告诉它这个站叫 AcoFork / AF。它顺手画出了现在这个 AF 图标:荧光绿底、黑色字标,简单但很有识别度。我觉得它做得非常不错,后来就直接拿来当新站的 favicon 和品牌标识。

AcoFork 新站的 AF 标识

这次并不是我先把每一个按钮、每一个卡片的尺寸都规定好,再让 AI 照着施工;我只先把架构边界和品牌方向说清楚,让它自己把一个「有很多内容的 Astro 站」搭起来。

Astro 这次负责什么

这次的 Astro 不再只是「博客生成器」,而是负责整个站点的路由、构建、静态页面和统一外壳

首页现在是一张入口地图:AI 生图、博客、交互小说、追番、工具集和连接,六个方向放在同一张桌子上。关于、隐私、公告、归档、工具详情等页面也都是 Astro 页面,直接在构建时产出 HTML。

博客则回到了它应该在的位置:

ts
const posts = await getCollection('posts', ({ data }) => !data.draft)

文章文件直接放在 src/content/posts,由 Astro Content Collection 读取。每篇文章通过 getStaticPaths() 生成独立页面,标题、描述、日期、标签、目录、Open Graph、JSON-LD、RSS、搜索索引和站点地图都能在构建期确定。

这意味着用户打开一篇文章时,第一眼拿到的是已经存在的文章,而不是一个「正在加载博客」的空壳。

Markdown 也不再只是简单地转成 HTML。现在的处理链会保留 Shiki 代码高亮,给 Mermaid 留出客户端升级成 SVG 的位置,识别 GitHub 风格的提示块,并且给已知图片补上尺寸、懒加载和异步解码属性。以前那些文章里的代码块、目录和图片,终于不需要依赖旧站的整套渲染器才能工作。

React 没有消失,只是不再负责整站

Astro 页面里最重要的一行,可能是 client:load

astro
<SiteHeader currentPath={Astro.url.pathname} client:load />
<AnnouncementDialog client:load />
<RuntimeErrorRecovery client:load />

这就是所谓的 React island。页面主体可以是静态 HTML,但需要交互的那一小块在浏览器里加载 React。

博客列表、文章阅读增强、评论、账号中心、AI 生图工作台、小说阅读器、小说编辑器、追番探索器和钱包,都沿用了这个思路。Astro 页面负责放置组件、传入初始数据和页面元信息,React 组件负责状态、事件和 API 通信。

于是现在的结构更像这样:

flowchart TD A[Astro 路由与构建] --> B[静态首页 / 博客 / 归档 / 工具] A --> C[统一 Layout / 导航 / 主题 / SEO] A --> D[React islands] D --> E[AI 生图] D --> F[账号与钱包] D --> G[交互小说] D --> H[追番 / 评论 / 博客增强] E --> I[现有后端 API] F --> I G --> I H --> I

这不是把 React 和 Astro 粘在一起就结束了。真正有用的地方是:它们可以共享一套站点边界,但不必共享同一个渲染策略。

Astro 静态外壳、React islands 与现有 API 的分工

一个博客页面可以是静态的,一个生图页面可以保持长连接和上传能力;它们使用同一个 Layout、同一个站点头部和同一个主题系统,但互相不必为对方承担运行时成本。

迁移的不是几个页面,但文章本身并不重

我之前把这一节写成了「迁移的不是几个页面,是 165 篇文章和一整套旧资产」,听起来好像文章迁移是这次最重的工作。其实不是。

静态博客说到底就是文件系统加上框架:读取 Markdown 和图片,再按照路由渲染成 HTML。现在仓库里有 165 篇文章,它们没有被改写成一套新的数据库记录,也没有被拆到另一个内容服务里,而是作为 Markdown 继续保存在仓库里。把这些内容放回 Astro 的 Content Collection,实际上是一件很直观的事情。

旧文章里的图片也跟着进入新站,构建期通过统一的图片尺寸表为文章预留版面,避免图片加载时把页面内容突然推开。灯箱、Mermaid 渲染、代码复制、目录和图片懒加载,这些都属于在内容之上的锦上添花,AI 写起来也非常顺手。

文章迁移完成之后,博客还需要重新补齐一整套边角:

  • 草稿不能进入公开列表;
  • 文章目录要和正文标题保持一致;
  • 旧文章里出现的 H1 要降成合理的章节层级;
  • 代码块需要语言标识和复制按钮;
  • Mermaid 需要在静态 HTML 到达之后再渲染;
  • 图片需要尺寸、懒加载和错误时的可恢复体验;
  • RSS、搜索索引、站点地图和文章结构化数据不能漏掉。

所以,「把 Markdown 复制过来」在这次迁移里并没有想象中那么重。它需要认真收尾,但不是决定项目能不能上线的那道大关。

真正重的是 AI 生图和交互小说

整个站最难、最重的地方,是 AI 生图和交互小说

在开始做这两个功能之前,已经搭好的内容只有 2000 多行代码。等 AI 生图和交互小说竣工之后,代码量增加到了 3 万多行。这不是页面数量突然变多,而是这两个功能本身就非常复杂:它们有登录态、复杂表单、上传、队列、实时进度、流式事件、媒体令牌、条件分支、故事状态、编辑器和管理端。

AI 生图和交互小说带来的复杂度跃迁

AI 生图也绝不是一个「提交 prompt,然后等图片」的按钮。它还包含角色和风格库、预设、图片上传、队列、插队券、媒体令牌、我的图片、精选图片、TTS、LoRA 申请以及实时进度。把这么大的项目改成一个按钮,是最明显的偷懒行为:接口看起来通了,真正的产品却根本没有迁移。

旧站归档里,我现在一共列了 19 次重构,实际上远比这更多。它们大部分都在开发初期就胎死腹中了:我兴致勃勃地选了一个新框架,把除了 AI 生图和交互小说以外的事情全做完,随后就没有动力继续了。

原因很简单:我没有足够的理由去重构这两套大东西,但不把它们重构完,又没有办法替换生产;我也不可能让两个站同时运行。于是很多新框架只完成了首页、博客、关于和一些边角功能,最后还是因为核心业务没有搬过去,只能放弃。

所以对于 AI 生图和交互小说这种非常重、同时又是重要商业项目的功能,迁移必须谨慎、谨慎、再谨慎。如果完全不做这两个项目,我一天可以重构几十次这个网站;但只要带上它们,一天连一次完整重构都做不到。

好在每次重构基本都只改前端,也就是改一下外在,后端 API 不需要跟着重写。真正的做法不是让 AI 偷懒,把后端返回的 JSON 原样显示到页面上,而是让它一点一点对齐字段:把数据做成结构化解析,做成一个个按钮、一个个表单、一个个有明确状态的交互。

我把整个旧站前端交给 AI,让它对照旧实现去理解 API 字段、请求参数和返回结构;然后我再拿真实账号、真实队列、真实图片和真实故事流程去点、去试、去验证。即使有完整的旧前端作为参考,最后仍然花了大半天手动测试和冒烟测试。只要一个字段对不上,或者一个按钮在边界状态下失效,就继续回到代码里修。

这非常麻烦,但这是发布到生产之前必须做的事。我不能因为 AI 已经把页面写出来了,就把一套没有经过真实数据验证的核心商业功能交给用户。

AI 业务迁移与真实数据验证流程

这次迁移最终还是把旧项目里的能力逐项搬到了新的 React 组件树里。Astro 只负责提供 /create//create/admin/ 和找回入口等页面,真正复杂的状态仍然由生图工作台处理。

交互小说也采用相同方式:阅读、故事页、创作台、导入页和管理端各自有 Astro 路由,具体的阅读状态、条件判断、编辑和保存逻辑留在 React 组件中。

账号、追番、钱包和评论没有因为站点静态化就被迫变成静态页面。它们照旧通过现有 API 工作;新前端只是把 API 调用放到了更清晰的客户端边界里。

这次迁移的重点不是「把后端也重写一遍」,而是让前端终于承认:静态内容和动态产品可以共存,但不该假装自己是同一种页面。

旧链接也要接住,这是最容易漏掉的部分

前面说了,最重的仍然是 AI 生图和交互小说;旧链接替换反而是最不费时间、却最容易被漏掉的部分。很多路径在迁移页面和路由的过程中就顺手处理完了,最后我再做了一轮收束,总共只花了不到半个小时。

它当然仍然值得做:旧链接不能因为新站换了命名就全部失效。但它更像是发布前的整理工作,而不是决定这次重构能不能完成的核心工程。

以前的入口有 /posts/login/ai/draw/webnovel 等,还有一些历史上生成过的短链。它们不能因为新站换了命名就全部失效,所以我把这些兼容关系集中放进 legacy-redirects.mjs,让 Astro 配置和构建后的迁移页面共用一份清单。

固定入口会跳到新的位置:

旧入口 新入口
/posts /posts/
/login /account/
/ai/draw /create/
/webnovel /novels/

这里还藏着一个很小、但足以让评论区永久消失的细节:尾斜杠

Astro 的推荐实践是把 trailingSlash 统一设成 always,让页面始终使用 /posts/<slug>/ 这样的地址。大部分前端框架并不会强迫你做这个选择,甚至会让 /posts/<slug>/posts/<slug>/ 看起来像是同一个页面。但对依赖页面路径来匹配评论的 Giscus 来说,它们就是两个不同的身份。

当时我去对接 Giscus,发现评论区竟然全部没了。后来才发现,问题不是评论数据丢了,而是新站页面加了尾斜杠,评论归属用的路径也随之变化。更尴尬的是,旧前端的文章地址从始至终也没有加尾斜杠,所以旧站的 Giscus 评论区实际上早就瘫痪了,只是我一直没有发现。如果这次不重构,它大概会一直死在那里;等有人在错误的路径下留下新的评论,未来再想把评论区合并回来,就会变成一桩很脏的活。

所以这次我把尾斜杠当成了站点的路径契约:文章统一使用 /posts/<slug>/,Giscus 也使用固定的 posts/<slug>/ 作为评论归属键。它看起来只是 URL 末尾多了一个字符,却决定了评论、统计、搜索索引和旧链接到底是不是同一个页面。

尾斜杠与 Giscus 评论归属

AcoFork 旧入口到新站房间的迁移路线

带有查询参数的旧入口还会保留 query 和 hash,避免登录回调、分享链接或某些带参数的历史地址被迁移时截断。

更重要的是,这些页面不再是冷冰冰的浏览器默认跳转,而是一个带有 AcoFork 品牌样式的过渡页:告诉用户旧入口去了哪里,短暂展示迁移状态,并提供手动继续的按钮。

这件事看起来不像新功能,却直接决定了迁移对老用户来说是「站点升级」还是「链接突然坏了」。

发布也回到简单的静态模型

新站的生产产物就是 dist/,部署目标是 Cloudflare Workers Static Assets:

json
{
  "assets": {
    "directory": "dist",
    "not_found_handling": "404-page"
  }
}

构建时由 Astro 生成静态页面,再由 Wrangler 发布。404-page 让自定义 404 页面和旧站兼容入口继续有机会接住请求;固定的旧入口则由 Astro 重定向配置和构建钩子共同处理。

这套发布方式没有引入新的常驻前端服务器,也没有让博客为了几个交互页面重新回到 SSR。需要实时数据的地方请求后端,需要稳定内容的地方直接交付静态文件。

我还拆掉了旧站里的 Pages CMS

除了换框架,这次我还拆掉了旧站里嵌入的一个东西:Pages CMS

早期我专门写过一篇文章疯狂夸赞过它。它是一个很好的无头 CMS,可以接入各种静态网站。它解决的问题也很实在:我不需要坐在自己的电脑前,只要在世界上的任何地方连上网络,打开 Pages CMS,连接我的 GitHub 仓库,就能开始写文章、上传图片。

但它有一个和现代 CI/CD 流水线天然冲突的默认行为:上传一张图片,Pages CMS 默认就会创建一个提交并推送。

在现代 CI/CD 里,一次推送通常就意味着一次正确的部署。如果一篇文章上传十几张图片,Pages CMS 就可能制造十几个提交;不加限制的话,免费部署额度很快就会被消耗干净。

所以旧站不得不做兼容处理:我创建了两个分支,一个叫 edit,一个叫 pages

Pages CMS 连接到 edit 分支。写文章、传图片、保存修改,都只提交和推送到 edit;这个分支的推送不会直接触发 CI/CD。真正准备发布时,再在 Pages CMS 里点击一次部署按钮,由手动 GitHub Action 把源码构建成前端资产,然后推送到 pages 分支。pages 一旦收到推送,CI/CD 才会继续工作,最终渲染出一个新的网站。

Pages CMS 双分支流程与 AI 单主分支流程

这套方案当然很棒,只是它解决的是「人需要一个网页 CMS」的问题。到了 AI 时代,我完全可以让 AI 直接连接 GitHub 仓库,用嘴巴告诉它写什么、改什么。相比打开 CMS、找文件、上传图片、等待提交,这样写作更快,逻辑也更通畅。

所以新站直接放弃了这套做法:不再维护 editpages 两个分支,源码和文章回到一个 main 分支。CMS 曾经替我把「人在任何地方写作」这件事变简单;现在,这件事由 AI 接手了。

这篇文章本身就是这样写出来的:我直接和 ChatGPT 对话,把新站的架构、迁移过程和修改意见说清楚,让它读取仓库、起草文章,我再在本地开发环境里审阅和修改。左边是文章预览,右边就是这次写作过程的一部分。

左侧是新站文章预览,右侧是与 ChatGPT 讨论并修改迁移文章的过程
这篇迁移文章没有经过 Pages CMS,而是直接和 AI 对话、在本地开发环境里审阅。

这次重构真正改变的是什么

表面上看,变化是从 Vuetify 换回了 Astro,组件从 Vue 换成了 React islands。实际上真正改变的是我对这个项目的理解:

以前我总是在问「这个站应该用哪个框架」。现在我更愿意先问:这一块内容应该在什么时候生成?它需要多少运行时?它和后端的边界在哪里?

框架只是最后的答案。

这次迁移留下了几条比较明确的结论:

  • 静态优先不等于拒绝交互:把不需要运行时的内容先生成,把真正需要状态的部分交给 island。
  • 一个仓库不等于一个运行时:博客、首页和功能页可以在同一个仓库里协作,但各自采用适合自己的渲染方式。
  • 迁移的完成标准是旧链接仍然有意义:页面搬过来只是开始,旧入口、参数、图片、RSS 和搜索索引也要一起接住。
  • 旧资产最值得保留的不是代码,而是协议和内容:文章、账号、生图、小说这些已经运行起来的东西,比重新发明一套更重要。
  • 第 19 次也不会是最后一次:但下一次更应该是按边界调整,而不是因为某个框架最近很新鲜。

上一次迁移,我把主站从一套紧急拼起来的 CSR 重新做成了一个完整的应用。这一次,我又把这个应用拆回了更适合它的形状:外面是一座静态、稳定、容易访问的房子,里面住着几个真正需要实时工作的房间。

这大概就是现在这个新站最准确的样子:Astro 负责把门、走廊和地址写清楚,React 负责让房间里的东西动起来,后端继续提供真正的服务。

Community notes

评论

评论默认折叠,打开后才会连接 GitHub。