前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Rspack 作者揭秘,你的 Tree Shaking 真的起作用了吗?

Rspack 作者揭秘,你的 Tree Shaking 真的起作用了吗?

作者头像
童欧巴
发布2024-05-09 15:42:55
1140
发布2024-05-09 15:42:55
举报
文章被收录于专栏:前端食堂前端食堂

“原文链接:https://github.com/orgs/web-infra-dev/discussions/17 作者:hardfist 翻译润色:童欧巴 ”

本文主要探讨了 Webpack Tree Shaking 的基本概念,而非深入其底层代码实现。相关的代码示例可以在 这里[1] 查看。

Webpack Tree Shaking 的复杂之处在于它需要多种优化技术的共同作用。Webpack 对 “Tree Shaking” 这一术语的使用并不严格,通常泛指用于消除无用代码的优化。

Tree Shaking 的定义如下:

“Tree Shaking 是 JavaScript 中用于消除死代码的一个常用术语。 它基于 ES2015 模块语法的静态结构,即 import 和 export。 这个概念及其名称由 ES2015 模块打包工具 rollup 推广。 ”

在某些情境下,像 usedExports 这样的优化也被认为是 tree shaking 和 sideEffects[2] 的一部分:

“sideEffects 和 usedExports(通常更多地被称作 tree shaking)是两种不同的优化策略。 ”

为了避免对 Tree Shaking 的理解产生任何歧义,本文不会将焦点放在 Tree Shaking 本身,而是探讨在 Webpack Tree Shaking 类别下的多种代码优化技术。

Webpack Tree Shaking 主要包括三种优化方式:

  1. usedExports 优化:移除模块中未使用的导出变量,进一步清除相关的无副作用语句。
  2. sideEffects 优化:从模块图中移除没有使用导出变量的模块。
  3. DCE (死代码消除) 优化:通常由常规的代码压缩工具来实现,用于移除无用代码,但类似的功能也可通过 Webpack 的 ConstPlugin 等工具实现。

这些优化分别作用于不同层面:usedExports 针对导出变量,sideEffects 针对整个模块,而 DCE 则针对 JavaScript 语句。

考虑以下几个例子:

  • lib.js 中,变量 b 没有被使用,并因 usedExports 优化而未出现在最终输出中。
  • util.js 中,没有使用任何导出变量,因此该模块由于 sideEffects 优化而未在最终输出中出现。
  • bootstrap.js 中,console.log 语句不会执行,因此在最终输出中被移除,这是 DCE 优化的效果。
代码语言:javascript
复制
// index.js
import { a } from './lib';
import { c } from './util';
import './bootstrap';

console.log(a);

// lib.js
export const a = 1;
export const b = 2;

// util.js
export const c = 3;
export const d = 4;

// bootstrap.js
console.log('bootstrap');
if(false){
   console.log('bad');
}else {
   console.log('good');
}

这些优化虽然是独立进行的,但它们之间可以产生相互影响。接下来,我们将详细解释这些优化措施及它们之间的关系。

DCE 优化

在 Webpack 中,死代码消除 (DCE) 的过程相对直接,主要涉及两个重要场景:

假分支

代码语言:javascript
复制
if(false){ 
   false_branch;
} else { 
   true_branch;
}

在这种情况下,由于假分支(false_branch)根本不会被执行,因此可以直接将其删除。这样做主要有两个效果:一是减少最终代码的大小,二是改变变量的使用关系。考虑以下示例:

代码语言:javascript
复制
import { a } from './a';
if(false){
  console.log(a);
}else {
  
}

如果不移除假分支,变量 a 会被认为是在使用中。将其删除后,a 就会被标记为未使用,这种变化还会进一步影响对 usedExports 和 sideEffects 的分析。针对这种情况,Webpack 提供了两个进行死代码消除(DCE)的机会:

  • 在解析阶段,通过 ConstPlugin 执行基本的死代码消除,这有助于尽可能多地了解导入与导出变量的使用情况,从而优化后续的 sideEffect 和 usedExport。
  • 在 processAssets 阶段,通过 Terser 的 minify 实施更复杂的死代码消除,主要目的是减少代码体积。

