这个站到底是怎么搭起来的
聊聊这个个人网站背后的技术栈 —— Next.js 静态导出、Notion 当 CMS、Azure Static Web Apps 托管,以及一个会在构建期烧出撕纸效果照片的小流水线。
这是这个站点上的第一篇正经博客,那就让它来讲讲这个站本身吧。
我的目标其实很简单:一个小巧、快、双语的个人页面,能在手机上随手写、几乎零成本部署、不用我去维护。下面是它最终长成的样子 —— 我选了什么、跳过了什么、还有几个我自己挺得意的小手艺。
一句话版本
一个 完全静态 的站,由 Next.js 14 导出,托管在 Azure Static Web Apps。没有服务器,没有数据库,没有 API。博客内容来自 Notion,在构建期同步。中文文章用 Azure OpenAI 自动翻译成英文,用 content hash 做缓存,所以同一段中文不会被反复消耗 token。首页拼贴的照片在编译期由 sharp 处理成带撕纸边的 PNG。GitHub Actions 把这一切串起来,每 6 小时定时重建一次,让新发布的 Notion 文章不需要 push 就能上线。
技术栈速览
| 层 | 选型 |
|---|---|
| 框架 | Next.js 14 (App Router, output: 'export') |
| 语言 | TypeScript + React 18 |
| 样式 | Tailwind CSS + 一套小巧的设计 token |
| 国际化 | next-intl,路由前缀 (/en/… / /zh/…) |
| 主题 | next-themes 切换暗/亮 |
| 博客内容 | Notion API + notion-to-md |
| 翻译 | Azure OpenAI chat completions,本地缓存 |
| Markdown → HTML | unified + remark + rehype + shiki 高亮 |
| 图像处理 | sharp(只在构建期跑) |
| 3D 地球 | react-globe.gl + three + topojson-client |
| 托管 / CDN | Azure Static Web Apps |
| CI/CD | GitHub Actions |
所有看上去"动态"的东西 —— 博客、翻译、撕纸照片 —— 全都在构建期被冻成了静态文件。浏览器只会看到 HTML / JS / CSS / PNG。
构建流水线
每一次 npm run build 实际跑了三个串行步骤:
sync-notion.mjs—— 把 Notion 里所有Status = Published的页面拉下来,转 markdown,把里面引用的图片下载到public/blog/notion/(Notion 的签名 URL 1 小时就失效,必须固化),可选地用 Azure OpenAI 把中文翻成英文,然后走 unified 管道把 markdown 渲染成带 shiki 高亮的 HTML。产物就一个文件:.cache/blog/posts.json。build-collage.mjs—— 扫public/collage/*.png,应用下面会讲的撕纸效果,输出一个 typed TS 文件,里面有每张照片的位置和尺寸。next build——output: 'export'产出一棵静态的out/,images.unoptimized: true(SWA 不跑 Next 的图像优化),trailingSlash: true是为了 URL 更友好。
伪流程:
npm run build
├─ prebuild: sync-notion.mjs → .cache/blog/posts.json
│ public/blog/notion/*.png
├─ prebuild: build-collage.mjs → public/collage/processed/*.png
│ src/data/home-collage.generated.ts
└─ next build → out/ (静态 HTML/JS/CSS)
Notion 当 CMS,附带一个翻译缓存
Notion 非常适合在手机上写东西,但完全不适合做运行时依赖。我的妥协是:让它做"构建期依赖",而不是"站点运行时依赖"。
对于没改过的中文文章,我不想每次构建都重新翻译一遍(Azure OpenAI 是按 token 收费的)。所以 sync-notion.mjs 会对 title + excerpt + body 算个 SHA-256,把这个哈希当 cache key 存进 cache/translations.json,而且这个文件是 commit 进 git 的。命中缓存就是零 token 消耗。没命中才调模型,把返回的 { title, excerpt, body } 写回缓存,下一次 CI 又会把更新过的缓存提交回 main。
提示词里我让模型保留 markdown 结构、不要去翻代码或 URL、保留原作者的语气。翻译产物在列表上会带一个小小的 AI · EN 角标,让读者知道这是机翻。
撕纸照片,烧在编译期
首页有个纸张拼贴的 hero 区域 —— 一堆类 Polaroid 风格的照片散落在暖米色背景上。那种"撕纸边"(一圈柔和的奶油色边沿渐变到背景色)是完全在构建期生成的,浏览器一点图像处理都不用做。
算法靠 sharp 的形态学操作:
- 提取照片主体的二值 mask。
- 用小半径膨胀这个 mask —— 得到 紧贴主体的内层奶油色"领口"。
- 用大半径膨胀这个 mask —— 得到 撕纸能扩散到的最远范围(外层)。
- 撕边环带 =
外层 AND NOT 内层。只在这一圈环带上叠一层噪声纹理,边界就会是不规则的撕痕,而不是一条平滑曲线。
有个坑值得记一笔:sharp 用的 libvips 底层有个 pipeline 优化器,会偷偷重排 .blur().threshold() 链,把效果搞砸。绕过办法是:blur 还用 sharp 跑,但 threshold 在 JS 里直接操作原始像素 buffer。结果一致,没有意料之外的重排。
自动发现照片 + 类随机散布
每往拼贴里加一张照片就要手动改 JSON 真的太烦了。所以现在脚本把文件系统当作唯一真相源:public/collage/ 里每一张 PNG 都会自动出现在拼贴上,slots.json 退化成可选的"位置覆盖"配置,只在你想钉死某张照片位置时才用。
默认位置我希望是"看起来不像规则布局的随机"—— 不能聚团、不能成行、不能两张挤在同一片画布上。这里用了一个基于 plastic constant(塑性数)的低差异序列。对每张照片的索引 i,默认坐标是:
x = (i + 1) * 0.7548776662466927 mod 1
y = (i + 1) * 0.5698402909980532 mod 1
这个序列在单位正方形里铺得比均匀随机采样更均匀,又没有"黄金比例序列"在二维下会出现的对角条纹伪影。然后再用文件名哈希加一点小抖动,整体看上去就有机得多。
同样这个哈希也驱动旋转角度(±20°)和 z-index,所以同一张照片每次都落在同一个位置 —— 确定性的,但视觉上是杂乱的。
为了避免照片在桌面端那个又窄又固定宽的右栏里超出边界,脚本还会按每张照片实际渲染宽度自动 clamp 它的 x。自动定位的图片会被悄悄推回安全范围;手动钉死位置的图片只会给一条警告。扔一张新图、跑一下脚本就完事 —— 不用算数,不用调参。
一个页面到底怎么加载的
没有服务器,所以运行时的故事很短:
- 浏览器请求 SWA CDN。
- next-intl 的 middleware(在边缘节点解析)根据
Accept-Language把根路径重定向到/en/或/zh/。 - CDN 直接返回那个语言预渲染好的 HTML。
- React hydrate,几个客户端组件醒来 —— 主题切换、语言切换、懒加载的 3D 地球。
- 浏览器从 CDN 拉处理好的 PNG。
没有 API 调用,没有 session。暗/亮模式和语言切换都是纯客户端 state 加路由跳转。
部署
只有一个 GitHub Actions workflow,三种触发条件:
| 触发 | 作用 |
|---|---|
push to main | 正常的代码或内容变更 |
pull_request | SWA 上构建一个预览环境 |
schedule: cron 0 */6 * * * | 拉取新发布的 Notion 文章 |
workflow_dispatch | 手动 "立刻发布" 按钮 |
Azure 那边只有一个资源:一个 Azure Static Web Apps 实例。HTTPS、全球 CDN、预览环境、自定义域名全部都是免费送的。唯一另一个 Azure 资源是 Azure OpenAI deployment,仅在构建期被调用做翻译。
顺便聊聊域名
域名本身是一段独立的小插曲。我想要一个干净的 apex(jiaqihuang.fyi),而不是挂在
*.azurestaticapps.net 下面那串长长的子域,听起来很简单 —— 直到撞上每个静态站站长
迟早都会撞的那堵墙:DNS 规范不允许在 apex 上配 CNAME,可 Azure Static Web Apps
偏偏要求把 apex 指向一个像 agreeable-mushroom-073957f00.7.azurestaticapps.net
这样的主机名。大多数注册商对这件事的态度就是耸耸肩,劝你"要不你用 www. 子域吧"。
最后选了 Porkbun,原因很具体:它原生支持 ALIAS 记录
(有些注册商管它叫 ANAME)。ALIAS 对外看着像 A 记录,但解析的时候像 CNAME 一样去
追后面的主机名 —— 正好就是把光秃秃的 apex 指向托管主机所需要的东西。.fyi 第一年
大概 5.66 美元,SSL 由 Azure 自动签发并自动续期,上线的总开销不到一杯咖啡。
DNS 最终就三条记录:
ALIASapex →agreeable-mushroom-073957f00.7.azurestaticapps.netTXT_dnsauth→ Azure 在 Add custom domain 时给你的那段验证 token- URL Forwarding
www→https://jiaqihuang.fyi,301 永久跳转 + Wildcard + Include path
www 这条值得多说一句。直觉做法是把 www.jiaqihuang.fyi 当作第二个自定义域绑到
Azure 上,让 SWA 同时服务两个域,再在 staticwebapp.config.json 里写一条
www → apex 的重定向规则。问题在于:SWA 的路由规则根本不支持基于 Host 头匹配,
应用层做不了这个重定向。还不如让 Porkbun 直接在 DNS 层 301 干掉 —— Porkbun 的 URL
Forwarding 顺手会给 www 也签一张 Let's Encrypt 证书,整个过程 Azure 完全感知
不到 www 的存在。
最后一个小坑:现在在 Azure Portal 里创建 Static Web App,旧的 "Next.js (Static
HTML Export)" 这个 build preset 已经被下架了。正确选法是 Build Presets → Custom,
App location: /、Output location: out,靠 next.config.mjs 里的 output: 'export'
触发静态导出。
一些我会再做一次的取舍
- 选静态导出而不是 SSR。 SWA 的 Functions 后端会带来冷启动和额外的运维负担,而真正"看起来动态"的东西(博客、翻译、图像处理)都能在编译期算掉。
- 用 Notion 而不是 git 里的 MDX 仓库。 能在手机上写、排版直观,对我比"内容必须躺在 git 里"的纯粹性更重要。代价是构建期多一个网络依赖,而且降级路径很干净(Notion 不可达时站点照样能构建)。
- 定时重建而不是 webhook。 个人博客 6 小时的新鲜度完全够用,而且没有任何东西需要维护或轮换密钥。需要"立刻发"的话手动 dispatch 一下就行。
- 图像处理放在编译期而不是运行时。 让浏览器跑 sharp 那套形态学操作会卡主线程,特别是在移动端。烧一次,永远复用。
- 把
cache/translations.jsoncommit 进 git。 这是一个成本杠杆。缓存 key 是 content hash,所以改一篇文章只会让那一篇失效,不会株连其他。
接下来想做什么
剩下的 backlog 不算多,基本都是视觉层面:一个正经的相册页、可筛选的项目列表、地球上更多旅行打点、一个像样的 RSS。不过骨架我觉得已经对了 —— 在手机上写一篇,什么都不用 push,明天早上 build 就自己上线了,而且是中英双语。
如果你对某一块特别好奇 —— libvips 重排那段绕路、翻译用的 prompt、或者 plastic constant 散布的数学 —— 告诉我,我后面单开一篇细讲。
分享这篇文章