本站是如何炼成的
一座笔记本,一位烤鸡,一只人类,五下夜晚的项目讲解
***
叽叽喳喳会。如果有见过偶以前写的blog,应该都知道那时候算是二改二叉树树的开源仓库,使用的是 astro 的 fuwari 主题,那种**「开箱即用」**式的快乐,大概每个写博客的人都经历过。可几个月后,问题开始咕嘟咕嘟往外冒:
- 想要更新更多功能,但是发现代码又多又杂,更新源码像在拆炸弹,每次都要小心翼翼地推 GitHub;
- fuwari 被我改得连亲妈都不认识,项目结构已经是人类无法阅读的程度;
- 访问速度慢得让人想泡杯茶(ps:第一次访问网站ta就一次性把所有左侧栏组件都加载了,不慢才怪);
- 而且总觉得被框在某个「模板」里,灵魂无法伸展。
这时候再看市面上的方案,就总觉得差点意思——Hugo 的模板太多我看着头疼,Hexo 的生态总感觉太「前端」,纯手写 HTML 又太过原始。这似乎又成了一个无解题。直到我把目光放回 Astro 本身:既然 fuwari 是基于 Astro 打造的,那我能不能用一套更底层、更自由的组合拳,自己来搭?于是就有了这个博客。它用着 Astro 7 + React 19 + Tailwind v4,彻底脱离模板,而且从内容发布到构建部署,整个流程已经被我调教成全自动的,「现在如果不从白嫖别人主题变成自己手搓博客,那可能就再也不会动手了」,我如是说道。
作为一个之前只知道往 fuwari 里塞 md 文件的用户来说,从零搭博客其实是有陌生感的。于是本篇的主题就是带着各位看看我是怎么一步步搭起这个站点,又如何通过**「构建时能干的绝不留到运行时」**的思想,把各种痛点逐个击破的。
技术地基:Astro 7 + React 19 + Tailwind v4
选这套组合的原因其实就一句话:动画支持度 ok,流畅度 ok,自定义 ok,代码看得懂,超级 ok。
fuwari 慢就慢在它一进首页,左侧栏那堆模块是全部默认加载的,而且 Astro 的平滑布局跟 fuwari 像是用胶水粘在一起,我这点本事根本拆不开,只能赶紧删库跑路。不过好歹它也陪了我一年多,此处默哀一秒。
Tailwind v4 是个意外之喜。它的新引擎跑得飞快,@tailwindcss/vite 直接走 Vite 插件通道,连 postcss 配置都不用折腾了。颜色变量这里我耍了个小聪明:用 CSS custom properties 存了一套自创的「暗夜孟菲斯」调色板,霓虹粉 #ff2e88、青绿 #00f0d0、明黄 #ffe600,然后通过 Tailwind 的 @theme 桥接这些变量,这样以后想换肤只要改几行 CSS 就好。
最大的一个坑:
图.片.优.化!
已经压缩好的 WebP,一旦被 Astro 的
接管,二次压缩后体积反而膨胀,构建时间也翻倍。
解决方案:所有自压缩图片一律放进 public/ 目录,用原生
引用,彻底避开 Astro 的处理管线.
总之,总结就一个字:搬。把一切能提前算完的事情,全都搬到构建时去做,运行时能跑的地方越少越好。
数据与展示解耦:D1 存内容,R2 存图片,手机直接发文
这里其实是在解最开始那几个痛点,尤其是「每次更新文章都得用电脑推 GitHub」这一条,简直是我懒癌路上的最大绊脚石。
我的思路是把「内容存储」和「内容展示」彻底劈开:
- Cloudflare D1 用来存文章(posts 表)和说说(talks 表)的结构化数据,字段就是 title、published、tags、body_md 这些;
- R2 用来存图片素材,通过
blogr2.8765777.xyz 这个公开域名直接走 CDN 访问;
- 本地仓库只存代码和配置,所有业务数据全部踢到云端。
关键在 scripts/sync-d1.mjs 这个脚本。它在 astro build 之前跑,调 Cloudflare D1 的 HTTP API,把 D1 里的 posts 和 talks 拉下来,落地成本地的 src/content/posts/*.md 和 src/content/talks/*.json,然后 Astro 的 content collection 照常构建。这样一来,博客展示层完全不知道数据是从云端来的,它只认本地文件,而本地文件其实是我构建时「造」出来的。
但「改数据」这一步,我压根没打算打开 D1 控制台——那太不优雅了。我选择自己写了个 Android APK,项目名叫 yiyan-blog-update,一个 Capacitor 套壳的 React 应用,我管它叫「博客发布舱」。在 APK 里可以直接写 markdown、传图片、管理友链,提交时直接调 D1 的 REST API 写库,R2 那里走 S3 兼容接口,用 aws4fetch 签名直传图片。
这里有个绕不开的坑:WebView 的 CORS。api.cloudflare.com 和 R2 端点都不给浏览器端任意来源的跨域放行,APK 里的 WebView 说到底也算浏览器。翻看 Capacitor 的文档和相关讨论,发现可以架一个 Cloudflare Worker 当中转站:
- APK 里开关
_useTransferStation 一打,D1 查询走 ${TS_URL}/d1/query,R2 上传走 ${TS_URL}/r2/put/<key>;
- Worker 端拿着真实密钥转发,APK 端只存一个 token,安全得很;
- 更绝的是,原生层还能走
CapacitorHttp.post,完全绕过 WebView 的 fetch CORS 拦截,这就是 Capacitor 给的「外挂」。
ps:于是多写了个 https://cloudflare.8765777.xyz
这样一来,我更新文章的真实流程变成了:
- 手机掏出来,打开「博客发布舱」,写东西、贴图、点发布 → 数据直接写进 D1 和 R2;
- 触发部署(Vercel Deploy Hook 或者每天 0 点 GitHub Actions 定时 CI);
- 构建时
sync-d1.mjs 自动拉最新数据 → 烤进静态页。
电脑?不需要。git push?不需要。D1 控制台?也不需要。手机就是我的后台,一机在手,博客我有。
图片这块我还折腾了一个方案。文章配图都扔在 R2 上,数量一多,每个图单独请求的延迟就很难看。我干脆让 R2 上的图片按月打成 tar 包,客户端首次访问时下载整个 tar,然后手写了一个极简的 tar 解析器——只认普通文件条目,没去折腾那些扩展头——解包后存进 IndexedDB。后续所有图片的 src 全部替换成 blob: URL,几百张图只发一个请求,二次访问直接走本地缓存,加载速度直接从散步变成闪现。
另一个是媒体去重。APK 上传图片前先算 SHA-256,在 D1 的 media_assets 表里按 hash 主键查一遍,已存在就直接复用旧 URL,绝不往 R2 重复传图。这样 R2 不会堆满一模一样的内容,也省了流量。
umami 与 twikoo:把统计变成点赞,把评论挂上云端
这两个玩意儿我以前就用过,部署轻车熟路。
umami 自托管统计的部署流程大概是这样:在 Vercel 上新建项目导入 umami 官方仓库,配一个 Postgres 数据库(我白嫖了 Neon 的免费档),把 DATABASE_URL 填进环境变量,部署后初始化 admin、添加网站,拿到 website_id,然后把 umami 的 script.js 塞进 BaseLayout 的 <head> 里,绑定个子域名 umami.8765777.xyz 就完事。
我把点赞功能做成了「虚拟路由统计」。/like/talk-xxx/ 和 /like/post-xxx/ 是真实存在的静态页,页面里除了 umami 的 script 什么都没有,还被设了 noindex,nofollow。用户点「点赞」按钮时,一个隐藏 iframe 加载这个路由,umami 就会记一次 pageview。于是,umami 表直接变成了我的点赞数据库。构建时 fetch-likes.mjs 调 umami API 批量查询这些路由的 pageview 数,写进 .likes-cache.json,页面静态渲染时直接读缓存显示点赞数,根本不用写后端。
twikoo 评论系统也是类似,Vercel 部署 twikoo 云函数,配 MONGODB_URI(白嫖 MongoDB Atlas 免费档),绑定 twikoo.8765777.xyz,客户端用 TwikooComment.tsx 组件运行时动态注入 twikoo 的 CDN script,然后 window.twikoo.init 一把梭。完全运行时的事,构建时不参与,干净利落。
构建时全线自动化
为了达到blog端接住上面的新设定,创建了4个新build脚本
build:full 的链路是:fetch:likes → sync:d1 → gen:seed-explorer → gen:bg-pool → astro build。
四个附属脚本,编程思想其实就一个词:造。把一切能在构建时干完的活,绝不留到运行时。静态站没有运行时,有的只是构建时这一次机会,那就把算力往死里用。
fetch-likes.mjs:调 umami API 预取点赞数到 cache,运行时页面直接读 JSON,不用等 umami 慢吞吞的接口。
sync-d1.mjs:前面说过了,把 D1 数据落地成本地文件,让 Astro content collection 高高兴兴地构建。
gen-seed-explorer.mjs:读 seed/*.md,这是一个的组件字典,然后生成一个可搜索的 HTML 页面,把散落的文档聚合成一个可检索的产物,可以见组件星图
gen-bg-pool.mjs:扫文章 frontmatter 里 addToBgPool: true 的封面图,合并基础池,输出 .bg-pool.json。说说首屏和广场 banner 直接从这里随机抽。
题外话,这四个脚本跑起来的样子特别像工厂流水线,一个接一个往外吐文件。感觉像是在用另一种方式开工厂。
视觉魔法:niko 挂件、无限画布与 GSAP 叙事
这三个是全站最吃性能的部分,各自用了一种截然不同的渲染策略,差点把我显卡干冒烟。
niko挂件:
niko 挂件是挂在 BaseLayout 里的一个 <canvas>,z-index: 9999,pointer-events: none,全站可见。架构上被我拆成五个文件:
PendantPhysics.ts:绳子物理,多节点 verlet 积分,固定锚点 + 重力 + 约束迭代,REST_LEN 控制绳长;
PendantState.ts:状态机,管 gif 切换、挂壁检测、光标跟随时的眩晕检测(每 100ms 采样一次鼠标速度,鼠标太快 niko 会晕);
PendantRenderer.ts:渲染器,gif 帧解码 + 绘制挂点 + 绳子 + niko 立绘;
DragController.ts:拖拽舵,锚点把手可拖,松手时位置持久化到 localStorage;
EdgeStickZones.ts:挂壁区域收集,视口四边 + 页面内 data-niko-hang 标记元素。
主循环用 requestAnimationFrame,帧 delta 钳制到 maxFrameDeltaSec,防止切后台回来时物理引擎直接爆炸。有意思的是,归色过渡幕显示期间不渲染但物理照常推进,这样 loading 淡出那一瞬间,挂件已经安安静静地垂在那儿,不会突兀地跳一下。
无限广场:
无限画布是广场页的核心。卡片墙可以无限延伸,最左侧是驿站界面,里面有最近更新以及公告和最好玩的召唤台,2D 自由拖拽,带惯性和驿站吸附.性能上有做了这么几件事:
- 每张卡片首次绘制后缓存到独立的离屏 canvas,后续直接
drawImage,跳过文字测量和图片裁剪;
- 拖动中开启
simplified=true 模式,只画图片和背景,松手后再补完整渲染;
- 手机端 DPR 上限限制到 1.5,避免高 DPI 设备渲染过多像素;
- 同一 cover URL 的卡片共享一个
HTMLImageElement,全局图片缓存;
- 行级确定性哈希
hashRow(r),保证同一行起点稳定,拖动时卡片不会乱跳。
驿站吸附的阈值设计也花了些心思:往右拖到视口 92% 区域时进入吸附区,松手自动缓动到完全可见;从吸附区往左拖必须超过 40px 才能离开,防止误触。驿站区域内纵向滑动锁住画布,交给浏览器原生滚动处理。
GSAP:
GSAP 叙事则是首页祈愿之幕用的,ScrollTrigger 加上 pin: true、scrub: 1,6 个阶段串成一条 timeline:前置全屏 CG 淡出 → 双海报从两侧向中心进入 → 五卡交错入场 → 立绘从底部滑入 → 半屏双卡水平进入 + 大字放大 → 终幕立绘缓出 + 主视觉大图。缓动统一用 power4.out,部分阶段弹一下用 elastic.out(1, 0.7)。end: "+=7200" 意味着要滚动 7200px 才能走完整段叙事,invalidateOnRefresh: true 保证 resize 后重新测量,window.addEventListener("load", () => ScrollTrigger.refresh()) 确保图片加载完后再校准一次,不然 pin 高度算错整段就会跳戏。
偷懒到极致:评论构建时内联与每日 CI 自动部署
偷懒是第一生产力,这句话似乎在本博客得到了充分验证。
评论构建时内联原本是设计好的:构建时调 twikoo 的 comment_get API,把每条说说的评论拉下来,内联到静态页里,这样首屏就有评论,不用等 twikoo.js 加载。但 twikoo 云函数得升级到支持 HTTP API 的版本才行,所以 src/lib/twikoo-fetch.ts 现在只是个 stub,返回空数组。架构已经留好了,代码位都占着,就等东风。
每日 CI 自动部署才是真正的懒人福音。.github/workflows/schedule-deploy.yml 这个 workflow,cron 设的 0 16 * * *(UTC 16
就是北京时间 0
),每天准时触发,也可以手动
workflow_dispatch。做的事就一件:
curl -X POST 打 Vercel Deploy Hook。Vercel 收到 hook 就跑
build:full,拉 D1、拉 umami、生成各种 cache、astro build,然后自动部署。
整个链路是:在 D1 改了数据(或者什么都没改)→ 当天 0 点 GitHub Actions 自动触发 → Vercel 重新构建,拉最新数据 → 新版本上线。我什么都不用干,真的什么都不用干,烤鸡真好吃(吧唧吧唧)。
后记
经过这一顿折腾,我的博客终于从一个「模板套壳」变成了一个高度自动化、完全由自己掌控的静态站点。虽然还有不少事情没做——比如暗黑模式下某些组件还得再打磨,移动端某些动画还是有点掉帧——但已经是达到了「掏出手机就能更新,躺床上等它自己部署」的理想状态。
对博客框架最大的感悟就是,不要替博主做决定。fuwari 把所有东西打包成一个大而全的主题,看似方便,可一旦你想跳出它的设定,就会撞得头破血流。而现在的 Astro 组合拳,每一个环节我都能清清楚楚看到它在做什么,想改哪里改哪里。
而这,也是我的第一个非模板自建blog博客.
感谢你的聆听。