惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

N
Netflix TechBlog - Medium
I
InfoQ
Engineering at Meta
Engineering at Meta
Jina AI
Jina AI
Recent Announcements
Recent Announcements
T
The Blog of Author Tim Ferriss
P
Proofpoint News Feed
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
D
Docker
Microsoft Security Blog
Microsoft Security Blog
宝玉的分享
宝玉的分享
Last Week in AI
Last Week in AI
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
GbyAI
GbyAI
博客园 - Franky
博客园 - 聂微东
Microsoft Azure Blog
Microsoft Azure Blog
博客园 - 叶小钗
酷 壳 – CoolShell
酷 壳 – CoolShell
B
Blog RSS Feed
WordPress大学
WordPress大学
MyScale Blog
MyScale Blog
月光博客
月光博客
罗磊的独立博客

定の栈

AI 写得出功能,写不好 Dockerfile:Next.js 自托管踩坑记录 多个 AI 一起写代码时,我给它们划的边界 9 个 commit,没有一个人类作者:AI 独自写完一个项目之后 Next.js 渲染模式排查笔记:force-dynamic、hydration 与构建期没有数据库 让 Claude 少写错数据层:Prisma、Drizzle 和几条结构化做法 我的 CLAUDE.md 只有一行:约定文件到底该写多少 macOS Tahoe 如何用自己的视频做动态壁纸(替换 Aerial 方法) Podman Compose 常用命令速查 创建 Swap 文件 Cursor 2025 Hugo 中文阅读时间计算模版 手动重加载不蒜子计数 入坑舞萌 DX 历时两月终抵 w0 与首鸟加 多邻国 600 天连胜 原神五周年纪念 Asuna 成年生日 告别绝区零 站长18周岁啦! 一位动漫迷的追番日志与热情之旅 起飞日志 Reflector 镜像列表更新常用命令与配置文件 个人自用 rsync 文件同步常用命令 历时千日原神,深渊终抵满星 Arch Linux 个人常用命令记录 《三体III:死神永生》 《三体II:黑暗森林》 《千恋*万花》与现代物理学概念奇妙碰撞后产生出的糟糕想法 Minecraft 15 周年骨折价补票入正 网页添加 Live2D 看板娘 记第二次清醒控梦体验
同一个月,把主页搬出 Next.js,又把 Hugo 站搬进来
Asuna · 2026-07-21 · via 定の栈

最近同一个月里干了两件方向完全相反的事——把个人主页从 Next.js 搬去了 Vite,又把一个 Hugo 静态站整个搬进了 Next.js…两边都踩了点坑,趁还记得记一下,省得下次又忘了~

搬走的那个

home 这个仓库是我的个人主页,栈的演进链路是这样的:

commit日期事件
————Nuxt 4 / Vue 3 / Pinia
412b2582026-04-21 03:46重写成 Next.js 15 静态导出,新代码放 next-app/public 用 symlink 和 Nuxt 共享
0e83f8c2026-04-21 14:12下线 Nuxt,Next 项目 flatten 到根目录
081e5b42026-04-21 14:43升 Next 16 + Tailwind 4 + TypeScript 6
30755562026-05-14 07:50迁到 Vite 7

时间点还挺好笑的:下线 Nuxt 到升 Next 16 只隔了 31 分钟,同一个会话里连做两件大事;结果 22 天后就把其中一件推翻了…冲动是魔鬼。

迁 Vite 之前我数了一下:

# 1. src 下所有 tsx
find src -name "*.tsx" | wc -l          # 20
# 2. 其中带 use client 的
grep -rl "use client" src --include="*.tsx" | wc -l   # 19

20 个 tsx,19 个开头带 "use client"…唯一那个 Server Component 是 src/app/layout.tsx,它导出 metadataviewport、配了 next/font 的 Inter、挂了三个 preconnect、渲染 html/body 外壳和 Toaster——干的事不少,但没一件是非 RSC 不可的。

