欢迎光临
我们一直在努力

源码多 5%、冷启动快 4.9 倍:单文件工具里被忽略的 V8 急切编译成本

摘要:本文通过 node:vm 基准测试,拆解了单文件工具冷启动中编译与顶层执行的成本占比。实验表明,决定冷启动速度的是"顶层执行图"而非文件字节数——将 IIFE 注册改为未调用的函数表达式,编译段省 47%、执行段省 96%,总耗时快 4.9 倍。

把 100 个小工具塞进一个 HTML 文件之后,我做了一件很反直觉的事:给源码多加了 5% 的字节,冷启动却快了 4.9 倍。

这不是压缩技巧,也不是删功能。变的只有一件事——模块是"在顶层立即跑"还是"先登记、用到才跑"。而真正让我意外的是编译那一段:惰性版本的源码明明更大,V8 编译它反而快了将近一倍。

顺着这个异常查下去,才发现单文件工具的冷启动预算,长期被算错了对象。

背景:单文件工具的"体积焦虑"可能找错了对象

单文件、零依赖、双击即用的本地工具这两年重新流行起来。原因不难理解:没有安装器、没有 node_modules、没有构建步骤,换台机器把文件拷过去就能跑;对 AI 生成代码来说,单文件还消除了跨文件引用这个最大的幻觉来源。

但这个范式有个天然的增长压力——工具会越加越多。一个文件从 3 个工具长到 100 个工具,很自然会开始担心:"文件是不是太大了?加载会不会变慢?"

于是优化动作几乎都指向体积:压缩、精简、去空白、把大表外置。

问题是,体积和冷启动耗时并不是同一件事。浏览器拿到一段内联脚本要做两件事:先编译(parse / compile),再执行顶层代码(top-level execution)。前者大致跟字节数相关,后者跟"你在顶层真正跑了多少活"相关。这两者可以完全脱钩——而绝大多数单文件工具的慢,慢在第二段。

我想把这两段拆开,各自称一下重量。

实验设计:把冷启动拆成编译与顶层执行

要干净地测这件事,得先把浏览器里的噪音去掉:网络、HTML 解析、CSS、布局、绘制都会污染读数。所以我用 node:vm 直接驱动 V8——它和 Chrome 是同一个引擎,new vm.Script() 对应编译,script.runInContext() 对应执行,两段可以分别打点。

生成的"工具模块"贴近真实单文件工具里一个编码/换算类工具的体量:一张 256 项查找表、一个渲染函数、一段事件装配。对照的两种范式是:

  • 急切(eager):每个模块用 IIFE 包裹,在顶层立即建表、建模板、装事件。
  • 惰性(lazy):每个模块只往注册表里 push 一个未调用的 init 闭包,只有当前激活的那一个真正初始化。

// 急切:顶层立刻执行模块体
(function () {
var table_7 = [];
for (var k = 0; k < 256; k++) table_7.push(((k * 14) ^ 0x5f).toString(16).padStart(2, '0'));
/* …建模板、装事件… */
__SINK.push(state_7);
})();

// 惰性:只登记闭包,模块体不在顶层跑
__REG.push({ id: 7, init: function () {
var table_7 = [];
for (var k = 0; k < 256; k++) table_7.push(((k * 14) ^ 0x5f).toString(16).padStart(2, '0'));
/* …同样的模块体… */
__SINK.push(state_7);
return state_7;
}});

这里有个很容易踩的坑,第一版实验我就掉进去了:V8 有 isolate 级的 compilation cache,重复编译同一份源码字符串会直接命中缓存。我第一次跑出来 87KB 脚本编译只要 0.003ms,比物理上限还离谱。修正办法是每轮迭代往源码里注入唯一 salt,保证 V8 每次见到的都是新字符串:

function buildScriptSource(count, eager, salt) {
const parts = [`var __SALT = ${salt}; var __SINK = []; var __WIRED = []; var __REG = [];`];
for (let i = 0; i < count; i++) parts.push(makeModuleSource(i, eager, salt));
if (!eager) parts.push('__REG[0] && __REG[0].init();'); // 只初始化激活的那一个
return parts.join('\\n');
}