相比之下,Terser 执行的死代码消除更为耗时且复杂,而 ConstPlugin 的优化过程则相对简单。例如,Terser 能成功移除处理过的假分支,但 ConstPlugin 可能做不到这一点。

代码语言:javascript
复制
function get_one(){
  return 1;
}
let res = get_one() + get_one();

if(res != 2){
  console.log(c);
}

未使用的顶层声明

在模块中,若顶层声明未被导出,则可将其移除,因为它不产生额外的副作用。例如,以下的变量 b 和函数 test(假定这是模块而非脚本;脚本会影响全局作用域,因此不能安全移除)可以被安全地删除。Webpack 的 usedExports 优化正是利用了这一点来简化其实现过程。

代码语言:javascript
复制
// index.js
export const a = 10;
const b = 20;
function test(){

}

usedExports 优化

相较于其他打包工具的类似优化措施,Webpack 的 usedExports 优化非常巧妙。它利用依赖项的活动状态来判断模块内部的变量是否被使用。然后,在代码生成阶段,如果某个导出变量未被使用,Webpack 就不会为其生成相应的导出属性,这使得依赖这些导出变量的代码段变成了死代码。这种方法在后续的死代码消除(DCE)最小化过程中得到了进一步的加强。

Webpack 通过 optimization.usedExports 配置项来启用 usedExports 优化。考虑以下示例:

代码语言:javascript
复制
// index.js
import { a } from './lib';
console.log({a});
// lib.js
export const a = 1;
export const b = 2;

未启用树摇(tree shaking)时,你可以注意到输出结果中包含了关于变量 b 的信息:

代码语言:javascript
复制
var __webpack_modules__ = [ , (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, {
        a: () => a,
        b: () => b  // b is not removed
    });
    const a = 1;
    const b = 2;
} ];

当启用 optimization.usedExports 后,你会发现变量 b 的导出已被删除,但 const b = 2 的声明仍然存在。然而,由于变量 b 并未被使用,const b = 2 也就成了死代码:

代码语言:javascript
复制
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   a: () => (/* binding */ a)
/* harmony export */ });
/* unused harmony export b */
const a = 1;
const b = 2; // this is actually dead code

/***/ })

进一步通过启用 optimization.usedExports 来实现压缩,由于 const b = 2 是死代码,它被移除了:

代码语言:javascript
复制
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    __webpack_require__.d(__webpack_exports__, {
        a: () => a
    });
    const a = 1;
}, __webpack_module_cache__ = {};

然而,判断 b 是否被使用并非总是简单明了。考虑以下例子:

代码语言:javascript
复制
// index.js
import { a,b } from './lib';
console.log({a});
function test(){
  console.log(b);
}
function test1(){
  test();
}

// lib.js
export const a = 1;
export const b = 2;

在这个示例中,b 被函数 test 所使用,因此我们看到 b 没有被直接从输出中删除。这是因为 Webpack 默认不进行深度静态分析。虽然函数 test 没有被使用,这暗示了 b 也未被使用,但 Webpack 并未能识别出这种关联:

代码语言:javascript
复制
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    __webpack_require__.d(__webpack_exports__, {
        a: () => a,
        b: () => b
    });
    const a = 1, b = 2;
}, __webpack_module_cache__ = {}

幸运的是,Webpack 提供了另一种配置项 optimization.innerGraph,这使得可以对代码进行更深入的静态分析。通过这种方式,可以确定 b 实际上未被使用,从而成功地移除了 b 的导出属性:

代码语言:javascript
复制
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   a: () => (/* binding */ a)
/* harmony export */ });
/* unused harmony export b */
const a = 1;
const b = 2;

/***/ })

死代码消除(DCE)也对 usedExports 优化有影响。考虑以下示例:

代码语言:javascript
复制
// index.js
import { a, b, c } from './lib';
console.log({a});
if(false){
  console.log(b);
}
function get_one(){
  return 1;
}
let res = get_one() + get_one();