再看 output: 'export'。这个站从 Next 15 开始就一直是静态导出,迁走前的 next.config.ts 长这样:

import type { NextConfig } from "next";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const nextConfig: NextConfig = {
  output: "export",
  reactStrictMode: true,
  trailingSlash: false,
  images: { unoptimized: true },
  turbopack: {
    root: dirname(fileURLToPath(import.meta.url)),
  },
};

export default nextConfig;

选了它,SSR / ISR / Route Handler / middleware / next/image 优化就全关了,剩下的就是文件路由、Metadata API、next/font 三样。对一个单页主页来说:路由用不上,meta 写死在 index.html 里就行,字体换系统栈也没人看得出来…那还留着干嘛呢。

迁完之后的账

3075556 那一笔的 stat 是 41 files changed, 902 insertions(+), 1862 deletions(-),净减 960 行。

配置量对比更直观。删掉的是 next.config.ts(15) + src/app/layout.tsx(69) + next-env.d.ts(6) + serwist.config.js(18),共 108 行;换来的是 index.html(45) + src/main.tsx(23) + vite.config.ts(30),共 98 行。行数差不多,但后面这些我一眼能看懂。

大部分组件的 diff 就是删掉开头那两行:

--- a/src/components/app-footer.tsx
+++ b/src/components/app-footer.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import { useEffect, useState } from "react";

那会儿的 vite.config.ts 是 30 行,后来陆续删到只剩 14 行:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  server: {
    port: 3000,
  },
  build: {
    target: "es2022",
    sourcemap: false,
  },
});

最能说明问题的是 Dockerfile 的运行阶段:

FROM node:22-alpine
RUN npm install -g serve
WORKDIR /app
COPY --from=builder /app/dist ./public
EXPOSE 12445
CMD ["serve", "public", "-l", "12445"]

连 Node 都不用跑,就一份静态文件挂着,运行阶段干净得有点无聊…

顺带砍掉的一条因果链

字体这条链跨了两笔提交。迁 Vite 那笔(3075556)里 next/font/google 的 Inter 用不了了,换成本地 @fontsource 包,中文字体一下多出约 5MB 的 woff2;十分钟后的 ae004b3 才想明白——当初装 PWA 的主要理由就是缓存这 5MB,那干脆改用系统字体栈(苹方 / 雅黑 / Roboto)算了,于是 @fontsource 和 PWA 一起删掉。

说「删掉」也不太准确啦…翻 log 才发现 PWA 早在 5321a54 就删过一次,后来又装回来了,这是第二次删。所以这压根不是什么干净的决策,是来回折腾了两轮才消停。

而且删 PWA 不是删代码就完事的。老访客浏览器里还装着旧的 Service Worker,得留一个墓碑 SW 让它自己注销自己:

// Tombstone service worker.
self.addEventListener("install", () => self.skipWaiting());

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const names = await caches.keys();
      await Promise.all(names.map((n) => caches.delete(n)));
      await self.registration.unregister();
      const clients = await self.clients.matchAll({ type: "window" });
      for (const client of clients) client.navigate(client.url);
    })(),
  );
});

清缓存、注销、再让已打开的页面重新导航一次…删功能比删代码麻烦多了。

没做完的收尾

然后是不太光彩的部分。到今天为止:

  • README.md 还写着 Next.js 15 + Serwist + Tailwind 3 + NEXT_PUBLIC_ 前缀,实际是 Vite 7 + Tailwind 4 + VITE_——每句技术描述都是错的
  • .github/workflows/build.yml:53 的 artifact path: out,而 Vite 产物在 dist,CI 上传的东西早就空了
  • components.json 里的 CSS 路径指向 src/app/globals.css,文件早搬到 src/globals.css
  • app-footer.tsx:38suppressHydrationWarning 成了死代码——现在压根没有 hydration

