跳到主要内容

Tree Shaking

问题

什么是 Tree Shaking?它的原理是什么?如何确保 Tree Shaking 生效?

答案

Tree Shaking 是一种基于 ES Module 静态分析的死代码消除(Dead Code Elimination, DCE)技术。它的核心思想是:在打包阶段分析模块之间的依赖关系,找出哪些导出(export)没有被任何地方引用,然后将这些未使用的代码从最终产物中移除,从而减小打包体积

"Tree Shaking"这个名称来源于一个形象的比喻——把模块依赖看作一棵树,摇晃这棵树,那些没有被引用的"枯叶"就会掉落下来,最终只保留真正被使用的代码。

关键区分:Tree Shaking vs DCE
  • DCE(Dead Code Elimination):传统编译器优化,移除永远不会执行的代码(如 if (false) { ... }
  • Tree Shaking:更进一步,移除虽然被定义但从未被使用的模块导出。它是 DCE 在模块层面的扩展

原理详解

基于 ESM 的静态分析

Tree Shaking 能够工作的前提是 ES Module(ESM)的静态结构。ESM 的 importexport 具有以下特性:

  1. 必须出现在模块顶层,不能嵌套在条件语句或函数中
  2. 模块标识符必须是字符串字面量,不能是变量
  3. 导入绑定是不可变的(immutable binding)

这些特性使得构建工具能够在**编译时(静态阶段)**就确定模块之间的依赖关系,而无需实际执行代码。

esm-static.ts
// ✅ ESM:静态声明,编译时即可分析
import { add } from './math'; // 编译时就能确定只使用了 add

// ❌ CJS:动态调用,只有运行时才能确定
const math = require('./math'); // 运行时才知道用了哪些方法

标记(Mark)与删除(Sweep)

Tree Shaking 的过程可以分为两个阶段,类似于垃圾回收中的"标记-清除"算法:

  1. 标记阶段(Mark):构建工具从入口文件开始,递归分析所有 import 语句,标记每个模块中被引用的导出。没有被引用的导出会被标记为"未使用"
  2. 删除阶段(Sweep):在代码压缩阶段(通常由 TerserSWC 执行),将标记为"未使用"的代码从最终产物中移除

具体示例

math.ts
export function add(a: number, b: number): number {
return a + b;
}

export function subtract(a: number, b: number): number {
return a - b;
}

export function multiply(a: number, b: number): number {
return a * b;
}
main.ts
// 只导入了 add,subtract 和 multiply 未被使用
import { add } from './math';

console.log(add(1, 2));
打包产物(Tree Shaking 后)
// subtract 和 multiply 被移除,只保留 add
function add(a, b) {
return a + b;
}
console.log(add(1, 2));

ESM vs CJS 对比

为什么 CommonJS(CJS)无法进行 Tree Shaking?根本原因在于 CJS 是动态的,而 ESM 是静态的。

特性ESM(ES Module)CJS(CommonJS)
导入语法import { fn } from 'mod'const { fn } = require('mod')
导出语法export function fn() {}module.exports.fn = function() {}
分析时机编译时(静态)运行时(动态)
条件导入不支持(语法错误)支持 if (x) require('a')
动态模块路径不支持支持 require(variable)
导入值只读绑定(live binding)值的拷贝
Tree Shaking支持不支持
cjs-dynamic.ts
// CJS 允许动态 require,构建工具无法静态分析
const moduleName = condition ? './moduleA' : './moduleB';
const mod = require(moduleName); // 运行时才知道加载哪个模块

// CJS 允许条件导出
if (process.env.NODE_ENV === 'production') {
module.exports = require('./prod');
} else {
module.exports = require('./dev');
}
注意

即使你的源码使用 ESM,如果经过 Babel 等工具转换为 CJS,Tree Shaking 同样会失效。务必检查编译配置,确保输出保留 ESM 格式。


sideEffects 配置

什么是副作用

在 Tree Shaking 的语境下,**副作用(Side Effect)**是指模块在被导入时会执行一些影响外部环境的操作,例如:

  • 修改全局变量(window.xxx = ...
  • 添加 CSS 样式(import './style.css'
  • 注册 Polyfillimport 'core-js/stable'
  • 执行立即调用函数表达式(IIFE)

即使模块没有导出任何内容,这些副作用代码也不能被移除,否则会导致功能异常。

package.json 中的 sideEffects 字段

Webpack 引入了 sideEffects 字段,用于告诉构建工具哪些文件是"纯净"的(没有副作用),可以安全地进行 Tree Shaking。

package.json — 标记所有文件无副作用
{
"name": "my-library",
"version": "1.0.0",
"sideEffects": false
}

当设置为 false 时,构建工具会认为该包中所有未被引用的模块都可以安全移除。

标记有副作用的文件

如果包中部分文件有副作用(如 CSS 文件、Polyfill),可以用数组指定:

package.json — 标记部分文件有副作用
{
"name": "my-library",
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.ts",
"./src/register-global.ts"
]
}
sideEffects: false 的风险

如果你的库中有副作用代码但错误地设置了 sideEffects: false,这些副作用代码会被 Tree Shaking 移除,导致运行时错误。务必仔细检查每个文件是否真的没有副作用。

Webpack 中的模块级 sideEffects

除了 package.json,Webpack 还支持在 module.rules 中配置:

webpack.config.ts
import type { Configuration } from 'webpack';

const config: Configuration = {
module: {
rules: [
{
test: /\.tsx?$/,
sideEffects: false, // 标记所有匹配的文件无副作用
},
{
test: /\.css$/,
sideEffects: true, // CSS 文件有副作用,不要 Tree Shake
},
],
},
};

常见失效场景

了解 Tree Shaking 的失效场景对于面试和实际开发都至关重要。

1. 使用 CommonJS 模块

❌ CJS 无法 Tree Shake
// 整个 lodash 都会被打包(约 72KB gzip)
const { get } = require('lodash');

// ✅ 使用 ESM 版本
import { get } from 'lodash-es'; // 只打包 get 函数

2. 代码存在副作用

side-effects.ts
// 模块顶层执行了副作用代码
// 即使没有导出被引用,整个文件也不会被移除
Array.prototype.customMethod = function() {
return this.filter(Boolean);
};

export function pureFunction(): string {
return 'hello';
}

3. 导入方式不当

import-patterns.ts
// ❌ 命名空间导入:打包工具可能无法确定哪些属性被使用
import * as utils from './utils';
utils.someFunction();

// ✅ 具名导入:明确声明使用了哪个导出
import { someFunction } from './utils';
someFunction();
关于 import *

现代打包工具(Webpack 5、Rollup、Vite)对 import * 的 Tree Shaking 支持已经较好,但某些边界情况仍可能失效。建议始终使用具名导入以获得最可靠的 Tree Shaking 效果。

4. Babel 将 ESM 转成 CJS

.babelrc — ❌ 错误配置
{
"presets": [
["@babel/preset-env"] // 默认会将 ESM 转为 CJS
]
}
.babelrc — ✅ 正确配置
{
"presets": [
["@babel/preset-env", {
"modules": false // 保留 ESM 语法,交给 Webpack/Rollup 处理
}]
]
}

5. 类的方法无法被 Tree Shake

class-tree-shaking.ts
export class MathUtils {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
multiply(a: number, b: number): number {
return a * b;
}
}
// 即使只用了 add 方法,subtract 和 multiply 也不会被移除
// 因为类的方法挂在原型链上,构建工具无法安全地移除单个方法

// ✅ 改用独立的函数导出
export function add(a: number, b: number): number { return a + b; }
export function subtract(a: number, b: number): number { return a - b; }
export function multiply(a: number, b: number): number { return a * b; }

6. 重导出(Re-export)的陷阱

index.ts — barrel 文件
// ❌ barrel 文件可能导致所有模块都被打包
export * from './moduleA';
export * from './moduleB';
export * from './moduleC';
// 如果 moduleA 有副作用,即使只导入了 moduleB 的内容,
// moduleA 也会被包含

最佳实践

1. 使用 ESM 格式发布 npm 包

package.json 中配置双格式输出:

package.json
{
"name": "my-library",
"main": "./dist/cjs/index.js", // CJS 入口(兼容旧环境)
"module": "./dist/esm/index.mjs", // ESM 入口(Tree Shaking)
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.js"
}
},
"sideEffects": false
}

