用 Astro 重构

杂谈 | 共 2740 字 | 2026/5/8 发表 | 2026/5/9 更新

Astro 看到挺久了,一直想拿它把这个站和「临高启明」都重构一下,但一直没精力。今年年初 AI 工具进步飞快,陆陆续续试了几个确实越来越好用了,干脆让 AI 帮我把这次重构做了。

为什么再换一次

上一次说要用 NextJS 重构是 2024 年的事,现在又过了一年多。NextJS 用了一年多没出大毛病,但我这边内容更新频率本来就低,跑 ISR 的 serverless 函数对一个纯静态内容站属实过度。

之前已经把「临高启明公开图书馆」从 NextJS 迁到 Astro 了,那次自己写过一篇内部迁移指南。这次基本照着走,不过 halu.lu 多了几个交互组件,APlayer 全站底部固定播放器、字号和主题切换、康威生命游戏,比 lgqm 麻烦一些。

怎么做的

文件结构

pages/ components/ utils/ styles/ 改成 src/pages/ src/components/ src/lib/ src/styles/content/ 不动。

getStaticPaths 从 async 改成同步,构建时跑没必要异步,getStaticProps 直接合并到 Astro 的 frontmatter,一个 <Layout> 里直接 loadAll()markdown(...)Astro.params 解构,七行 frontmatter 搞定章节页。

React 组件里那些 useState 维护的 UI 状态比如字号、主题、汉堡菜单,全改成原生 JS 加 localStorage,这种几行的玩意不需要 React 运行时。康威生命游戏直接删了,反正用得少。

APlayer 跨页持续

这是最有意思的一块。NextJS 的 React SPA 行为下 _app.tsx 包住每个页面,APlayer 一旦初始化就跨页常驻,音乐不会停。Astro 默认每次跳转都是整页加载,APlayer 实例和 <audio> 元素都会被销毁。

解决方案是 Astro 的 View Transitions

<head>
  <ClientRouter />
</head>
<body>
  <div id="mainPlayer" transition:persist></div>
</body>

<ClientRouter /> 拦截内部链接走 SPA 风格的「换 body」跳转,transition:persist 告诉 Astro 这个元素跨跳转保留,APlayer 的 <audio> 元素活着,音乐不停。

不过 SPA 跳转引入了一堆边角问题:

  • 每页脚本(hljs、单曲播放器初始化)只在首次模块加载时跑一次,要把初始化挂到 astro:page-load 事件,每次跳转后再跑一遍。
  • 导航栏汉堡菜单、字号按钮、主题按钮的 DOM 每次跳转都被替换,事件监听需要在 astro:page-load 里重新挂。
  • Giscus iframe 需要按当前 pathname 加载评论线程,每次 astro:page-load 把 giscus 容器清空再注入一个新的 <script> 标签。
  • Google Analytics 的 gtag('config') 默认上报一次 pageview,SPA 跳转就不会再报,改成 gtag('config', ..., { send_page_view: false }),自己在 astro:page-load 里手动 gtag('event', 'page_view', { page_path, ... })

全站重构为 Tailwind CSS

本来 Astro 迁移时还留着 Bulma,但感觉在 AI 时代 Bulma 已经有点过时了。于是顺手用 Tailwind CSS (v4) 把全站样式重新写了一遍,并且引入了 @tailwindcss/typography 插件(即 prose)来优化 Markdown 的渲染排版。

以前字号缩放是靠给 html 加 .is-size-* 的类,换了 Tailwind 之后,起初我直接修改了根节点 htmlfont-size,结果由于 Tailwind 全站使用 rem 布局,导致整个网站的 Navbar、边距、间距全部跟着放大。为了让字号只影响正文排版,我改成了通过 JS 注入 CSS 变量 --article-fs,然后在 global.css 里指定 .prose 容器的 font-size 使用这个变量。这样借助 Typography 内部各级标签 em 的相对单位特性,只完美等比缩放了文章正文,其他 UI 组件丝毫不受影响。

对于 Markdown 里的 Blockquote(引用块),Typography 插件默认会在两边加巨大的双引号并全倾斜,不太符合中文排版。我在全局 CSS 里强行去掉了双引号并恢复了以前的带侧边框设计:

.prose blockquote {
  background-color: theme('colors.zinc.100');
  border-left-width: 4px;
  border-left-color: theme('colors.zinc.300');
  color: theme('colors.zinc.600');
  padding: 1rem 1.25rem;
  border-radius: 0 0.375rem 0.375rem 0;
  font-style: normal;
  margin: 1.5rem 0;
}
.prose blockquote p {
  margin: 0 !important;
}
.prose blockquote p::before,
.prose blockquote p::after {
  content: none !important;
  display: none !important;
}

