Tree Shaking
问题
什么是 Tree Shaking?它的原理是什么?如何确保 Tree Shaking 生效?
答案
Tree Shaking 是一种基于 ES Module 静态分析的死代码消除(Dead Code Elimination, DCE)技术。它的核心思想是:在打包阶段分析模块之间的依赖关系,找出哪些导出(export)没有被任何地方引用,然后将这些未使用的代码从最终产物中移除,从而减小打包体积。
"Tree Shaking"这个名称来源于一个形象的比喻——把模块依赖看作一棵树,摇晃这棵树,那些没有被引用的"枯叶"就会掉落下来,最终只保留真正被使用的代码。
- DCE(Dead Code Elimination):传统编译器优化,移除永远不会执行的代码(如
if (false) { ... }) - Tree Shaking:更进一步,移除虽然被定义但从未被使用的模块导出。它是 DCE 在模块层面的扩展
原理详解
基于 ESM 的静态分析
Tree Shaking 能够工作的前提是 ES Module(ESM)的静态结构。ESM 的 import 和 export 具有以下特性:
- 必须出现在模块顶层,不能嵌套在条件语句或函数中
- 模块标识符必须是字符串字面量,不能是变量
- 导入绑定是不可变的(immutable binding)
这些特性使得构建工具能够在**编译时(静态阶段)**就确定模块之间的依赖关系,而无需实际执行代码。
// ✅ ESM:静态声明,编译时即可分析
import { add } from './math'; // 编译时就能确定只使用了 add
// ❌ CJS:动态调用,只有运行时才能确定
const math = require('./math'); // 运行时才知道用了哪些方法
标记(Mark)与删除(Sweep)
Tree Shaking 的过程可以分为两个阶段,类似于垃圾回收中的"标记-清除"算法:
- 标记阶段(Mark):构建工具从入口文件开始,递归分析所有
import语句,标记每个模块中被引用的导出。没有被引用的导出会被标记为"未使用" - 删除阶段(Sweep):在代码压缩阶段(通常由 Terser 或 SWC 执行),将标记为"未使用"的代码从最终产物中移除
具体示例
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;
}
// 只导入了 add,subtract 和 multiply 未被使用
import { add } from './math';
console.log(add(1, 2));
// 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 允许动态 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') - 注册 Polyfill(
import 'core-js/stable') - 执行立即调用函数表达式(IIFE)
即使模块没有导出任何内容,这些副作用代码也不能被移除,否则会导致功能异常。
package.json 中的 sideEffects 字段
Webpack 引入了 sideEffects 字段,用于告诉构建工具哪些文件是"纯净"的(没有副作用),可以安全地进行 Tree Shaking。
{
"name": "my-library",
"version": "1.0.0",
"sideEffects": false
}
当设置为 false 时,构建工具会认为该包中所有未被引用的模块都可以安全移除。
标记有副作用的文件
如果包中部分文件有副作用(如 CSS 文件、Polyfill),可以用数组指定:
{
"name": "my-library",
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.ts",
"./src/register-global.ts"
]
}
如果你的库中有副作用代码但错误地设置了 sideEffects: false,这些副作用代码会被 Tree Shaking 移除,导致运行时错误。务必仔细检查每个文件是否真的没有副作用。
Webpack 中的模块级 sideEffects
除了 package.json,Webpack 还支持在 module.rules 中配置:
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 模块
// 整个 lodash 都会被打包(约 72KB gzip)
const { get } = require('lodash');
// ✅ 使用 ESM 版本
import { get } from 'lodash-es'; // 只打包 get 函数
2. 代码存在副作用
// 模块顶层执行了副作用代码
// 即使没有导出被引用,整个文件也不会被移除
Array.prototype.customMethod = function() {
return this.filter(Boolean);
};
export function pureFunction(): string {
return 'hello';
}
3. 导入方式不当
// ❌ 命名空间导入:打包工具可能无法确定哪些属性被使用
import * as utils from './utils';
utils.someFunction();
// ✅ 具名导入:明确声明使用了哪个导出
import { someFunction } from './utils';
someFunction();
现代打包工具(Webpack 5、Rollup、Vite)对 import * 的 Tree Shaking 支持已经较好,但某些边界情况仍可能失效。建议始终使用具名导入以获得最可靠的 Tree Shaking 效果。
4. Babel 将 ESM 转成 CJS
{
"presets": [
["@babel/preset-env"] // 默认会将 ESM 转为 CJS
]
}
{
"presets": [
["@babel/preset-env", {
"modules": false // 保留 ESM 语法,交给 Webpack/Rollup 处理
}]
]
}
5. 类的方法无法被 Tree Shake
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)的陷阱
// ❌ barrel 文件可能导致所有模块都被打包
export * from './moduleA';
export * from './moduleB';
export * from './moduleC';
// 如果 moduleA 有副作用,即使只导入了 moduleB 的内容,
// moduleA 也会被包含
最佳实践
1. 使用 ESM 格式发布 npm 包
在 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. 避免副作用代码
// ❌ 模块顶层的副作用
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__*/ 注释告诉压缩工具:该函数调用是纯的(没有副作用),如果返回值未被使用,可以安全移除。
// 没有标注时,构建工具不确定 createLogger() 是否有副作用
const logger = createLogger();
// 使用 /*#__PURE__*/ 标注后,如果 logger 未被使用,整行可被移除
const logger = /*#__PURE__*/ createLogger();
// 常见于库的源码中
export const MyComponent = /*#__PURE__*/ React.memo(function MyComponent() {
return <div>Hello</div>;
});
5. 优先使用具名导入
// ❌ 默认导入整个库
import _ from 'lodash';
// ❌ 命名空间导入
import * as _ from 'lodash';
// ✅ 具名导入
import { debounce, throttle } from 'lodash-es';
// ✅ 子路径导入(部分库支持)
import debounce from 'lodash-es/debounce';
Webpack vs Rollup 的 Tree Shaking 对比
| 对比维度 | Webpack | Rollup |
|---|---|---|
| Tree Shaking 触发 | mode: 'production' 时启用 | 默认启用 |
| 实现方式 | 标记 unused + Terser 删除 | 只包含(include)被引用的代码 |
| 代码作用域 | 模块包裹在函数中(module scope) | 扁平化合并(Scope Hoisting) |
| 副作用处理 | 依赖 sideEffects 字段 | 更激进的静态分析 |
| 效果 | 良好(需正确配置) | 更优(天然适合库打包) |
| 适用场景 | 应用打包 | 库打包 |
| Scope Hoisting | 需手动启用 ModuleConcatenationPlugin | 默认行为 |
Vite 在生产构建时使用 Rollup 作为打包工具,因此继承了 Rollup 优秀的 Tree Shaking 能力。开发环境下 Vite 使用 esbuild,也支持基本的 Tree Shaking。
Webpack 配置示例
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 配置示例
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
- Yarn
- pnpm
- Bun
npm install webpack-bundle-analyzer --save-dev
yarn add webpack-bundle-analyzer --dev
pnpm add webpack-bundle-analyzer --save-dev
bun add webpack-bundle-analyzer --dev
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 输出
export default {
stats: {
usedExports: true, // 在构建输出中显示 used/unused exports
},
};
5. 对比产物大小
打包前后对比产物大小是最直观的方式:
// 在 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 的静态结构进行分析。整个过程分为两个阶段:
- 标记阶段:从入口文件出发,递归分析所有
import语句,构建模块依赖图。对每个模块的每个export,检查是否被其他模块import,未被引用的标记为"unused" - 删除阶段:在压缩阶段(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__*/ 标注 |
一个综合的最佳实践配置:
{
"sideEffects": ["*.css", "*.scss"]
}
{
"presets": [["@babel/preset-env", { "modules": false }]]
}
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。
三种配置方式:
{
"sideEffects": false
}
{
"sideEffects": [
"*.css",
"./src/polyfills.ts",
"./src/setup-global.ts"
]
}
{
// 不声明 sideEffects 时,Webpack 假设所有文件都有副作用
// Tree Shaking 效果会大打折扣
}
工作原理:
当 sideEffects: false 时,如果某个模块的导出完全没有被引用,构建工具会直接跳过整个模块,连模块中的顶层代码也不会执行。这就是为什么如果有副作用代码(如 CSS 导入、Polyfill 注册),必须将对应文件标记在 sideEffects 数组中——否则这些文件会被"摇掉",导致样式丢失或全局功能缺失。
// 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 的 require() 是运行时调用,无法静态分析
const { get } = require('lodash'); // 整个 lodash 都会被打包(~72KB gzip)
// ✅ 使用 ESM 版本
import { get } from 'lodash-es'; // 只打包 get 相关代码
2. Babel 将 ESM 转为 CJS
{
"presets": [
["@babel/preset-env"] // 默认 modules: "auto",会将 ESM 转为 CJS
]
}
{
"presets": [
["@babel/preset-env", {
"modules": false // 保留 ESM 语法,让 Webpack/Rollup 处理 Tree Shaking
}]
]
}
3. 模块顶层存在副作用代码
// 模块加载时就执行了副作用
// 即使 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
// 类的方法挂在原型链上,构建工具无法安全移除单个方法
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. 动态属性访问
import * as utils from './utils';
// ❌ 动态属性访问,构建工具无法确定使用了哪个方法
const methodName = getMethodName();
utils[methodName]();
// ✅ 使用具名导入
import { specificMethod } from './utils';
specificMethod();
6. re-export(桶文件)与副作用的组合
// index.ts(barrel 文件)
export * from './moduleA'; // 如果 moduleA 有顶层副作用
export * from './moduleB';
export * from './moduleC';
// 即使只导入了 moduleB 的内容,moduleA 的副作用代码也会被保留
// ✅ 使用具体路径导入,避免经过 barrel 文件
import { someFunction } from './moduleB';
7. 函数调用结果被视为有副作用
// 构建工具无法确定 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 字段的作用是什么?
答案:
sideEffects 是 package.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() {
// 初始化逻辑
})();
这些代码即使没有被显式引用,删除它们也会导致功能异常。因此构建工具默认不敢移除这些模块。
三种配置方式:
{
"sideEffects": false
}
{
"sideEffects": [
"*.css",
"*.scss",
"*.less",
"./src/polyfills.ts",
"./src/register-global.ts"
]
}
{
// 未声明 sideEffects → Webpack 认为所有文件都可能有副作用
// Tree Shaking 效果大打折扣
}
工作原理图示:
实际案例:CSS 文件丢失问题
这是最常见的 sideEffects 配置错误:
import './button.css'; // 副作用:注入样式
export function Button(): string {
return '<button class="btn">Click me</button>';
}
// 假设 main.ts 没有使用 Button
import { OtherComponent } from './components';
当 sideEffects: false 时:
Button未被引用 →button.ts整个模块被跳过button.css也不会被打包 → 样式丢失!
正确做法:
{
"sideEffects": ["*.css", "*.scss"]
}
在 Webpack module.rules 中配置:
除了 package.json,还可以在 Webpack 配置中对特定文件类型标记副作用:
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 包,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 工具,可以配合任何构建工具使用。
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 中使用:
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:
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 原理:
- 扫描文件内容:根据
content配置,读取所有指定文件的文本内容 - 正则提取类名:使用正则表达式提取所有可能的 CSS 类名字符串
- 匹配生成:只生成被匹配到的工具类 CSS,未使用的直接不生成
// 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 的 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(按需原子化):
import { defineConfig, presetUno, presetAttributify } from 'unocss';
export default defineConfig({
presets: [
presetUno(), // 默认预设(兼容 Tailwind/Windi)
presetAttributify(), // 属性化模式
],
// UnoCSS 只会生成项目中实际出现的 CSS 规则
// 构建速度比 PurgeCSS 更快(无需先生成再删除)
});
三种方案对比:
| 对比维度 | PurgeCSS | Tailwind CSS | UnoCSS |
|---|---|---|---|
| 策略 | 先生成全部 CSS,再移除未使用的 | 扫描文件,只生成使用到的工具类 | 按需生成,只产出用到的规则 |
| 适用场景 | 任何 CSS 框架 | Tailwind 项目 | 原子化 CSS 项目 |
| 配置复杂度 | 中等(需配置 safelist) | 简单(内置) | 简单 |
| 构建速度 | 较慢(先全量生成再过滤) | 中等 | 最快(按需生成) |
| 误删风险 | 有(需仔细配 safelist) | 低(仅 Tailwind 类名) | 无 |
CSS Tree Shaking 的核心思路是内容匹配——扫描 HTML/JS/模板文件中出现的选择器字符串,然后只保留被匹配到的 CSS 规则。PurgeCSS 是通用方案("全生成再删除"),Tailwind 内置了这一机制,而 UnoCSS 则采用更高效的"按需生成"策略。