每个配置预热 3 轮、正式跑 15 轮取中位数。测试环境:AMD Ryzen 7 9700X(8 核 16 线程)、31.1GB 内存、Windows 10.0.26200、Node v22.22.2、V8 12.4.254.21。

实测:执行成本主导,且随模块数近似线性

四种模块规模、两种范式的结果如下(中位数,单位 ms):

模块数范式源码 KB编译顶层执行合计
10 急切 8.6 0.168 0.325 0.493
10 惰性 9.1 0.101 0.071 0.172
30 急切 25.9 0.399 0.864 1.263
30 惰性 27.3 0.232 0.087 0.319
60 急切 51.9 0.843 1.626 2.469
60 惰性 54.7 0.434 0.101 0.535
100 急切 86.6 1.380 2.719 4.099
100 惰性 91.3 0.719 0.122 0.841

图1:编译(浅色)与顶层执行(深色)的耗时拆分。急切范式下执行段随模块数线性膨胀,惰性范式下几乎是一条平线。

三个读数值得注意。

第一,执行是主导项,不是编译。 100 模块急切范式里,执行 2.719ms 占了总耗时 4.099ms 的 66%。

第二,急切范式的执行成本严格线性。 换算成每模块摊销:10 模块 0.0325ms、30 模块 0.0288ms、60 模块 0.0271ms、100 模块 0.0272ms。基本是一个常数——你每往单文件里加一个急切初始化的工具,就固定买入约 0.027ms 的冷启动债务,跟这个工具用不用得上无关。

第三,惰性范式几乎不随规模增长。 执行段从 10 模块的 0.071ms 到 100 模块的 0.122ms,模块数涨了 10 倍,耗时只涨 1.7 倍——多出来的那点是注册闭包本身的开销,而真正的模块体只跑了被激活的那一个。

100 模块时两者执行段相差 22.3 倍(2.719ms vs 0.122ms),总耗时相差 4.9 倍。

反直觉:源码多 5%,编译却快 1.9 倍

上面那张表里藏着一个说不通的地方:惰性版本源码是 91.3KB,比急切版本的 86.6KB 大 5.4%,编译耗时却是 0.719ms vs 1.380ms——快了 1.92 倍。

字节更多、编译更快,这违反"编译成本正比于体积"的直觉。所以我做了第二个对照实验来定位原因:同一段模块体,三种包裹形式,只编译、不执行。

// A) IIFE:立即调用
(function () { /* BODY */ })();

// B) 函数表达式:登记但不调用
__REG.push({ init: function () { /* BODY */ } });

// C) 函数声明:定义但不调用
function mod_7() { /* BODY */ }

100 个模块,结果如下:

包裹形式源码 KB仅编译耗时
IIFE(立即调用) 50.3 0.943 ms
函数表达式(未调用) 52.1 0.499 ms
函数声明(未调用) 50.4 0.390 ms

图2:函数表达式比 IIFE 源码大 3.6%,编译耗时却只有其 53%。差异来自 V8 是否对函数体做完整编译。

IIFE 的编译耗时是等价函数表达式的 1.89 倍,尽管它的源码还小 3.6%。 这就解释了主实验里的异常。