2. 正确配置 sideEffects

  • 确认每个文件是否有副作用
  • CSS、Polyfill 等文件标记为有副作用
  • 纯逻辑模块设置 sideEffects: false

3. 避免副作用代码

avoid-side-effects.ts
// ❌ 模块顶层的副作用
const config = JSON.parse(
fs.readFileSync('./config.json', 'utf-8')
);

// ✅ 封装为函数,按需调用
export function getConfig(): Record<string, unknown> {
return JSON.parse(
fs.readFileSync('./config.json', 'utf-8')
);
}

4. 使用 /*#__PURE__*/ 标注

/*#__PURE__*/ 注释告诉压缩工具:该函数调用是纯的(没有副作用),如果返回值未被使用,可以安全移除。

pure-annotation.ts
// 没有标注时,构建工具不确定 createLogger() 是否有副作用
const logger = createLogger();

// 使用 /*#__PURE__*/ 标注后,如果 logger 未被使用,整行可被移除
const logger = /*#__PURE__*/ createLogger();

// 常见于库的源码中
export const MyComponent = /*#__PURE__*/ React.memo(function MyComponent() {
return <div>Hello</div>;
});

5. 优先使用具名导入

named-import.ts
// ❌ 默认导入整个库
import _ from 'lodash';

// ❌ 命名空间导入
import * as _ from 'lodash';

