博客

把 Pi Web 从 Next.js 搬到 vinext:一次构建管线的迁移实录

构建从 28 秒降到 4.8 秒,仓库里不再躺着 695 MB 的缓存目录。但浏览器下载的字节数,一秒都没少——这篇把两边都测给你看。

先说结论,因为图表更有意思:这次搬迁赢的是开发和发布的流程,不是用户的网速。构建时间快了将近 6 倍,发布物体积小了四成,磁盘上那个 695 MB 的缓存目录彻底消失;而打开页面时浏览器真正下载的东西,迁移前后是同一个数量级——我用 WebKit 实测过,差距在 1% 以内。

这篇文章记录我把自己的一个项目从 Next.js 16 搬到 vinext 的完整过程:为什么要搬、vinext 是什么、中间踩了哪些坑、以及最后那一堆实测数字。所有数字都是在这台机器上现测的,不是从 release note 里抄的。

01缘起:一次 28 秒的构建#

Pi Web 是我维护的一个开源项目:pi 编程智能体的本地 Web 界面。它和 pi 共用同一套配置和会话文件(就在 ~/.pi/agent 里),所以在浏览器里你可以浏览和继续之前的对话、运行 agent 回合、配置模型和 MCP 服务器、顺便看看项目里的文件和 Git 改动。服务端跑在本机,界面是个 React 单页应用。

它原本是个标准的 Next.js 16 应用,而我这边是一条 Bun 优先的分支。问题出在每次发布前的那一次构建上:

bash · 迁移前的每次发布
$ time bun run build
real    27.97s
user    50.23s
$ du -sh .next
727M    .next        # 其中 695M 是 webpack 的 cache

28 秒听起来还能忍,但它是每次改一行代码都要付的钱,而且那 695 MB 的缓存目录会一直躺在仓库里,CI 上每次都是全新的 checkout,等于每次都按冷构建的价格结算。更别说 Next 16 在我们这个项目上还只能用 webpack(Turbopack 在我们依赖的某个原生模块上有问题),这玩意儿在 Next 16 里已经是「legacy 但还能用」的状态。

所以当我知道 Cloudflare 做了 vinext 之后,就决定试试。

02vinext 是什么#

vinext 是 Cloudflare 开源的一个项目,目标一句话能说完:让 Next.js 的 App Router 代码跑在 Vite 上。

它不是又一个元框架,也不是让你重写页面。你的 app/ 目录、路由约定、route.ts 里的 GET/POST 导出、next/link、Server Component——全都保持原样。变的是底下的引擎:

next dev→ vinext dev· next build→ vinext build· next start→ vinext start

配置上,它把 Next 的配置接过来改名:next.config.ts 变成 next-config.ts(导出还是一个 NextConfig 对象,类型直接从 vinext 里 import),再在 vite.config.ts 里作为插件引入。我这边改完是这样的:

ts · vite.config.ts
import { defineConfig } from "vite";
import vinext from "vinext";
import { nextConfig } from "./next-config.ts";

export default defineConfig({
  plugins: [vinext({ nextConfig })],
  // 我们项目自己的两个补丁,下面会讲
  build: { target: "safari16.2" },
});

底层是 Vite 8 + Rolldown。Rolldown 是 Rolldown 团队用 Rust 写的打包器,接口对齐 Rollup,目标是替代 webpack/esbuild 那一层——所以「快到不正常」这件事是有来处的,不是魔法。另外它还自带一个兼容性检查命令 vinext check,会扫描你的代码里用到的 Next 特性,告诉你哪些不支持。

值得说明

vinext 做的是「Next.js 兼容层」,不是「Next.js 的替代品」。它不实现 Next 的全部能力,也不打算让你迁移到 Cloudflare Workers 才能用——本地 vinext start 就是一个普通的 Node/Bun 生产服务器。对我们这种「一个本机 Web UI」的项目,正合适。

03迁移过程:代码几乎没动,坑都在边上#

业务代码的改动小得出乎意料。API 路由里的 NextRequest、NextResponse 改成从 vinext/shims/server 导入,一共 62 处,机械替换;proxy.ts、PWA 的 sw.js、静态资源都没动。真正花时间的是下面这三件事,它们全都不是 vinext 的问题,但都会在迁移后第一次暴露出来。

