用 Bun 再打包 Next.js standalone,实际省下了什么
我的博客已经用 Bun 运行 Next.js,接下来很自然会想到:能不能连部署产物也交给 Bun,再缩小一点?
最容易下手的是 standalone 里的 server.js。它是现成的入口,而 Bun 既能打包 JavaScript,也能生成可执行文件。我在临时目录里试了压缩和编译,结果与预期有些距离:入口确实变小了,但没有得到一个可以独立运行的博客。
这次实验使用 Bun 1.4.1 和 Next.js 16.3.4。下面的体积与压测数字都是这轮实验的记录,不是两者所有版本的固定表现。
入口缩小了,依赖还在旁边
对生成的入口做 bun build --minify 后,文件从 12.33 KB 降到 10.96 KB。打包器只处理了一个模块,require("next") 仍然留到运行时加载。
省下约 1.4 KB 本身没有问题。问题是,它没有把 Next.js 及其运行时依赖一起收进去。把结果移出 standalone 目录后再启动,程序仍会尝试解析 Next,随后因为找不到 React 而失败。在原目录旁边能启动,不能证明产物可以独立部署。
再尝试 bun build --compile,得到的 EXE 约 82.2 MiB。启动时,Next 的 process.chdir(__dirname) 指向了 Bun 可执行文件中的虚拟目录:
B:\~BUN\root程序在这里报错,没能启动服务。Bun 的可执行文件文档介绍了文件嵌入和运行时路径的处理方式;现有服务器如果依赖真实目录布局,不能只把入口编译进去就期待它继续按原样工作。
这个失败只说明本次直接编译生成入口的方案不成立。它并不证明所有定制适配方案都不可能,但继续做下去,就需要处理 Next 对目录、依赖和部署文件的假设了。
standalone 大在哪里
当时整套 standalone 约 109 MiB,主要分布是:
.next/server/app:73.74 MiB,包含预渲染页面等产物。- 根
node_modules:27.03 MiB。 content:4.89 MiB。server.js:约 0.01 MiB。
入口占比很小。即使把它压到零,也改变不了部署包的体积。
Next.js 的 output 文档解释了 standalone 的工作方式:构建时追踪页面需要的文件,复制相关依赖,再生成一个最小服务器。它不是一个单文件应用。默认还不会把 public 和 .next/static 复制进去,部署时需要另外安排这些资源。
我的博客还有放在另一个 workspace 的 Markdown。只看入口中的 import,无法判断哪些文章需要随镜像发布。文件追踪的根目录、额外包含的内容路径、静态资源的复制位置,都是部署的一部分。
因此生产容器仍然保留完整 standalone,并由 Bun 执行入口:
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["bun", "server.js"]去掉 output: "standalone" 也不会省去这些文件。普通构建仍然生成 .next,运行 next start 时还需要准备生产依赖。变化只是依赖如何收集和启动,并没有把应用变成 Bun 可以直接接手的源码入口。
页面已经预渲染,CPU 还在做什么
二次打包没有解决体积问题,那么请求性能呢?我又用项目已有的压测脚本打了生产 standalone,100 个连接,持续 60 秒,访问前 10 个有效 HTML 页面:
bun run load-test -- --url http://127.0.0.1:3101 --connections 100 --duration 60 --max-urls 10这是博客项目自己的脚本,底层使用 autocannon;它不是 Bun 内置命令。那次测试约完成 65,000 次请求,平均 1,077.5 req/s,p99 为 228 ms,传输约 7.11 GB,没有 HTTP 或连接错误。最初扩大爬取范围时遇到了文章 404,原因后来查明是文件扫描漏掉了点开头的文章,所以这里不能当成全站覆盖结果。
这些请求主要访问已经预渲染的页面,但服务器仍要匹配路由、取出响应内容、处理响应头并发送正文。首页未压缩 HTML 约 130 KB,反复发送这样的页面,HTTP 处理本身就有成本。
CPU Profile 中,原生 writeHeadAndEnd 的 self time 占比约 18.8%,App Router 的 prepare() 调用链 total time 约 6.5%。还有一些编译后匿名函数,需要结合产物定位,不能看到匿名热点就直接叫它“SSR 慢”。
检查 Next.js 的 send-payload.js,能够看到静态响应处理中的这条路径:
RenderResult 转成完整字符串
→ 根据配置生成 ETag
→ 检查条件请求
→ 设置响应头
→ res.end(payload)生成入口的压缩发生在部署阶段,以上工作发生在每次请求期间。压缩 server.js 没有改动这些步骤,所以不能据此期待页面吞吐量上升。
这轮本地压测也没有模拟真实浏览器完整的资源请求和缓存行为。数 GB 的响应传输说明它在反复取 HTML,不等于读者打开一次文章的网络开销。不同路由集合、压缩条件和机器上的测试结果不能直接拼成一张性能排名。
访问日志可以少做一些工作
项目里比较容易处理的是访问日志。快速成功请求原来每次都要格式化时间并输出;100 并发持续请求时,这些操作也会不停执行。
现在先判断是否需要记录,再格式化日志:
if (status < 400 && durationMs < 1000) return;
const output = formatHttpLogLine(method, url, status, durationMs);这样留下 4xx、5xx 和耗时至少一秒的请求。关键在于提前返回:只把 stdout 重定向走,并不能省掉前面的日期格式化和字符串构造。这个方案适合当前博客;需要完整访问记录的服务,应另行考虑采样或代理日志。
计时也没有必要顺手重写。项目已经用 Bun.nanoseconds() 算请求耗时,new Date() 用来生成日志里的日期时间。两者回答不同的问题。减少不需要的日志后,快速请求也就不用再生成可读时间戳。
ETag 则没有因为出现在热点里就一直关着。关闭后可以少做哈希计算,却会失去 HTML 条件请求的 304 响应。后来用真实首页比较了完整响应和条件响应,最终保留 ETag;这部分放在缓存文章里展开。
接下来要实验什么
这次保留下来的是 Next 构建 → standalone → Bun 执行。Bun 原生 API 继续用于项目自己维护的代码;Next 创建的 HTTP Server 和产物布局仍按框架约定工作。node:http 出现在源码中,也不意味着容器里额外启动了一个 Node 进程,相关区别见Next.js 的 nodejs runtime 为什么仍能由 Bun 执行。
如果后续目标是缩小镜像,我会先检查那 73.74 MiB 的页面产物,以及文件追踪收进来的依赖。如果目标是提高请求吞吐,就继续对照日志、压缩和条件请求做实验。至于把 Next 真正改造成 Bun 原生服务,需要单独验证适配器的构建和路由行为,不能用一次 bun build server.js 代替。