// ✅ 具名导入
import { debounce, throttle } from 'lodash-es';

// ✅ 子路径导入(部分库支持)
import debounce from 'lodash-es/debounce';

Webpack vs Rollup 的 Tree Shaking 对比

对比维度WebpackRollup
Tree Shaking 触发mode: 'production' 时启用默认启用
实现方式标记 unused + Terser 删除只包含(include)被引用的代码
代码作用域模块包裹在函数中(module scope)扁平化合并(Scope Hoisting)
副作用处理依赖 sideEffects 字段更激进的静态分析
效果良好(需正确配置)更优(天然适合库打包)
适用场景应用打包库打包
Scope Hoisting需手动启用 ModuleConcatenationPlugin默认行为
Vite 的 Tree Shaking

Vite 在生产构建时使用 Rollup 作为打包工具,因此继承了 Rollup 优秀的 Tree Shaking 能力。开发环境下 Vite 使用 esbuild,也支持基本的 Tree Shaking。

Webpack 配置示例

webpack.config.ts
import type { Configuration } from 'webpack';

const config: Configuration = {
mode: 'production', // 开启 Tree Shaking
optimization: {
usedExports: true, // 标记未使用的导出
minimize: true, // 启用压缩(Terser 删除未使用代码)
concatenateModules: true, // Scope Hoisting
},
};

export default config;

Rollup 配置示例

rollup.config.ts
import type { RollupOptions } from 'rollup';
import typescript from '@rollup/plugin-typescript';

const config: RollupOptions = {
input: 'src/index.ts',
output: {
file: 'dist/bundle.js',
format: 'esm', // 输出 ESM 格式
},
plugins: [typescript()],
// Rollup 默认就会进行 Tree Shaking,无需额外配置
};

export default config;

如何验证 Tree Shaking 是否生效

1. Webpack Bundle Analyzer

使用 webpack-bundle-analyzer 可视化分析打包结果:

npm install webpack-bundle-analyzer --save-dev
webpack.config.ts
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';

export default {
plugins: [
new BundleAnalyzerPlugin(), // 会打开一个交互式页面展示包内容
],
};

2. 检查打包产物

直接搜索未使用函数的名称,确认它不在最终产物中:

# 在 Webpack 产物中搜索目标函数名
grep -r "unusedFunction" dist/
# 如果没有搜索结果,说明 Tree Shaking 生效

