普通视图

发现新文章,点击刷新页面。
昨天 — 2025年6月29日首页

理解 Tree Shaking:原理与那些“甩不掉”的代码陷阱

作者 CAD老兵
2025年6月28日 21:54

在现代前端构建中,“Tree Shaking”是一项至关重要的优化技术。它的目标是在打包过程中移除那些未被使用的“死代码”,从而减小最终 bundle 的体积,提升加载性能。

本文将深入剖析 Tree Shaking 的原理,并结合实际代码示例讲解一些常见的“甩不掉”的坑。


一、什么是 Tree Shaking?

Tree Shaking 是一种 静态分析(Static Analysis) 技术,用于在构建过程中移除未被引用的 ES 模块导出内容。

前提条件:

  • 必须使用 ES Module(即 ES6 的 import / export 语法)
  • 必须启用 生产模式构建(如 webpack 的 mode: 'production'
  • 构建工具支持 Tree Shaking(如 Webpack、Rollup、esbuild、Vite 等)

工作原理简述:

  1. 构建工具解析模块依赖关系;
  2. 标记哪些导出被实际引用;
  3. 移除未被引用的导出代码(Dead Code Elimination);
  4. 压缩器(如 Terser)进一步移除无效语句。

二、一个简单示例

// math.js
export function add(a, b) {
  return a + b;
}

export function sub(a, b) {
  return a - b;
}
// main.js
import { add } from './math.js';

console.log(add(2, 3));

构建后,如果使用了 Tree Shaking,sub() 方法将不会被打包进最终 bundle。


三、Tree Shaking 常见失效案例

1. 使用 CommonJS(require

// bad-math.js (CommonJS)
exports.add = (a, b) => a + b;
exports.sub = (a, b) => a - b;
// main.js
const { add } = require('./bad-math');
console.log(add(1, 2));

问题:Tree Shaking 无法分析 CommonJS 模块导出内容(因其导出是动态对象,静态分析无法进行)。

建议:统一使用 import/export 的 ES Module 语法。


2. 导出对象或数组,统一挂载多个功能

// utils.js
export const utils = {
  a: () => console.log('A'),
  b: () => console.log('B'),
};
// main.js
import { utils } from './utils.js';
utils.a();

问题:尽管只用了 a(),但整个 utils 对象都会被打包进去。

建议:尽可能按功能粒度导出:

export const a = () => console.log('A');
export const b = () => console.log('B');

3. 间接引用导致 Tree Shaking 失效(以及如何正确写)

Tree Shaking 对代码的分析是基于静态导入路径的。当你通过中间模块“转发”某个模块的内容时,特别是使用聚合对象导出时,可能会导致 Tree Shaking 无法判断你真正使用了什么。

// math.js
export const add = (a, b) => a + b;
export const sub = (a, b) => a - b;
// index.js
import * as math from './math.js';
export default math;
// main.js
import math from './index.js';
math.add(1, 2);

问题

你可能只用了 add,但由于 math 是整个对象,打包工具无法静态分析 math.sub 是否会在运行时被用到。因此它会保留整个模块的内容(包括 sub)。

建议:在写模块聚合的时候

  • 避免 default export 聚合对象
  • 使用 export { xxx } from 的形式将原始模块静态 re-export
// math.js
export const add = (a, b) => a + b;
export const sub = (a, b) => a - b;
// index.js
export { add, sub } from './math.js'; // 直接 re-export 静态绑定
// main.js
import { add } from './index.js';
add(1, 2);

这样,构建工具可以静态分析 add 的引用。sub 没被引用,会被安全地移除。


4. 使用副作用模块(Side Effects)

js
// side.js
console.log('I will always run');
export const x = 1;

问题:即使你不使用 x,这个模块仍然会被打包进来,因为它有副作用(console.log())。

解决方案

  • package.json 中配置 "sideEffects": false,告诉构建工具默认模块无副作用;
  • 对于有副作用的模块明确标注 "sideEffects": ["./side.js"]

5. 动态语法阻止静态分析

export const tools = {};

tools['run'] = () => console.log('run');
tools['build'] = () => console.log('build');

问题:动态属性访问使构建工具无法判断哪个属性被使用,只能保留整个对象。

建议:使用静态结构进行导出。


四、如何判断 Tree Shaking 是否生效?

方法一:查看构建产物体积

  • 使用 webpack-bundle-analyzersource-map-explorer 等工具分析 bundle;
  • 比较仅引入部分函数和引入整包的体积差异。

方法二:查看产物代码

  • 打开打包后文件;
  • 搜索你不希望打包进去的函数名,看是否仍然存在。

五、总结

错误用法 原因 建议修复方式
使用 require() 动态导入,静态分析失败 改为 ES Module 的 import
聚合导出为对象 无法移除未用属性 单独导出每个函数/变量
使用 export * + 再导出 无法精准分析具体依赖 直接从源文件 import 所需内容
动态属性赋值 静态分析工具无法识别 改为静态结构导出
模块含副作用 构建工具不敢随意删除 使用 sideEffects 明确标注

六、结语

Tree Shaking 并不是魔法,而是一种依赖静态语法分析的工具优化手段。理解它的工作机制并遵循相关规则,才能让你的项目瘦身成功,跑得更快!

如果你还在为构建体积太大而苦恼,或许是时候检查一下你的代码结构,是不是也存在一些“甩不掉”的“树枝”呢?

昨天以前首页

JIT 编译器是什么?JavaScript 为啥能“跑得像风一样快”?

作者 CAD老兵
2025年6月25日 21:59

🧠 摘要

你有没有想过:明明 JavaScript 是一门动态语言,代码写得“自由奔放、毫无类型”,为啥跑起来还能嗖嗖地快?这背后,其实站着一个默默无闻但非常聪明的存在——JIT 编译器。本文将用通俗比喻 + 实际代码,揭开它的神秘面纱。


一、JIT 是啥?一个边跑边换引擎的飞行员

JIT,全称 Just-In-Time Compilation,翻译成中文就是“即时编译”。你可以把它想象成这样一个场景:

飞机已经起飞了,飞行员在空中边观察风速、气流、油耗,边更换更强的引擎,甚至有时候把整个机翼重焊了一遍——只为飞得更快、更稳。

JavaScript 引擎正是这样干的:

  • 一开始用解释器快速起飞(先跑起来)
  • 跑着跑着发现某段代码经常跑,于是拿出来扔进JIT 编译器
  • 编译器加鸡腿优化,生成机器码,从此这段代码变“喷气式发动机”

比如 V8 引擎:

阶段 角色 特点
解释器 Ignition 快速开始执行
JIT 编译器 TurboFan 热代码编译成机器码

二、JIT 到底优化了啥?

现在我们来揭晓:JIT 究竟做了哪些“黑科技”,让 JS 像打了鸡血一样飞快?

1. 类型推断:你看起来像个“数字”,我就用加法电路对你操作

function add(a, b) {
  return a + b;
}

JIT 会观察多次调用:

add(1, 2);
add(3, 4);

看到都是数字,就大胆猜测是数字加法,并用机器码生成专用的加法逻辑。如果突然变成字符串拼接,JIT 就会降级回解释器执行。


2. 内联缓存:你家门口我来过,下次不敲门了直接进

对象属性访问优化,例如:

const person = { name: "Alice" };
console.log(person.name);

JIT 记住了 person 的结构和 name 的偏移地址,下次直接读内存地址,极快!


3. 函数内联:你太小,我把你复制粘贴进来得了

function square(x) {
  return x * x;
}
let result = square(10);

JIT 直接将 square(10) 优化成 10 * 10,甚至做常量折叠。


4. 死代码消除:你不动我也不动,直接丢掉!

if (false) {
  console.log("不会执行");
}

JIT 完全不生成这段代码的机器码。


5. 逃逸分析:你没“逃出去”,那我就别分配内存了!

function makePoint(x, y) {
  return { x, y };
}

如果这个对象只在函数内部用到,JIT 会分析变量没有逃逸,直接用栈或寄存器存储,避免堆分配。


6. 🔁 循环优化:你拧螺丝太慢了,我给你自动批量工具!

for (let i = 0; i < 100; i++) {
  sum += i;
}

JIT 会将其展开、简化,甚至 SIMD 化,提升执行效率。


7. 闭包优化:变量我知道在哪,别担心,我罩着你!

闭包是 JS 的招牌技能,但过度使用可能会拖慢性能:

function outer() {
  let counter = 0;
  return function inner() {
    counter++;
    return counter;
  };
}

JIT 编译器会分析闭包中捕获的变量是否“逃逸”了作用域:

  • ✅ 没有逃逸:寄存器分配、甚至常量折叠
  • ❌ 逃逸了:只能慢路径堆分配

例如下面这段可优化:

function outer() {
  let a = 10;
  return function() {
    return a * 2;
  };
}

JIT 甚至能优化成:

function() {
  return 20;
}

但这种则会被关闭优化:

const fns = [];
for (let i = 0; i < 10; i++) {
  fns.push(() => console.log(i));
}

变量 i 被闭包捕获并延迟使用,JIT 无法预测其状态,只能老老实实留在内存中。


🧨 三、不是所有代码都适合优化!

别高兴太早,JIT 虽然聪明,但它也怕麻烦。

比如:

eval("console.log('你猜我是谁?')");

JIT 编译器直接摆烂:

“我……我啥都不知道,这代码运行前都还没出现呢!”

再比如:

with (obj) {
  console.log(name);
}

你让编译器怎么猜 name 是谁家的?于是,这类语法通常会关闭优化。


🚀 四、为什么 JIT 能让 JavaScript 跑得接近 C++?

虽然 JavaScript 动态到飞起,但在热点代码处,通过 JIT 的层层优化,最终生成的机器码其实和静态编译语言没太大区别。

  • 多次调用的函数 → 内联 + 类型推断
  • 对象访问 → 内存地址直接访问
  • 数组遍历 → SIMD/循环展开优化
  • 函数调用 → 内联 & 寄存器传参

这些优化,让 JavaScript 在浏览器中跑得飞快,连 WebAssembly 有时候都追不上。


🎉 总结

JIT 编译器,就像一位“不鸣则已、一鸣惊人”的优化大师,在幕后悄悄把你的代码变得更聪明、更快、更节能。

所以,下次你写 JS 的时候,请对它多一分敬意——不是它自己快,是它背后站着一位 JIT 的“魔法师”。

❌
❌