返回列表
13 分钟阅读#nextjs #azure #notion

这个站到底是怎么搭起来的

聊聊这个个人网站背后的技术栈 —— 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 → HTMLunified + remark + rehype + shiki 高亮
图像处理sharp(只在构建期跑)
3D 地球react-globe.gl + three + topojson-client
托管 / CDNAzure Static Web Apps
CI/CDGitHub Actions

所有看上去"动态"的东西 —— 博客、翻译、撕纸照片 —— 全都在构建期被冻成了静态文件。浏览器只会看到 HTML / JS / CSS / PNG。

构建流水线

每一次 npm run build 实际跑了三个串行步骤:

  1. sync-notion.mjs —— 把 Notion 里所有 Status = Published 的页面拉下来,转 markdown,把里面引用的图片下载到 public/blog/notion/(Notion 的签名 URL 1 小时就失效,必须固化),可选地用 Azure OpenAI 把中文翻成英文,然后走 unified 管道把 markdown 渲染成带 shiki 高亮的 HTML。产物就一个文件:.cache/blog/posts.json
  2. build-collage.mjs —— 扫 public/collage/*.png,应用下面会讲的撕纸效果,输出一个 typed TS 文件,里面有每张照片的位置和尺寸。
  3. next build —— output: 'export' 产出一棵静态的 out/images.unoptimized: true(SWA 不跑 Next 的图像优化),trailingSlash: true 是为了 URL 更友好。

构建流水线示意图:两个 prebuild 脚本并行处理后馈入 next build

伪流程:

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 的形态学操作: 撞纸算法四阶段示意:主体掩码 → 小半径膨胀 → 大半径膨胀 → 环带营地上叠加噪声

  1. 提取照片主体的二值 mask。
  2. 用小半径膨胀这个 mask —— 得到 紧贴主体的内层奶油色"领口"
  3. 用大半径膨胀这个 mask —— 得到 撕纸能扩散到的最远范围(外层)
  4. 撕边环带 = 外层 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。自动定位的图片会被悄悄推回安全范围;手动钉死位置的图片只会给一条警告。扔一张新图、跑一下脚本就完事 —— 不用算数,不用调参。

一个页面到底怎么加载的

没有服务器,所以运行时的故事很短:

  1. 浏览器请求 SWA CDN。
  2. next-intl 的 middleware(在边缘节点解析)根据 Accept-Language 把根路径重定向到 /en//zh/
  3. CDN 直接返回那个语言预渲染好的 HTML。
  4. React hydrate,几个客户端组件醒来 —— 主题切换、语言切换、懒加载的 3D 地球。
  5. 浏览器从 CDN 拉处理好的 PNG。

没有 API 调用,没有 session。暗/亮模式和语言切换都是纯客户端 state 加路由跳转。

部署

只有一个 GitHub Actions workflow,三种触发条件:

触发作用
push to main正常的代码或内容变更
pull_requestSWA 上构建一个预览环境
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 最终就三条记录:

  • ALIAS apex → agreeable-mushroom-073957f00.7.azurestaticapps.net
  • TXT _dnsauth → Azure 在 Add custom domain 时给你的那段验证 token
  • URL Forwarding wwwhttps://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 → CustomApp location: /Output location: out,靠 next.config.mjs 里的 output: 'export' 触发静态导出。

一些我会再做一次的取舍

  • 选静态导出而不是 SSR。 SWA 的 Functions 后端会带来冷启动和额外的运维负担,而真正"看起来动态"的东西(博客、翻译、图像处理)都能在编译期算掉。
  • 用 Notion 而不是 git 里的 MDX 仓库。 能在手机上写、排版直观,对我比"内容必须躺在 git 里"的纯粹性更重要。代价是构建期多一个网络依赖,而且降级路径很干净(Notion 不可达时站点照样能构建)。
  • 定时重建而不是 webhook。 个人博客 6 小时的新鲜度完全够用,而且没有任何东西需要维护或轮换密钥。需要"立刻发"的话手动 dispatch 一下就行。
  • 图像处理放在编译期而不是运行时。 让浏览器跑 sharp 那套形态学操作会卡主线程,特别是在移动端。烧一次,永远复用。
  • cache/translations.json commit 进 git。 这是一个成本杠杆。缓存 key 是 content hash,所以改一篇文章只会让那一篇失效,不会株连其他。

接下来想做什么

剩下的 backlog 不算多,基本都是视觉层面:一个正经的相册页、可筛选的项目列表、地球上更多旅行打点、一个像样的 RSS。不过骨架我觉得已经对了 —— 在手机上写一篇,什么都不用 push,明天早上 build 就自己上线了,而且是中英双语。

如果你对某一块特别好奇 —— libvips 重排那段绕路、翻译用的 prompt、或者 plastic constant 散布的数学 —— 告诉我,我后面单开一篇细讲。

分享这篇文章

留言