if(res != 2){
  console.log(c);
}
// lib.js
export const a = 1;
export const b = 2;
export const c = 3;

依靠 Webpack 内置的 ConstPlugin 来执行死代码消除,它成功地移除了变量 b,但由于 ConstPlugin 的处理能力有限,它未能移除变量 c。

代码语言:javascript
复制
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   a: () => (/* binding */ a),
/* harmony export */   c: () => (/* binding */ c)
/* harmony export */ });
/* unused harmony export b */
const a = 1;
const b = 2;
const c = 3;

/***/ })

sideEffects 优化

usedExports 优化虽然专注于导出变量的优化,但 sideEffects 优化则更为全面和高效,其目标是移除整个模块。为了安全地移除一个模块,必须确保该模块的所有导出变量都未被使用,并且该模块不产生任何副作用。

Webpack 通过 optimization.sideEffects 配置来启用 sideEffects 优化。以下是一个简单的例子:

代码语言:javascript
复制
// index.js
import { a } from './lib';
import { c } from './util';
console.log({a});

// lib.js
export const a = 1;
export const b = 2;

// util.js
export const c = 123;
export const d = 456;

如果未启用 optimization.sideEffects,输出结果将保留 util 模块:

代码语言:javascript
复制
/***/ "./src/lib.js":
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   a: () => (/* binding */ a),
/* harmony export */   b: () => (/* binding */ b)
/* harmony export */ });
const a = 1;
const b = 2;

/***/ }),

/***/ "./src/util.js":
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   c: () => (/* binding */ c),
/* harmony export */   d: () => (/* binding */ d)
/* harmony export */ });
const c = 123;
const d = 456;

/***/ })

当启用 optimization.sideEffects 时,util.js 会从输出中删除。这发生是因为 util 满足了删除所需的两个条件。接下来,我们来看看当违反这些条件时会发生什么:

首先,在 util.js 中引入副作用:

代码语言:javascript
复制
export const c = 123;
export const d = 456;
console.log('hello');

此更改使得 util.js 再次出现在输出结果中。现在,撤销该更改并修改 index.js 来使用 util.js 中的变量 c:

代码语言:javascript
复制
import { a } from './lib';
import { c } from './util';
console.log({a}, c);

这一修改同样使得 util.js 再次出现在输出中。这些实验显示,模块必须同时满足这两个条件才能被安全地移除。确保这些条件得到满足对于在实际应用中有效利用 sideEffect 优化非常关键。

现在,让我们回顾一下模块安全移除所必需的两个条件:

未使用的导出变量

这个条件表面看来简单,但实际上遇到的挑战与 usedExports 优化中遇到的挑战相似,这可能需要进行深入的分析才能确定一个变量的使用情况。

考虑以下示例,变量 c 在函数 test 中被使用,这阻止了 util.js 的成功移除:

代码语言:javascript
复制
// index.js
import { a } from './lib';
import { c } from './util';
console.log({a});
function test(){
  console.log(c);
}

// lib.js
export const a = 1;
export const b = 2;

// util.js
export const c = 123;
export constd = 456;

当启用 optimization.innerGraph 时,Webpack 会进行更深入的分析,确定到函数 test 也未被使用,这也就意味着变量 c 同样未被使用,从而允许正确移除 util.js

sideEffects 属性

与判断一个变量是否被使用相比,判断一个模块是否具有副作用是一个更加复杂的过程。考虑对 util.js 进行以下修改:

代码语言:javascript
复制
export const c = 123;
export const d = test();
function test(){
  return 456;
}

在这个例子中,尽管函数 test 是一个无副作用的函数调用,Webpack 无法确认这一点,因此它仍然将该模块视为可能具有副作用。结果是,util.js 被包括在最终输出中。

为了让 Webpack 知道 test 函数没有副作用,有两种方法可以采用:

  1. 纯注解:通过给函数调用添加一个纯注解,你表明这个函数没有副作用。