3. Rollup 的 Tree Shaking 提示

Rollup 在打包时会输出警告,提示哪些导出未被使用:

(!) Generated an empty chunk: "unused-module"

4. 使用 stats 输出

webpack.config.ts
export default {
stats: {
usedExports: true, // 在构建输出中显示 used/unused exports
},
};

5. 对比产物大小

打包前后对比产物大小是最直观的方式:

check-bundle-size.ts
// 在 package.json 中添加分析脚本
// "scripts": {
// "build": "webpack --mode production",
// "analyze": "webpack --mode production --profile --json > stats.json"
// }

// 使用 source-map-explorer 分析
// npx source-map-explorer dist/main.js

常见面试问题

Q1: Tree Shaking 的原理是什么?为什么必须使用 ESM?

答案

Tree Shaking 的原理是基于 ES Module 的静态结构进行分析。整个过程分为两个阶段:

  1. 标记阶段:从入口文件出发,递归分析所有 import 语句,构建模块依赖图。对每个模块的每个 export,检查是否被其他模块 import,未被引用的标记为"unused"
  2. 删除阶段:在压缩阶段(Terser/SWC),将标记为"unused"的代码从产物中移除

必须使用 ESM 的原因

ESM 的 import/export静态声明,具有以下约束:

  • 必须在模块顶层,不能在条件语句中
  • 模块路径必须是字符串字面量,不能是变量
  • 导入的绑定是只读的

这些约束使构建工具可以在编译时确定模块依赖关系。而 CJS 的 require() 是一个运行时函数调用,参数可以是变量、可以在条件语句中调用,构建工具无法在编译时确定依赖关系:

对比示例
// ESM — 编译时就能确定依赖
import { add } from './math';

// CJS — 运行时才能确定
const modulePath = getModulePath(); // 动态计算路径
const math = require(modulePath); // 无法静态分析

Q2: 什么情况下 Tree Shaking 会失效?如何解决?

答案

Tree Shaking 失效的常见场景及解决方案:

失效场景原因解决方案
使用 CJS 模块require() 是动态的使用 ESM 版本(如 lodash-es
Babel 转换 ESM 为 CJS@babel/preset-env 默认转换模块设置 "modules": false
模块有副作用构建工具不敢移除有副作用的代码配置 sideEffects 字段
类方法无法移除方法挂在原型链上,无法单独移除改用独立函数导出
import * 命名空间导入部分工具无法分析属性访问使用具名导入 import { fn }
函数调用有副作用构建工具无法确定函数是否纯净使用 /*#__PURE__*/ 标注

一个综合的最佳实践配置:

package.json
{
"sideEffects": ["*.css", "*.scss"]
}
.babelrc
{
"presets": [["@babel/preset-env", { "modules": false }]]
}
webpack.config.ts
import type { Configuration } from 'webpack';

const config: Configuration = {
mode: 'production',
optimization: {
usedExports: true,
minimize: true,
concatenateModules: true,
},
};

Q3: package.json 的 sideEffects 字段有什么作用?

答案

sideEffects 字段用于告诉 Webpack 等构建工具,该包中哪些文件是"纯净"的(没有副作用),从而帮助构建工具更安全、更激进地进行 Tree Shaking。

三种配置方式

1. 所有文件无副作用
{
"sideEffects": false
}
2. 指定有副作用的文件
{
"sideEffects": [
"*.css",
"./src/polyfills.ts",
"./src/setup-global.ts"
]
}
3. 不设置(默认)
{
// 不声明 sideEffects 时,Webpack 假设所有文件都有副作用
// Tree Shaking 效果会大打折扣
}

工作原理

sideEffects: false 时,如果某个模块的导出完全没有被引用,构建工具会直接跳过整个模块,连模块中的顶层代码也不会执行。这就是为什么如果有副作用代码(如 CSS 导入、Polyfill 注册),必须将对应文件标记在 sideEffects 数组中——否则这些文件会被"摇掉",导致样式丢失或全局功能缺失。

示例:sideEffects 对 CSS 的影响
// button.ts
import './button.css'; // 副作用:注入 CSS

export function Button(): string {
return '<button class="btn">Click</button>';
}

如果 Button 没有被使用,且 sideEffects: false

  • button.ts 整个模块被跳过
  • button.css 也不会被打包(样式丢失!)

正确做法是将 CSS 文件列入 sideEffects 数组,或在 Webpack 的 module.rules 中标记 CSS 文件 sideEffects: true

Q4: 哪些写法会导致 Tree Shaking 失效?

答案

Tree Shaking 失效的根本原因只有一个:构建工具无法在编译时确定某段代码是否被使用、或无法确定移除它是否安全。以下是常见的失效写法及解决方案:

1. 使用 CommonJS 模块

cjs-fail.ts
// ❌ CJS 的 require() 是运行时调用,无法静态分析
const { get } = require('lodash'); // 整个 lodash 都会被打包(~72KB gzip)

// ✅ 使用 ESM 版本
import { get } from 'lodash-es'; // 只打包 get 相关代码

2. Babel 将 ESM 转为 CJS

.babelrc — ❌ 错误配置
{
"presets": [
["@babel/preset-env"] // 默认 modules: "auto",会将 ESM 转为 CJS
]
}
.babelrc — ✅ 正确配置
{
"presets": [
["@babel/preset-env", {
"modules": false // 保留 ESM 语法,让 Webpack/Rollup 处理 Tree Shaking
}]
]
}

3. 模块顶层存在副作用代码

side-effect-fail.ts
// 模块加载时就执行了副作用
// 即使 pureFunction 没被使用,这个模块也不能被移除
window.__APP_VERSION__ = '1.0.0';
Array.prototype.last = function () { return this[this.length - 1]; };

export function pureFunction(): string {
return 'hello';
}
✅ 封装为函数,消除顶层副作用
export function initApp(): void {
window.__APP_VERSION__ = '1.0.0';
}

export function pureFunction(): string {
return 'hello';
}

4. 类的方法无法被单独 Tree Shake

class-fail.ts
// 类的方法挂在原型链上,构建工具无法安全移除单个方法
export class MathUtils {
add(a: number, b: number): number { return a + b; }
subtract(a: number, b: number): number { return a - b; }
multiply(a: number, b: number): number { return a * b; }
}
// 即使只用了 add,subtract 和 multiply 也会被保留

// ✅ 改用独立函数导出
export function add(a: number, b: number): number { return a + b; }
export function subtract(a: number, b: number): number { return a - b; }
export function multiply(a: number, b: number): number { return a * b; }

5. 动态属性访问

dynamic-access.ts
import * as utils from './utils';

// ❌ 动态属性访问,构建工具无法确定使用了哪个方法
const methodName = getMethodName();
utils[methodName]();

// ✅ 使用具名导入
import { specificMethod } from './utils';
specificMethod();

6. re-export(桶文件)与副作用的组合

barrel-file.ts
// index.ts(barrel 文件)
export * from './moduleA'; // 如果 moduleA 有顶层副作用
export * from './moduleB';
export * from './moduleC';
// 即使只导入了 moduleB 的内容,moduleA 的副作用代码也会被保留

// ✅ 使用具体路径导入,避免经过 barrel 文件
import { someFunction } from './moduleB';

7. 函数调用结果被视为有副作用

function-call-fail.ts
// 构建工具无法确定 createLogger() 是否有副作用
const logger = createLogger(); // 即使 logger 未使用,这行也不会被移除

// ✅ 使用 /*#__PURE__*/ 标注
const logger = /*#__PURE__*/ createLogger();
// 如果 logger 未被使用,Terser 会安全地移除这行

失效场景速查表

失效场景根因解决方案
使用 CJS 模块动态导入无法静态分析换用 ESM 版本
Babel 转换 ESM模块语法被转为 CJS设置 modules: false
顶层副作用代码移除可能破坏功能封装为函数 / 配置 sideEffects
类方法原型链方法无法单独移除改用独立函数导出
动态属性访问无法确定访问了哪个属性使用具名导入
barrel 文件 + 副作用re-export 引入有副作用的模块直接从具体文件导入
函数调用无 PURE 标注无法确定是否有副作用添加 /*#__PURE__*/

Q5: sideEffects 字段的作用是什么?

答案

sideEffectspackage.json 中的一个字段,用于告诉 Webpack 等构建工具哪些文件是"纯净"的(没有副作用),从而帮助构建工具更安全、更激进地进行 Tree Shaking。

什么是"副作用"(Side Effect)?

在 Tree Shaking 语境下,"副作用"指的是模块被 import 时会自动执行一些影响外部环境的操作

有副作用的模块示例
// ① 修改全局变量
window.__APP_INIT__ = true;

// ② 注入 CSS
import './global.css';

// ③ 注册 Polyfill
import 'core-js/stable';

// ④ 修改原型链
Array.prototype.customFilter = function() { /* ... */ };

// ⑤ 执行 IIFE
(function() {
// 初始化逻辑
})();

这些代码即使没有被显式引用,删除它们也会导致功能异常。因此构建工具默认不敢移除这些模块。

三种配置方式

1. 所有文件无副作用 —— 最激进
{
"sideEffects": false
}
2. 指定有副作用的文件 —— 推荐
{
"sideEffects": [
"*.css",
"*.scss",
"*.less",
"./src/polyfills.ts",
"./src/register-global.ts"
]
}
3. 不设置 —— 默认
{
// 未声明 sideEffects → Webpack 认为所有文件都可能有副作用
// Tree Shaking 效果大打折扣
}

工作原理图示

实际案例:CSS 文件丢失问题

这是最常见的 sideEffects 配置错误:

button.ts
import './button.css';  // 副作用:注入样式

export function Button(): string {
return '<button class="btn">Click me</button>';
}
main.ts
// 假设 main.ts 没有使用 Button
import { OtherComponent } from './components';

sideEffects: false 时:

  • Button 未被引用 → button.ts 整个模块被跳过
  • button.css 也不会被打包样式丢失!

正确做法:

package.json
{
"sideEffects": ["*.css", "*.scss"]
}

在 Webpack module.rules 中配置

除了 package.json,还可以在 Webpack 配置中对特定文件类型标记副作用:

webpack.config.ts
import type { Configuration } from 'webpack';

const config: Configuration = {
module: {
rules: [
{
test: /\.tsx?$/,
sideEffects: false, // TS 文件标记为无副作用
},
{
test: /\.css$/,
sideEffects: true, // CSS 文件标记为有副作用
use: ['style-loader', 'css-loader'],
},
],
},
};
发布 npm 包时务必正确配置

如果你在发布一个 npm 包,sideEffects 字段的正确配置直接影响消费方的打包体积。错误配置 sideEffects: false 会导致消费方的 CSS 或 Polyfill 被意外移除;不配置 sideEffects 则会导致消费方无法有效 Tree Shake 你的库。

Q6: CSS 的 Tree Shaking 怎么做?(PurgeCSS / Tailwind)

答案

JS 的 Tree Shaking 基于 ESM 静态分析,但 CSS 不是模块系统,没有 import/export 的概念。CSS 的 Tree Shaking(更准确地说是 CSS Purging)采用的是完全不同的策略:扫描 HTML/JS/模板文件中实际使用的 CSS 选择器,移除未使用的 CSS 规则

核心原理

方案一:PurgeCSS —— 通用方案

PurgeCSS 是一个独立的 CSS Tree Shaking 工具,可以配合任何构建工具使用。

postcss.config.ts
import purgecss from '@fullhuman/postcss-purgecss';

export default {
plugins: [
purgecss({
// 指定扫描哪些文件中的选择器
content: [
'./src/**/*.tsx',
'./src/**/*.ts',
'./src/**/*.html',
'./src/**/*.vue',
],

// 默认的提取器(正则匹配类名)
defaultExtractor: (content: string): string[] => {
return content.match(/[\w-/:]+(?<!:)/g) || [];
},

// 安全列表:永远不要移除这些选择器
safelist: {
standard: ['html', 'body', /^router-/],
deep: [/^el-/], // Element Plus 组件的类名
greedy: [/^ant-/], // Ant Design 组件的类名
},
}),
],
};

在 Webpack 中使用

webpack.config.ts
import PurgeCSSPlugin from 'purgecss-webpack-plugin';
import glob from 'glob';
import path from 'path';

const config = {
plugins: [
new PurgeCSSPlugin({
paths: glob.sync(`${path.join(__dirname, 'src')}/**/*`, { nodir: true }),
safelist: {
standard: [/^ant-/, /^el-/],
},
}),
],
};

方案二:Tailwind CSS 内置的 CSS Purging

Tailwind CSS 是最典型的 CSS Tree Shaking 实践。完整的 Tailwind CSS 框架有数 MB 的 CSS,但通过内置的 purging 机制,最终产物通常只有 几 KB ~ 几十 KB

tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
// Tailwind 会扫描这些文件,只生成实际使用的工具类
content: [
'./src/**/*.{ts,tsx}',
'./src/**/*.html',
'./public/index.html',
],
theme: {
extend: {},
},
plugins: [],
};