坑一:dev 下所有写请求都变成了 GET

迁完之后我第一次在 dev 里发消息,前端所有的 POST 都变成了 GET,界面直接瘫掉。我一开始怀疑 vinext,写了最小复现:一个非原生的 Request-like 对象加上 init 传给 new Request(),Bun 会把 method 丢掉。

根因不在 vinext

这是 Bun 的 bug(oven-sh/bun#42759):new Request(input, init) 在 init 没有 method 时,没有回落到 input 上的 method。vinext 的 Node 适配器正是这么构造请求的,Node 的 undici 会抛错从而走重建逻辑,Bun 不会,于是静默降级成 GET。修复在 Bun 的 PR #41994 里,被 Jarred Sumner 的 HTTP 整合大 PR #46528 吸收。我装了那个 PR 的 CI 构建,问题消失。

这个坑的教训是:换构建工具的时候,会把运行时里一直存在但没被触发的 bug 激活。Next 的 dev server 自己不这么构造请求,所以这个 bug 在我们的代码里躺了很久都没人发现。

坑二:一个文件锁让整个侧栏状态写入 500

第二个坑更朴实。侧栏的置顶、归档、项目排序要写一个 JSON 状态文件,用了 proper-lockfile 加文件锁。迁移后所有写入都返回 500,错误信息是 options.fs.mkdir is not a function。

原因是 Vite 的模块运行器会给 dev server 的环境注入自己的一份 fs 对象——它没有 mkdir。而 proper-lockfile 是 CJS,它 require("graceful-fs") 时拿到的是被加工过的那份。修法是把这类包声明成 serverExternalPackages,让 Node 原生加载它们:

ts · next-config.ts
serverExternalPackages: [
  "node-pty", "undici", "web-push",
  // proper-lockfile 是 CJS,会对自己 require 到的 fs 调 mkdir;
  // Vite 模块运行器注入的 fs 没有这个方法,于是每次写入都 500。
  "proper-lockfile", "graceful-fs",
],

坑三:一个动态 import 让 WebKit 报错

第三个坑是浏览器测试跑出来的。有一处 await import("@/lib/tool-presets"),生产构建下它会被拆出一个独立的 chunk;页面在它还没下载完的时候发生了导航,WebKit 直接把这次 import 判失败,报 Importing a module script failed。改成静态导入就好——那个模块本来就没有循环依赖的问题,动态 import 只是个多余的习惯。

方法论

这三个坑有一个共同点:只有把浏览器端到端测试跑起来才会发现。我这次是先把 bun test、tsc、eslint、vite build 全跑绿,然后用 Playwright 的 WebKit 把 43 项检查在 dev 和 vinext start 两种模式下各跑一遍,第二个和第三个坑才浮出来。

04实测对比#

以下所有数字都在同一台机器(Apple Silicon macOS)上现测,用 /usr/bin/time -p 计时间。构建分「冷」(先删掉产物目录)和「热」(紧接着再构建一次)两种;运行时分 Bun 和 Node,因为发布走的是 Node。每张图下面都藏着一份原始数据表,点开就能核对。

构建时间

构建时间 四种组合 · 单位秒 · 悬停查看精确值与差距

Next.js 16 使用 webpack 构建(本项目下 Turbopack 有兼容问题)。热构建的 Next 数字依赖 .next/cache 里约 695 MB 的持久化缓存;Vite 不留构建缓存,所以它的冷热两次几乎一样——这也是下面磁盘那张图要讲的事。

原始数据
运行时冷构建热构建冷构建倍率热构建倍率
Bun 1.4.427.97 → 4.81 s12.35 → 4.05 s5.8×3.0×
Node 26.731.33 → 6.00 s17.81 → 5.16 s5.2×3.5×

把这张图读出来就是:Bun 下冷构建 27.97 秒 → 4.81 秒,快 5.8 倍;热构建 12.35 秒 → 4.05 秒,快 3.0 倍。换成 Node 也是同一个量级(5.2 倍 / 3.5 倍)。

如果想直观看一下差距,下面这个按钮会按真实时长比例重放一次「构建竞速」——左边那条跑完的时间里,右边那条已经跑完五轮半:

构建竞速 动画时长按真实比例压缩(约 8:1),读数按真实秒数
Next.js 16 · webpack27.97 s
vinext · Vite 8 + Rolldown4.81 s
0.00 s Bun · 冷构建

磁盘上的产物

这是我最喜欢的一张图,因为它同时说明了两件事:Vite 不留构建缓存,以及 Next 那 695 MB 到底是些什么。上半部分是同一线性比例尺下的两根条——dist 之所以只剩一条细线,就是它本来的样子;下半部分按同样颜色放大,把两边的构成拆开,免得细到看不见。

构建产物占用的磁盘空间 同一线性比例尺 · 长度 = 大小 · 单位 MB

去掉缓存后,真实产物是 32 MB → 19 MB,小 41%。服务端那部分从 22 MB 降到 10 MB。客户端静态资源两者接近(9.0 MB vs 8.8 MB)。

原始数据
目录合计构建缓存服务端客户端/其他
.next(Next.js 16)727 MB695 MB22 MB9 MB static + 3 MB trace + 0.9 MB types
dist(vinext)19 MB010 MB8.8 MB client

发布包

NPM TARBALL
5.86MB
← 6.85 MB · −14%
解包后体积
18.7MB
← 32.2 MB · −42%
文件数
691
← 775 · −11%
客户端 JS 分片
108
← 164 · −34%

四个指标单位不同,没法放在同一根轴上,所以下面用哑铃图:每个指标各自归一化到 0–100%,左点是迁移前、右点是迁移后,连线的长度就是差距。悬停可以看真实数值。

发布包:迁移前 → 迁移后 每行按各自量程归一化 · 悬停查看真实数值

npm tarball 用 npm pack --dry-run --json 的 size 与 unpackedSize;文件数与分片数直接数产物目录。

原始数据
指标迁移前迁移后变化
npm tarball6.85 MB5.86 MB−14%
解包后体积32.2 MB18.7 MB−42%
文件数775691−11%
客户端 JS 分片164108−34%

浏览器第一次打开,下载了多少

现在到了泼冷水的部分。我用 Playwright 的 WebKit 打开生产服务器的首页,把同源请求全部累加,并按类型拆开——因为「总量没变」和「构成没变」是两回事,拆开看才知道差别在哪:

首屏下载量构成(同源,WebKit 实测) 堆叠柱 · 单位 KiB · 悬停查看各段

两次测量各跑三遍,数字稳定。请求数 29 → 25,因为 Vite 把 chunk 合并得更少更大(一个 2.7 MB 的主分片,对比 Next 的 1.1 + 1.0 + 0.3 + 0.3…);但总字节数没变。图例可以点掉某一段来对比。

原始数据
类型Next.js 16vinext变化
JavaScript3435 KiB3469 KiB+1.0%
CSS220 KiB223 KiB+1.4%
WOFF2 字体49 KiB49 KiB0%
HTML / API19 KiB25 KiB+32%
合计(raw)3722 KiB3767 KiB+1.2%
合计(gzip)1087 KiB1100 KiB+1.2%
请求数2925−14%
为什么没变小

因为首屏下载的是同一份代码:React、整个应用、依赖库。换打包器改变的是这些代码怎么被切分、怎么被压缩传输,不改变它们有多少。真正能让这个数字下降的是拆包、按需加载和删依赖——那是另一篇文章的事。

还有一项是变慢的

为了不把这篇写成软文,这一项也得放上来:生产服务器就绪的时间。我各跑了三次——第一遍我还只跑了一次,得出「慢 2.5 倍」,写完自己都心惊,补跑才发现 vinext 的首次启动撞上了冷缓存。稳定下来是 Next 0.48 秒、vinext 0.56–0.66 秒,慢大约 0.1 秒。日常是常驻进程,这点差别无所谓;但它是全文唯一一项变慢的指标,得记在这。

生产服务器就绪时间 中位数相差 0.18 秒(1.4×)· 三次独立启动

第一次启动明显更慢(Next 1.98 s、vinext 1.23 s),之后回落到 0.5 秒上下;文章写的是后两次的稳定值。这也是我一开始只跑一次会得出「慢 2.5 倍」的原因。

05一张对照表#

项目迁移前(Next.js 16)迁移后(vinext)
构建命令next build --webpackvite build
开发命令next devvite dev
生产服务next startvinext start
构建耗时(Bun / 冷)27.97 s4.81 s
构建耗时(Bun / 热)12.35 s4.05 s
产物目录.next/(727 MB,含 695 MB 缓存)dist/(19 MB)
开发状态目录.next-dev/.vinext/
配置文件next.config.tsnext-config.ts + vite.config.ts
发布包(tarball)6.85 MB5.86 MB
首屏下载(raw / gzip)3722 / 1087 KiB3767 / 1100 KiB
生产服务器就绪(3 次中位数)0.48 s0.56 s

06测量方法#

免得这些数字不可复现,把方法写清楚。全部在同一台 Apple Silicon Mac 上完成,两次构建之间没有其他负载:

bash · 测量脚本
# 构建时间:冷 = 先删产物目录,热 = 紧接着再来一次
rm -rf dist .next
/usr/bin/time -p bun run build      # 或 npm run build(Node)

# 磁盘与发布包
du -sh .next dist
npm pack --dry-run --json           # size / unpackedSize / 文件数

# 首屏下载量:Playwright WebKit,累加同源 response body
page.on("response", async (r) => {
  if (new URL(r.url()).origin !== origin) return;
  bytes += (await r.body()).length;
});

运行时的版本:Bun 1.4.4-canary.1(带上面提到的 Request 修复),Node 26.7.0。每一项测量至少跑两遍,文章中写的是稳定值;Vite 的构建时间波动在 ±10% 以内,Next 的冷构建波动更小(它的耗时主要由 CPU 决定)。

07写在最后#

vinext 推荐吗?我的答案是:看项目,但我自己是推荐的。如果你也是一个人维护一个本机或小规模部署的 Next.js 应用,并且有测试兜底,那每次构建从半分钟变成五秒,是会改变你写代码节奏的事——我现在敢在发布前反复重建了。反过来,如果你的项目在首屏性能、SEO 或者边缘部署上有硬指标,它帮不上忙:我实测首屏字节数一模一样,该做的拆包、缓存、流式渲染一点都少不了。

最有意思的发现是:换构建工具会把运行时里潜伏的 bug 激活。dev 下所有 POST 变成 GET 那个坑,我第一反应是 vinext 写错了,还给它提了 issue。维护者在自己的 Node 上复现不了,直接把 issue 关了。回头写最小复现才发现,问题是 Bun 的 new Request(input, init) 在 init 没有 method 时不回落——Next 自己的 dev server 不这么构造请求,所以这个 bug 在我们的代码里躺了一年多,硬是没人踩到。换掉打包器的那一刻,它醒了。

另外两个发现也值得说。一个是 Vite 的模块运行器会给 dev 环境注入一份没有 mkdir 的 fs,于是 proper-lockfile 一加锁就 500——这类「打包器替身模块」的坑,文档里不会写,只有真跑起来才会撞上。另一个更隐蔽:生产构建会把动态 import() 拆出独立 chunk,页面一跳转这个 fetch 就被中断,WebKit 报 Importing a module script failed,而同样的代码在 Chrome 上一点事没有。不测浏览器,你根本不知道自己的应用在坏。

还有一件我判断错过的事:服务器启动时间。我最初只跑了一次,得出「vinext 慢 2.5 倍」,数字好看得可疑。补跑到三次才发现那是冷缓存,真实差距只有 0.1 秒左右。这篇文章里被我自己的单次测量推翻过两次,所以上面每个数字都标了跑了几次。

项目在这边:pi-web-bun(迁移在 try/vinext 分支),vinext 在 cloudflare/vinext,Bun 那个 bug 的修复进展在 oven-sh/bun#46528。要是你也在折腾同样的东西,欢迎来聊聊。


文中所有数据由作者于 2026-10-25 在 Apple Silicon macOS 上实测。图表为纯手写 SVG + CSS 实现,无图片、无第三方图表库;语法高亮同为手写分词器。

∎