代码语言:javascript
复制
export const c = 123;
export const d = /*#__PURE__*/ test();
function test(){
  return 456;
}
  1. sideEffects 属性:当一个模块包含多个顶层声明时,给每个声明标记纯注解可能既繁琐又容易出错。因此,Webpack 引入了 sideEffects 属性来标记整个模块为无副作用的。在模块的 package.json 中添加 "sideEffects": false,可以安全地移除 util.js
代码语言:javascript
复制
// package.json
{
  "sideEffects": false
}

然而,一个被标记为 sideEffect: false 的模块如果依赖于另一个被标记为 sideEffect: true 的模块,这会引起一些问题。考虑这样一个场景:button.js 导入了 button.css,其中 button.js 被标记为 sideEffects: false,而 button.css 被标记为 sideEffects: true

代码语言:javascript
复制
// package.json
{
    "sideEffects": ["**/*.css", "**/side-effect.js"]
}

// a.js
import { Button } from 'antd';

// index.js
import { Button } from './button';

// button.js
import './button.css';
import './side-effect';
export const Button = () => {
  return `<div class="button">btn</div>`
}

// button.css
.button {
  background-color: red;
}

// side-effects.js
console.log('side-effect');

如果 sideEffects 只标记当前模块是否有副作用,根据 ESM 标准,因为 button.cssside-effect.js 都具有副作用,理应被打包。但是,Webpack 的输出结果并未包括 button.cssside-effect.js

因此,sideEffects 字段的真正意义如下:

sideEffects[3] 之所以特别有效,是因为它允许跳过整个模块/文件及其完整的子树。

如果一个模块被标记为 sideEffect: false,这表明如果该模块的导出变量未被使用,则可以安全地移除该模块及其整个子树。这一点解释了为什么在提到的例子中,button.js 及其子树(包括 button.cssside-effect.js)可以被安全删除,这在组件库的情景中尤为重要。

不幸的是,这种行为在不同的打包工具中表现不一。测试表明:

  • Webpack:能够安全地删除子树中含副作用的 CSS 和 JS。
  • esbuild:删除子树中含副作用的 JS,但不处理 CSS。
  • Rollup:不删除子树中含副作用的 JS(不处理 CSS)。

barrel 模块

SideEffects 优化不只是针对叶节点模块,也适用于中间节点。考虑一个常见的情况,某个模块仅作为桥梁,重新导出其他模块的内容。如果这样的模块(这里称作 mid)自身没有任何导出变量被使用,仅用来重新导出其他模块的内容,那么保留这个重新导出的模块是否真的有必要?

代码语言:javascript
复制
// index.js
import { Button } from './components';
console.log('button:', Button);

// components/index.js
export * from './button';
export * from './tab';
export const mid = 'middle';

// components/button.js
export const Button = () => 'button';

测试表明,Webpack 会直接删除这种重新导出的模块,在 index.js 中直接从 button.js 导入内容。

代码语言:javascript
复制
    (() => {
        __webpack_require__.r(__webpack_exports__);
        var _components__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/components/button.js");
        console.log("button:", _components__WEBPACK_IMPORTED_MODULE_0__.Button);
    })();

这种行为看起来就像直接修改了源代码的导入路径:

代码语言:javascript
复制
- import { Button } from './components';
+ import { Button } from './components/button';

像 Next.js 和 UmiJS 这类框架也提供了类似的优化功能,称为“优化包导入”[4]。他们的方法是在加载阶段重写这些路径。需要注意的是,尽管 Webpack 的 barrel 优化侧重于输出,它在构建阶段仍会构建 components/index.js 及其子依赖。然而,Next.js 等使用的技术直接修改源代码,意味着 components/index.js 不参与构建过程。这对于需要重新导出成百上千个子模块的库来说,可以显著提升优化效果。

我们还测试了 esbuild 和 Rollup 在这方面的表现:

  • esbuild:删除 barrel 模块内的副作用。参见示例[5]。
  • Rollup:不删除 barrel 模块内的副作用。参见示例[6]。