export default config;
体积对比
// Tailwind CSS 完整框架
// 未 Purge:~3.7MB(开发时所有工具类)

// 经过 content 扫描后的最终产物
// Purge 后:~8KB gzip(只保留项目中实际使用的类名)

Tailwind 的 Purging 原理

  1. 扫描文件内容:根据 content 配置,读取所有指定文件的文本内容
  2. 正则提取类名:使用正则表达式提取所有可能的 CSS 类名字符串
  3. 匹配生成:只生成被匹配到的工具类 CSS,未使用的直接不生成
Tailwind 类名检测示例
// Tailwind 会检测到以下类名
function Card(): JSX.Element {
return (
// "bg-white", "rounded-lg", "p-4", "shadow-md" 被检测到
<div className="bg-white rounded-lg p-4 shadow-md">
<h2 className="text-xl font-bold">标题</h2>
<p className="text-gray-600 mt-2">内容</p>
</div>
);
}
Tailwind 动态类名的陷阱

Tailwind 的 purging 基于文本匹配而非 AST 分析,所以动态拼接的类名会导致 purging 失败:

动态类名问题
// ❌ 动态拼接类名 → Tailwind 无法检测
const color = 'red';
const className = `text-${color}-500`; // "text-red-500" 不会被检测到

// ✅ 使用完整的类名字符串
const colorClassMap: Record<string, string> = {
red: 'text-red-500',
blue: 'text-blue-500',
green: 'text-green-500',
};
const className = colorClassMap[color]; // 完整类名会被检测到