暗黑模式跟闪白

借着 Tailwind,原本的一堆手动维护的 CSS 变量也不需要了。通过配置 @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));,完美地将老站点击按钮修改 <html data-theme="dark"> 的逻辑跟 Tailwind 的 dark: 前缀结合了起来。

<head> 里加了一个 is:inline 的小脚本,在外部 CSS 加载前同步读 localStorage 应用 data-theme,避免冷启动闪白。

但热启动还有问题。开了 ClientRouter 之后 SPA 跳转时,浏览器在两个页面快照之间做 cross-fade,dark mode 下过场期间会透出一帧浏览器默认 canvas 色(白),看着像全屏闪瞎眼。

原因有两点:第一是在 Tailwind 里,我起初把背景色只加在了 <body> 上,而根节点 <html> 在过渡时暴露了默认的白色底色;第二是 view-transition 伪元素在暗黑模式下的选择器如果不小心加了空格(比如 [data-theme='dark'] ::view-transition),就会变成查找子节点,导致完全没匹配上。最终的修复方案是给 html 加上底色,并且严格限制没有空格的伪元素选择器:

html {
  background-color: theme('colors.zinc.50');
}
[data-theme='dark'] {
  background-color: theme('colors.zinc.900');
}

::view-transition,
::view-transition-group(*),
::view-transition-image-pair(*) {
  background-color: theme('colors.zinc.50');
}
[data-theme='dark']::view-transition,
[data-theme='dark']::view-transition-group(*),
[data-theme='dark']::view-transition-image-pair(*) {
  background-color: theme('colors.zinc.900');
}

::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
  animation: none !important;
}

(*) 通配所有 group 加上给 html 和过场根元素填上主题底色,闪白彻底没了。

代码高亮跟主题

highlight.js 之前一直用 github-dark.css,浅色模式下代码块是个突兀的深色框。这次把 light 和 dark 两套样式都用 Vite 的 ?inline 当字符串导入,做一个 <style id="hljs-theme"> 元素动态切换内容,配合 MutationObserver 监听 <html data-theme> 变化:

import githubLight from 'highlight.js/styles/github.css?inline';
import githubDark from 'highlight.js/styles/github-dark.css?inline';

function applyHljsTheme() {
  const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
  styleEl.textContent = isDark ? githubDark : githubLight;
}

代价是单 post 页 bundle 多了 10KB 左右,反正只在文章页加载,无所谓。

Sitemap 跟 RSS

之前完全没有,这次装了 @astrojs/sitemap@astrojs/rss,build 时自动生成 /sitemap-index.xml/rss.xml,Layout 的 <head> 加了 <link rel="alternate" type="application/rss+xml"> 让阅读器和爬虫能发现。

重定向