最好笑的是 0e83f8c 那次下线 Nuxt,commit body 里明明列过一份完整的「换框架要连带改的 5 个地方」:Dockerfile、vercel.json、GitHub Actions artifact 路径、.dockerignore、README…清单一个字没错,下一次迁移就是没照着做,包括我自己也没看一眼。

再补一条:e6b5d81 清理时发现 @radix-ui/react-slotclass-variance-authority 这两个 shadcn 脚手架装的依赖,从 4-21 装进来到 5-14 删掉,23 天里零 import(项目里压根没有 button.tsx);.vscode 里还推荐着 Vue.volar,两个框架之前的残留了。加依赖一秒钟,删依赖等三周…

搬进来的那个

另一边是 vns,一个 Galgame 资源站。同一个仓库里能看到完整的三段式:old 分支是 Hexo 8.1.1 + NexT,dev/beast 是 Hugo Extended,而 vns 这边 .claude/launch.json 里的 dev server 直接指向了隔壁 vns-next 目录跑 bun run dev——两个仓库并排对照着迁。

搬进 Next.js 的理由有三条。

一是静态站没有服务端,API key 只能明文写在前端 JS 里。当时 main.js 里就这么躺着一个 One API 的 key:

const API_URL = "https://example.com/v1/chat/completions";
const API_KEY = "sk-xxxx";  // 就这么明文躺着

纯静态站要做 AI 功能就只有这一条路,没得选。同样的东西在 Next.js 里可以塞进 Route Handler,key 留服务端就完了。另外,这种进过公开仓库的 key 记得去轮换掉,改代码是不算数的哦…

二是这站的「数据库」就是 data/ 下的 6 个 JSON。广告位、友链、下载源、汉化组,全是构建期读进去的伪数据库。改一条数据要全站重建、再重推一次部署分支,而部署分支上是 27.9MB / 1832 个文件…为了改一行 JSON。

三是为了做出「动态感」堆了两千多行客户端 JS。main.js 拆分前 2347 行,塞了 swup 切页、懒加载、计数动画、localStorage 开关、SSE 流式请求。后来拆成 8 个 ES module,主文件降到 613 行,但拆的过程里踩的坑挺典型(下面这几笔都在 dev 分支上):

  • 4842e804e:拆成 ES module 后,VALINE_CONFIG 这些顶层变量留在了原文件里。模块顶层作用域不跨文件,评论组件从来没初始化过,还是用户报上来的
  • 187ad71b4:一个 try/catch 包住了所有 init,中间一个崩了后面全不执行。用户报「评论和 TOC 都坏了」,其实 TOC 纯属被连累。改成每个 [name, fn] 独立 catch
  • d83c2801amain.js 作为 render-blocking 脚本先于 DOM 跑,getElementById 返回 null;swup 切页又让全局 click 重复绑定,最后靠一个 window._fireworkBound 标志位兜底

这几个全是组件生命周期白送的东西,自己手写就得一个个踩。

还有一条容易忘的,URL 得原样保住…Hexo 的 abbrlink 短链(crc16/dec,permalink 是 p/:abbrlink/)变成 Hugo 的 slug,再变成新站的 work.id/p/{id}/ 这条老链一路保了三代。连尾斜杠都不能动——Artalk/Valine 的历史评论是按 /p/{id}/ 这个带斜杠的路径存的,少一个字符就等于丢评论。为此 vns-next 那边还得把框架内置的尾斜杠重定向关掉自己接管,这段拉扯我在讲约定文件那篇里贴过完整注释。

最后提个中间态。bbps 那个站也是 output: 'export'next.config.ts 全文才 12 行,实时数据干脆靠客户端轮询绕过去:

useEffect(() => {
  fetchStats()
  const id = setInterval(fetchStats, 30000)
  return () => clearInterval(id)
}, [fetchStats])

30 秒打一次外部 API,够用了…静态壳 + 客户端轮询,省事得很。

搬来搬去折腾一个月,其实就是在问同一句话:这站有没有非放服务端不可的东西。琢磨下就知道了,没啥玄学的。溜了溜了~