从 Valaxy(Vue)迁移到 SvelteKit,再加一层 Butterfly 主题和全套 SEO。这篇复盘整个过程的得与失。
为什么重构
旧站是 Valaxy + Vue 3 + Vite,跑了很久,功能齐全。但有几个绕不过去的问题:
- SEO 靠补丁。SPA 的 HTML 是空壳,我是靠 Worker 在服务端给爬虫注入预渲染正文来凑合的。能工作,但每加一个页面都要维护一套注入逻辑。
- 主题魔改到头了。为了 Butterfly 风格,我覆盖了 Valaxy 主题的七八个组件,升级一次主题就可能全崩。
- 管理后台是「能用」级别。Vditor + Tailwind CDN,加载慢,代码三千行,改一处怕全局崩。
所以这次目标很明确:内容先成为 HTML;SEO 内建而不是外挂;后台重写成想维护的样子。
技术栈选择
- SvelteKit 3.1(SSR/SSG 一体)+ Svelte 5(runes)
- adapter-static:全站预渲染成静态 HTML(232 个页面的思路来自 acofork 的第 20 次重构)
- Cloudflare Pages 部署;后端沿用现有的 Workers + D1 + B2(零迁移成本)
- Markdown 用
marked渲染,评论换 Twikoo
值得说明的是 acofork.com 的误导:我一开始通过逆向分析判断它"不是 SvelteKit 而是 Preact CSR",后来发现是我的探测方法失效了——它所有未匹配路径都返回 200+首页内容(soft 404),任何探针都会"成功"。它自己 bundle 里的版本历史表写得很清楚:第 20 代就是 SvelteKit。教训:soft 404 的站点,任何"存在性探测"都不可信。
SEO 层(这次的核心投入)
SEO 不是加几个 meta 就完事。我用 Playwright + REA 逆向了 acofork.com 的真实产物,逐值对齐:
- 标题规则:首页 = 站点名;子页 =
页面标题 | 站名 - canonical:绝对 URL、统一尾斜杠、每页一个
- JSON-LD:单
@graph:Person + WebSite + WebPage,文章页加 BlogPosting(11 个键与键序逐一对齐)+ BreadcrumbList - robots.txt / sitemap.xml / rss.xml:作为静态文件在构建期生成(sitemap 是
<loc>唯一子元素、文章倒序)
同时修掉了原站(以及 acofork)的几个缺陷:
| 缺陷 | 修复 |
|---|---|
| 未知路径返回 200+首页(soft 404) | 真实 404 + 独立页面 |
文章页 og:type=website |
article |
无 dateModified |
空值回退发布日期 |
| 无分页(174 篇挤一页) | 分页 + rel=prev/next |
| 无安全头 | HSTS / nosniff / X-Frame-Options / Referrer-Policy |
Butterfly 主题:逆向而非照抄
主题样式我全部从 hexo-theme-butterfly 编译后的 CSS 里取值,不是看截图猜的。几个反直觉的事实:
- 卡片不是玻璃拟态——全站只有固定导航用
backdrop-filter: blur(7px),卡片是不透明的 - 主色是三件套:
#49b1f5(主)、#00c4b6(激活:分页当前页/TOC/::selection)、#ff7242(悬停),没有单一--primary-color - 文章卡片 hover 不位移——只有阴影加深 + 封面
scale(1.1) - 头像在侧栏作者卡,不在 banner
有些坑只有部署了才发现:
background-attachment: fixed在overflow:hidden的短 banner 里会让图片只显示一条- SVG 波浪要无缝循环,路径必须是整数周期 + 用百分比平移(
preserveAspectRatio="none"下用户单位 ≠ CSS 像素) - SvelteKit 的 SEO 标签必须包在
<svelte:head>里,否则渲染进<body>,爬虫读不到
目录滑块:一个被我理解错三次的需求
"目录要有动画"——我先后做了:抽屉弹出动画(错)、恢复吸顶(错)、放大动画时长(还是错)。最后才明白用户要的是目录左侧那个 4px 竖条跟随阅读位置滑动。
实现:IntersectionObserver 跟踪正文标题 → 计算 active 项的 offsetTop → 驱动滑块 top。过渡曲线 cubic-bezier(0,1,.5,1) 0.25s,数值全部从老版 DOM 实测。
教训:需求里的"动画"指什么,先确认再说。
音乐播放器:上游才是真问题
播放器换成了 APlayer(与老版一致),但部署完发现无声。追了整条链路:
API url → 302 → music.163.com/song/media/outer/url?id=….mp3
→ 302 → music.163.com/404 ← 是网页不是音频
网易云外链已被版权/VIP 封锁,Meting 拿不到直链。我还写了个"逐首探测可播性"的过滤器,结果发现它从原理上就是错的——fetch 探测受 CORS 限制必然失败,而 <audio> 播放不受限,于是大量能播的歌被误杀。已回退。
中间还发现 Meting 官方测试页自己也全线报错——上游版权状态就是这样,与播放器实现无关。最终方案:保留 APlayer,用户家宽 IP 下可播(服务端出口 IP 会被拒绝,这是 IP 相关)。
评论系统:Twikoo + 同域挂载
从 giscus 换成 Twikoo(支持匿名评论,不强制 GitHub 登录)。后端部署在 Cloudflare Workers + D1。
两个坑:
- D1 免费额度满了——复用现有 blog 库(Twikoo 的三张表名与博客表不冲突)
- 发评论报
code 1000: FinalizationRegistry is not defined——主 Worker 的compatibility_date太旧,该 V8 特性未启用。提升到 2026-09-01 解决
最初的独立 Worker 偶发报「请求被跨域策略拦截」,后来直接同域挂载到博客 API 的 /twikoo 路径下——同域就没有跨域这回事。
管理后台重写
新后台:登录(用户名+密码)+ 文章列表 + Markdown 编辑器(11 个工具栏按钮 + 实时预览)+ 相册管理。走现有 Worker API,数据与旧后台互通。
发布后自动重建:Worker 调 GitHub repository_dispatch → Actions 重新预渲染 → 部署 Pages,全程约 1 分钟。
修过一个隐蔽 bug:触发重建的 fetch 没有 await,而 Cloudflare Workers 会在响应返回后取消未完成的子请求——于是重建经常不触发。改成 await Promise.allSettled([...]) 后实测通了。
优点
- SEO 内建:SSR 直出 + 全套结构化数据,不再依赖 Worker 注入补丁
- 真实 404:不再是 200 + 首页内容
- 每页恰好一个 h1:正文 h1 自动降级,构建期保证
- 主题可维护:所有样式是普通 CSS + 设计令牌,不依赖主题框架版本
- 后台好用:干净、快、移动端可用
- 构建产物小:纯静态 HTML + 少量 JS
缺点
- 动态性下降:adapter-static 意味着新文章要等自动重建(约 1 分钟)才上线;旧版是即时显示
- 多了一层构建链:发布 → dispatch → Actions → build → deploy,任何一环挂了站点就不更新
- Svelte 5 runes 有学习曲线:
$effect里读写自己的依赖会无限重跑(打字机就这么坏过一次) - Theme 生态换血:Valaxy 的插件/主题机制全部作废,需要什么得自己写
- 搜索没做:旧版的 fuse 搜索没迁移
数字
- 构建产物:13 个静态 HTML 页 + 资源,~900KB
svelte-check:0 error / 0 warning- SEO 验证:13 项断言全过(每页 1 个 h1、JSON-LD 键序、真实 404、结构合规)
- 端到端:发布 → 自动重建 → 上线,实测约 1 分钟
最后
这次重构最大的收获不是换了框架,而是把「内容 → HTML → SEO → 后台 → 评论」整条链路重新理了一遍。每个环节现在都有明确的职责和验证方式。
以及一个朴素的教训:逆向别人的站点时,别相信任何"存在性探测"——尤其当那个站有 soft 404 的时候。