旧的 /杂谈/* /笔记/* Hugo 路径,本来用的是 Astro 内置的 redirects 配置,跑 loadAll() 拿到所有 post 后枚举生成几十个 meta-refresh HTML 跳转页。后来换成更简单的 public/_redirects 文件加通配符:

/杂谈/*  /posts/:splat  301
/笔记/*  /posts/:splat  301

Cloudflare Pages 和 Netlify 都识别这个文件,原生 301,比 meta-refresh 快,对 SEO 也好。

slug 改成日期加英文

迁移之前所有 post 都是中文 slug,比如 /posts/谁是好人。复制粘贴出去会变成 %E8%B0%81%E6%98%AF%E5%A5%BD%E4%BA%BA,不可读,GA 报表里也是百分号编码。

干脆把 32 篇都 rename 成 YYYYMMDD-english-slug.md,URL 形如 /posts/20251010-who-are-the-good-guys,日期前缀做时间锚,对未来同名重复也免疫。「重构」已经写到第三次了,要是没日期,/posts/the-refactor 早碰撞了。

旧链接通过 _redirects 全保留,从老中文 URL 301 跳到新带日期 URL,0 死链。

Tools 页面与 Navbar 体验重构

原先用 Vue 写的几个客户端小工具(Hash、Encode、JSON)也借着这次机会彻底翻新了。一开始这些工具被拆成了三个路由(/tools//tools/encode/tools/json),因为有 Astro 的 ClientRouter,点击时感觉像是在当前页切换。但实际上这会引起页面组件替换,带来不必要的渲染逻辑。于是我最终把它们合并到了同一个 index.astro 页面中,用原生 JS 加 display: none 实现了真正的客户端内 Tab 切换,彻底消灭了切换工具时的跳转感。

其次,为了充分利用屏幕空间,工具页面采用了流式拉通布局。以前的 Navbar 是被局限在居中的容器里的,这导致工具页内容拉宽了,但 Navbar 还在中间。我索性移除了 Navbar 的宽度限制,改成了真正的全屏 w-full,让 Logo 靠在屏幕最左上角,所有的功能入口都放在了右侧。

顺带修了一下以前由于 OS 级别暗黑模式影响导致的浅色主题下输入框变黑的问题,现在这几个工具终于有了正经生产力工具面板的样子了。

主页加只挖矿武士兔

原来主页就一行「主页还在施工中」太单调,索性让 Claude 用纯 SVG 画了只 Usagi Yojimbo 风的像素武士兔在那挖矿。每个像素就是一个 <rect>,32×32 viewBox 装下兔子、镐子、矿洞断面和几颗会发光的矿石;CSS 关键帧用 steps(1, end) 配九帧镐子挥动,给那种顿挫的像素动画感,比两帧切换有看头多了。

挥镐节奏走的是固定的 1.2s 一轮,矿石发光和灰尘飘动各自挂在更长的周期上互不打扰。中间折腾过一阵想让镐子速度跟着鼠标走——先是 :hover 二值切换(突变感强),又试了「鼠标到矿石距离」的连续函数(鼠标静止贴在矿石上反而最快,语义错位),再试了 500ms 滑动窗口算平均(采样进出窗口的瞬间会跳一下),最后上 EMA + @property <time> 变量加 transition 二次低通,理论上是 lisyarus 那篇 exponential smoothing 推荐的方案。结果几版下来都没真的丝滑过——CSS animation 中途改 animation-duration 总会让当前迭代莫名其妙地跳,外加跟鼠标速度耦合本身就会引入心理预期失配,最后干脆全部砍掉,回到匀速。prefers-reduced-motion 下镐子定格在抬起的姿势。整个场景被抽到 src/components/MinerScene.astro,主页就剩两行 import 和一个标签。

这种纯装饰性的小细节交给 AI 写起来挺爽的,几轮迭代下来从「丑得像勘探队员」走到「武士头巾兔子」,自己手画 pixel art 估计能磨掉一下午。

体感

Astro 的开发体验比 NextJS Pages Router 简单不少。frontmatter 里直接拿数据,模板里 {} 插值不用包成 prop,dangerouslySetInnerHTML 换成 <Fragment set:html={html} /> 也更短,构建产物纯静态,部署到任何 CDN 都是一份压缩包的事。

整个迁移大概几小时,主要时间花在想清楚 ClientRouter 怎么持久化 APlayer,调试 view-transition 的闪白,还有起 slug 的英文名,每个改了好几轮。

Antigravity 这个 IDE 体验不错,可惜 Gemini 模型不太行,Claude 倒是更好用,这次主要让 Claude 写的代码。Claude 好用归好用,每个细节还是要不断指导才能弄对,而且量也太少了,写一会就用完了得等。以后这种重构估计还是会交给 AI,但完全放手暂时是别想了。

十年了

想想博客第一篇「钻木取火」是 2016 年 12 月写的,刚好十年前。那年三月 AlphaGo 赢了李世石,OpenAI 也才成立一年多,没多久就开始研究怎么用强化学习打 Dota。我自己也差不多那段时间开始打 Dota,开始瞎折腾各种小网站和项目,对所有新东西特别兴奋。十年过去 Dota 倒是还在打,OpenAI 出了 ChatGPT,当年那批做棋类和打 Dota 的模型如今坐在我电脑上帮我写代码,挺奇妙的。

回头看这十年的 post 也有挺多幼稚的地方。2017 年那篇「在云币交易」,当时手上真的有几个 ETH,云币跑路前我把它换回了人民币,相比成本算是赚了一笔,但跟后面几年的复利一比就很短视了,知道一些东西但不真信,跟没看见也差不多。其他像 NodeBB、ZeroNet 那些折腾,那会儿就是高中对什么感兴趣就去做,也没怎么想过有什么用,Websocks 还帮我找到了第一份实习。倒是当年那种少年人见着新东西就想搞一遍的劲儿,现在确实没了。

总之先这么写着吧。下个十年再回头看现在,估计也会有挺多东西想感慨。


— Claude & Gemini on behalf of Halulu