先解释后优化: 热点函数被编译成机器码, 代价是你必须"形状稳定" — 一旦食言, 去优化当头一棒
V8 像一家先出餐后精修的餐厅: 菜单一来, Ignition 解释器先把字节码端上桌(启动快); 某道菜被反复点单("热点"), 后厨开始观察它每次的"食材规格"(类型反馈), 观察够了就交给 TurboFan 按这份规格批量预制(机器码+内联), 快到飞起。但预制菜有个前提: 规格不能变 — 你下次突然传了个不同形状的对象, 预制作废,deopt 现场打回解释器重观察。
而"规格"在 JS 里有个正式名字: 隐藏类(Map)。对象不是按"类"定义的, 而是按加属性的顺序攒出形状; 形状相同的对象, 属性读取就是一次固定偏移的内存访问, 快如 C。所以 JS 性能优化的第一性原理只有一句: 让同一种形状出现在同一个热函数里。
node --print-bytecode -e 'function f(a){return a+1} f(1)' // 关键: 能看到 Add/Star 之类的字节码指令序列
node --trace-opt -e 'function f(){}; for(let i=0;i<1e5;i++)f();' // 关键: 能看到 [marking f for optimization] 时机
for (let i = 0; i < 1e6; i++) sum += add(a, b); // 关键: add 这种小函数大概率被内联, 函数调用成本≈0
const a = {}; a.x = 1; a.y = 2; // M0 → M1 → M2 const b = {}; b.y = 2; b.x = 1; // 另一条链, 不同形状!
%DebugPrint(obj); # 需 --allow-natives-syntax // 关键: 输出里能看到 Map 地址与 elements kind
node --trace-deopt app.js
// 关键: 看 [deoptimizing] 原因: wrong map / not a heap numberconst double = n => n * 2; // 小函数 [1,2,3].map(double); // 大概率整体优化成一段循环
function norm(x, y) { const p = { x, y }; // 没逃出去 return Math.hypot(p.x, p.y); // p 可能根本不分配 }
const a = [1, 2, 3]; // PACKED_SMI_ELEMENTS 最快 a[3] = 1.5; // 降级到 DOUBLE a[4] = 'x'; // 降级到 ELEMENTS(装箱)
delete obj.cache; // 字典模式, 慢 obj.cache = undefined; // 保持形状, 快
// 预热 for (let i = 0; i < 1e5; i++) fn(); // 计时多轮取中位, 且消费结果防死码消除 // console.log(total); ← 消费, 防"没跑就被优化没"
// 错: 按 V8 某版内部行为硬编码优化 // 对: 稳定形状 + 正确算法 + 真实 profile 驱动
解析函数同时吃 MQTT/HTTP/WS 三种消息对象, IC 超多态。归一化入口形状后性能回升:
// 错: 三个来源对象形状各异, hot 函数 IC 超多态 handle(mqttMsg); handle(httpReq); handle(wsMsg); // 对: 入口处先归一成同一种"内部消息"形状 handle(toInternal(mqttMsg)); // toInternal 统一 { type, body, ts } handle(toInternal(httpReq)); // handle 里只见到一种形状 → 单态, 快回 8 倍
埋点对象被动态塞 200 个随机键, 形状爆炸。动态键名走 Map, 固定字段留对象:
// 错: event['attr_' + key] = v; 无限形状 // 对: 固定骨架 + Map 装动态键 const event = { type, ts, attrs: new Map() }; event.attrs.set(key, v); // Map 天生为动态键而生
热循环数组被塞进一个 undefined, PACKED_SMI 降级成 HOLEY_ELEMENTS, 每次读都装箱:
const mat = new Array(1e6).fill(0); // SMI // 事故: mat[flag ? i : -1] 越界写入 undefined // 对: 边界检查, 或用 Float64Array 定型数组 const mat = new Float64Array(1e6); // 无装箱, 无洞 mat[i] = v; // 稳定 double 视图, 数值计算首选
定型数组(TypedArray)绕开 elements kind 演化, 数值热点稳定 3~5 倍。
字段随业务慢慢挂, 不同路径造出的对象形状不同。构造器统一声明字段+顺序:
// 错: 有的实例先有 a, 有的先有 b const u1 = { }; u1.a = 1; u1.b = 2; const u2 = { }; u2.b = 2; u2.a = 1; // 不同形状! // 对: 构造器定形 class User { a = 0; b = 0; #extra = null; } // 一条形状链
凭感觉优化前先采样, 80% 的"优化"会打在没用的地方:
node --cpu-prof --cpu-prof-dir=./prof server.js # 得到 .cpuprofile, 拖进 Chrome DevTools → Performance # 关注: Self Time 排名 + 是否大量存在于 (garbage collector) # GC 占比高 → 先看分配速率; 函数 Self 高 → 看形状与算法
某接口 P99 毛刺。trace 显示 hot 函数每分钟 deopt 两次 — 偶发的"空对象"打破假设:
node --trace-deopt --trace-opt app.js // [marking optimize] → [deoptimizing: wrong map] 循环 // 根因: 偶发传 {}/null 进热函数 // 对: 入口卫语句提前 return, 让热路径形状单一 if (!msg || !msg.body) return skip; // 冷路径隔离
现代 V8 对 a + b 用绳结字符串延迟物化, 循环拼接不再必然 O(n²)——但超大 JSON 构建仍建议数组 join:
// 一般拼接: 引擎 rope 优化, 直接 + 即可 let html = '<li>' + esc(name) + '</li>'; // 超大累计(几十万段): join 仍更稳 const parts = rows.map(r => render(r)); const html = parts.join('');
大而全的 process() 内联不动。拆成小函数让 TurboFan 整体优化成一段:
// 错: 500 行巨函数, 复杂到内联与优化放弃 // 对: 拆职责单一的步骤, 热路径上的小函数会被内联 function process(m) { const v = validate(m); const n = enrich(v); return emit(n); } // 注意: 别为"优化"手动内联牺牲可读性 — 引擎比你内联得更好
团队基准显示"新代码快 40%", 上线无感 — 原来没预热且结果被死码消除:
function bench(fn) { for (let i = 0; i < 1e6; i++) fn(); // 预热触发 JIT const ts = []; for (let r = 0; r < 20; r++) { const t0 = performance.now(); for (let i = 0; i < 1e6; i++) acc += fn(); // 消费结果 ts.push(performance.now() - t0); } return ts.sort((a, b) => a - b)[10]; // 中位数抗毛刺 }
冻结配置对象防篡改, 但冻结后的属性访问走特殊路径, 超热路径需权衡:
const CFG = Object.freeze({ timeout: 3000, retries: 3 }); // 冻结对象读取通常没问题; 但把冻结对象当"模板"扩展属性会 deopt // 建议: 冻结"配置"这类冷数据; 热路径的工作对象不要 freeze
// 错: render(a) / render(b) / render(c) 都进同一个 render // 对: toInternal(x) 先归一, render 只见一种形状
undefined 或 Map。 // 错: delete obj.tmp; // 对: obj.tmp = undefined;
// 错: {} 后按需 a.b / b.a 乱序加 // 对: class C { a = 0; b = 0; } new C()
Map。 // 错: const a = []; a[999999] = 1; // 对: const m = new Map(); m.set(999999, 1);
[1, 2, 'x'] // ELEMENTS, 每次读小整数都装箱 [1, 2, 3] // PACKED_SMI, 最快
// 错: 直接 performance.now() 包一层循环 // 对: 预热 1e5 次 → 计时 20 轮 → 中位数
// 错: for(...) math(x); // 结果被丢 // 对: sink += math(x); console.log(sink);
// 错: eval('obj.' + key); // 对: obj[key] / map.get(key)
// 对: try 包热路径 ok; 但循环里靠 throw 结束=慢 // 对: 用返回值/哨兵表达"未找到", 别 throw
Array.prototype.last = ... // 运行中改 → 全局 deopt 风暴...rest。 // 对: function f(...args) { } // 现代写法, 无历史包袱
// 错: get x() { return this._x; } 在 1e7 次/帧 的路径上 // 对: 直接字段访问; 访问器留给需要逻辑的字段
Map。 // 错: cache[userKey] = v; // key 是 'constructor' 就出事 // 对: cache = new Map(); cache.set(userKey, v);
// profile 里 (garbage collector) 占 30% → 优化方向是少分配// 错: speed = hasData ? v : undefined; // 类型漂移 // 对: 统一 NaN 哨兵或 0, 保持 number 通道
// 错: buf[i - 1] 当 i=0 时读 undefined // 对: buf[(i - 1 + n) % n]
// 错: const all = JSON.parse(big) 每请求都来一次 // 对: 按需解析字段 / ndjson 分行
// 错: 模仿某 polyfill 手动展开循环"帮引擎" // 对: profile 显示瓶颈再动手
// 错: for(...) els[i].onclick = () => h(i); // 对: 委托 + dataset, 一个监听器
// --cpu-prof → Self Time Top1 → 改 → benchstat 式对比 → 合并