原因在于 V8 的惰性编译策略:默认情况下,V8 首次扫描脚本时对函数体只做 pre-parse——检查语法、记录作用域和变量引用,但不生成字节码。字节码要等函数第一次被调用时才生成。可 IIFE 是个例外:V8 有一条启发式规则,识别出 (function 这种立即调用的模式后,会直接对它做急切的完整编译,因为引擎知道这个函数马上就要跑,推迟没有意义。

于是"用 IIFE 包裹每个模块"这个在单文件工具里极其常见的写法,等于主动放弃了 V8 送给你的惰性编译优化。

图3:未调用的函数表达式停在 pre-parse,IIFE 则一路走到字节码生成与顶层执行。示意图,非引擎内部实现的精确还原。

落到设计:单文件工具的三条冷启动约束

把上面的数据翻译成可执行的设计约束,就三条。

一、优化目标是"顶层执行图",不是文件字节数。 判断标准很简单:打开文件到可交互之前,有多少代码是必须跑的?一个 500KB 但顶层只跑路由和一个工具的单文件,冷启动会明显快过一个 200KB 但把 60 个工具全初始化了的单文件。字节数是个糟糕的代理指标。

二、模块注册用未调用的函数,不要用 IIFE。 这是本文成本最低的一条改动——把 (function(){…})() 改成 { init: function(){…} } 并延后调用,编译段直接省掉近一半,执行段省掉全部。改动是机械的,不涉及业务逻辑。

三、把"建表/建模板/装事件"从加载期挪到激活期。 查找表、DOM 模板、事件绑定这类工作,是每模块 0.027ms 的主要来源。它们该发生在用户点开某个工具的那一刻,而不是打开文件的那一刻。

一个可直接套用的骨架:

const REG = [];
const define = (id, init) => REG.push({ id, init, inst: null });

define('base64', function () {
const table = buildTable(); // 建表推迟到这里
const root = renderTemplate(); // 模板推迟到这里
bindEvents(root); // 装配推迟到这里
return { root, table };
});

function activate(id) {
const m = REG.find(x => x.id === id);
if (!m.inst) m.inst = m.init(); // 只初始化一次,之后复用
return m.inst;
}

关键在于 define 的第二个参数是未被调用的函数表达式——V8 会让它停在 pre-parse,直到 activate 真正需要它。

局限:这个实验没有证明什么

有几件事必须说清楚,否则这些数字会被过度解读。

这是 V8 层的测量,不是端到端的页面性能。 node:vm 隔离掉了 HTML 解析、CSS 解析、样式计算、布局和绘制。真实单文件工具的可交互时间里,DOM 与样式往往占更大比重。本文测的是"脚本这一段花了多少",不是"页面多久能用"。

绝对值很小,桌面端用户基本无感。 100 模块急切范式合计 4.099ms——在 Ryzen 7 9700X 上这个量级不构成体验问题。真正的意义在于那个线性系数:0.027ms/模块是结构性成本,它会随工具数量无上限增长,而且在慢设备上按比例放大。中低端移动 CPU 单线程性能通常比这台机器慢数倍,同样的脚本开销会相应变大——但这是推断,本文没有在移动设备上实测,不应当作已验证的数字引用。

没有测内存与 GC。 急切范式把 100 张 256 项查找表全建出来常驻堆上,惰性范式只建 1 张。这个差异对长时间运行的工具可能比启动耗时更重要,但不在本次测量范围内。

结论依赖 V8 的当前启发式。 IIFE 急切编译是 V8 的实现选择,不是语言规范。不同引擎(JavaScriptCore、SpiderMonkey)的策略不同,未来 V8 版本也可能调整。本文结论限定在 V8 12.4。

合成模块不等于真实工具。 生成的模块体是同构的;真实工具的初始化成本差异极大,有的只绑一个事件,有的要预编译正则或解析大常量表。线性系数在真实项目里会是一个分布,不是常数。

结论与下一步

单文件工具的性能讨论,长期停在"文件多大"上。但这次测量给出的结论是:决定冷启动的是顶层执行图的大小,而不是文件字节数——两者可以反向变动,惰性版本大了 5.4% 却快了 4.9 倍就是证据。

如果只带走一条,那就是这条机械改动:别用 IIFE 注册模块。改成未调用的函数表达式,编译段省 47%,执行段省 96%,代价是几行结构调整。V8 本来就准备好帮你惰性编译,IIFE 是主动把这个优化关掉。

下一步我打算把同一组对照搬进真实浏览器,用 Performance API 量端到端的可交互时间,看看在 HTML 解析和布局的噪音里,这 3.3ms 的脚本差异还剩多少可观测性;同时补上堆内存和 GC 的对照。

复现方式:本文两段基准的完整脚本、原始 results.json 与环境信息都在下面的仓库里,node bench.js 即可复跑。

开源地址:

  • 矩阵门户:https://github.com/wangzifan396-wzf/WB
  • 单文件工具聚合器:https://github.com/wangzifan396-wzf/nano-workbench
  • GitHub 组织主页:https://github.com/wangzifan396-wzf
赞(0)
未经允许不得转载:171主机测评 » 源码多 5%、冷启动快 4.9 倍:单文件工具里被忽略的 V8 急切编译成本
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址