最近同一个月里干了两件方向完全相反的事——把个人主页从 Next.js 搬去了 Vite,又把一个 Hugo 静态站整个搬进了 Next.js…两边都踩了点坑,趁还记得记一下,省得下次又忘了~
搬走的那个
home 这个仓库是我的个人主页,栈的演进链路是这样的:
| commit | 日期 | 事件 |
|---|---|---|
| —— | —— | Nuxt 4 / Vue 3 / Pinia |
412b258 | 2026-04-21 03:46 | 重写成 Next.js 15 静态导出,新代码放 next-app/,public 用 symlink 和 Nuxt 共享 |
0e83f8c | 2026-04-21 14:12 | 下线 Nuxt,Next 项目 flatten 到根目录 |
081e5b4 | 2026-04-21 14:43 | 升 Next 16 + Tailwind 4 + TypeScript 6 |
3075556 | 2026-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 # 1920 个 tsx,19 个开头带 "use client"…唯一那个 Server Component 是 src/app/layout.tsx,它导出 metadata 和 viewport、配了 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 /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的 artifactpath: out,而 Vite 产物在dist,CI 上传的东西早就空了components.json里的 CSS 路径指向src/app/globals.css,文件早搬到src/globals.css了app-footer.tsx:38的suppressHydrationWarning成了死代码——现在压根没有 hydration
最好笑的是 0e83f8c 那次下线 Nuxt,commit body 里明明列过一份完整的「换框架要连带改的 5 个地方」:Dockerfile、vercel.json、GitHub Actions artifact 路径、.dockerignore、README…清单一个字没错,下一次迁移就是没照着做,包括我自己也没看一眼。
再补一条:e6b5d81 清理时发现 @radix-ui/react-slot 和 class-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]独立 catchd83c2801a:main.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,够用了…静态壳 + 客户端轮询,省事得很。
搬来搬去折腾一个月,其实就是在问同一句话:这站有没有非放服务端不可的东西。琢磨下就知道了,没啥玄学的。溜了溜了~