方案三:UnoCSS —— 按需生成

UnoCSS 采用了与 PurgeCSS 相反的思路——不是先生成全部 CSS 再删除未使用的,而是只生成用到的 CSS(按需原子化):

uno.config.ts
import { defineConfig, presetUno, presetAttributify } from 'unocss';

export default defineConfig({
presets: [
presetUno(), // 默认预设(兼容 Tailwind/Windi)
presetAttributify(), // 属性化模式
],
// UnoCSS 只会生成项目中实际出现的 CSS 规则
// 构建速度比 PurgeCSS 更快(无需先生成再删除)
});

三种方案对比

对比维度PurgeCSSTailwind CSSUnoCSS
策略先生成全部 CSS,再移除未使用的扫描文件,只生成使用到的工具类按需生成,只产出用到的规则
适用场景任何 CSS 框架Tailwind 项目原子化 CSS 项目
配置复杂度中等(需配置 safelist)简单(内置)简单
构建速度较慢(先全量生成再过滤)中等最快(按需生成)
误删风险有(需仔细配 safelist)低(仅 Tailwind 类名)
面试总结

CSS Tree Shaking 的核心思路是内容匹配——扫描 HTML/JS/模板文件中出现的选择器字符串,然后只保留被匹配到的 CSS 规则。PurgeCSS 是通用方案("全生成再删除"),Tailwind 内置了这一机制,而 UnoCSS 则采用更高效的"按需生成"策略。


相关链接