用 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 之后,起初我直接修改了根节点 html 的 font-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