调查 Webpack Tree Shaking 问题

在 on-call 时常遇到的一个问题是“为什么我的 Tree shaking 失败了?”这类问题的故障排查通常比较复杂。面临这种问题时,首先会考虑的是“哪一种 Tree shaking 优化未能成功?”这通常可以归结为三个主要类别之一:

SideEffect 优化失败

当一个模块的导出变量未被使用仍被包含在最终的包中时,通常表示 SideEffect 优化失败。

Webpack 有一个鲜为人知的特性,能够通过 stats.optimizationBailout[7] 来调试各种优化放弃的情况,包括 SideEffect 放弃的原因。考虑以下示例:

代码语言:javascript
复制
// index.js
import { a } from './lib';
import { abc } from './util';
console.log({a});

// lib.js
export const a = 1;
export const b = 2;

// util.js
export function abc(){
  console.log('abc');
}
export function def(){
  console.log('def')
}
console.log('xxx');

使用 optimization.sideEffects=truestats.optimizationBailout=true 编译:

Webpack 的日志清晰显示,util.js 中第7行的 console.log('xxx') 导致 SideEffect 优化失败,使得该模块被包含在最终的包中。

如果我们在 package.json 中进一步设置 sideEffects: false,这个警告就会消失,因为一旦设置了 SideEffect 属性,Webpack 将停止副作用分析,而是直接基于 sideEffects 字段进行 SideEffect 优化。

usedExports 优化失败

当一个未被使用的导出变量仍然生成导出属性时,表示 usedExports 优化失败。

在这种情况下,识别这些导出属性的使用位置是必要的:

然而,确定变量的使用原因和具体位置可能并不明确,因为 Webpack 并不提供这方面的详细记录。对于 Webpack 来说,一个可能的改进方向是跟踪并报告在模块树中特定导出变量的使用情况。这将极大地帮助分析和排查 usedExports 优化的问题。

DCE(死代码消除)优化失败

除了 sideEffect 和 usedExports 优化的问题外,大多数其它 Tree shaking 失败可以归因于 DCE 的失败。DCE 失败的常见原因包括使用了 evalnew Function 这样的动态代码结构,这些结构在代码压缩过程中可能导致优化失败。解决这些问题通常与所使用的压缩工具相关,经常需要对输出代码进行二分查找以定位问题。不幸的是,目前的压缩工具很少提供详细的失败原因,这是未来改进的一个重要领域。

总结来说,Webpack 中高效的 Tree Shaking 需要深入理解各种优化措施及其相互作用。通过正确配置和应用这些优化,开发者可以显著降低他们的包体积,从而提升性能和效率。随着 Webpack 和其他打包工具的不断发展,持续的学习和调整是保持最佳应用性能的关键。

参考资料

[1]

这里: https://github.com/hardfist/treeshaking-cases

[2]

tree shaking 和 sideEffects: https://webpack.js.org/guides/tree-shaking/#clarifying-tree-shaking-and-sideeffects

[3]

sideEffects: https://webpack.js.org/guides/tree-shaking/#root

[4]

“优化包导入”: https://vercel.com/blog/how-we-optimized-package-imports-in-next-js#new-solution-optimizepackageimports

[5]

示例: https://github.com/hardfist/treeshaking-cases/blob/main/packages/side-effects/side-effects-skip-barrel/esbuild-dist/index.js

[6]

示例: https://github.com/hardfist/treeshaking-cases/blob/main/packages/side-effects/side-effects-skip-barrel/rollup-dist/index.js

[7]

stats.optimizationBailout: https://webpack.js.org/configuration/stats/#statsoptimizationbailout

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2024-05-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端食堂 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • DCE 优化
    • 假分支
      • 未使用的顶层声明
      • usedExports 优化
      • sideEffects 优化
        • 未使用的导出变量
          • sideEffects 属性
          • barrel 模块
          • 调查 Webpack Tree Shaking 问题
            • SideEffect 优化失败
              • usedExports 优化失败
                • DCE(死代码消除)优化失败
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档