webpack原理(2):ES6 module在Webpack/vite中如何Tree-shaking构建
Author:zhoulujun Date:
Tree Shaking 较早前由 Rich Harris 在 Rollup 中率先实现,Webpack 自 2.0 版本开始接入,至今已经成为一种应用广泛的性能优化手段。
DCE 作用于模块内(webpack 的 DCE 通过 UglifyJS 完成),而 Tree-shaking 则是在打包的时候通过模块之间的信息打包必须的代码。
DCE
AST 对 JS 代码进行语法分析后得出的语法树 (Abstract Syntax Tree)。AST语法树可以把一段 JS 代码的每一个语句都转化为树中的一个节点。
DCE Dead Code Elimination [ɪˌlɪmɪˈneɪʃn],在保持代码运行结果不变的前提下,去除无用的代码。这样的好处是:
减少程序体积
减少程序执行时间
便于将来对程序架构进行优化
而所谓 Dead Code 主要包括:
程序中没有执行的代码 (如不可能进入的分支,return 之后的语句等)
导致 dead variable 的代码(写入变量之后不再读取的代码)
tree shaking 是 rollup 作者首先提出的。相比于排除不使用的代码,tree shaking 其实是找出使用的代码。
tree shaking
可以先回顾下《再唠叨JS模块化加载之CommonJS、AMD、CMD、ES6 》
CommonJS 的设计过于灵活,对静态分析不友好。
ES6 module 则有诸多限制:比如说只能在文件的顶部 import(CommonJS 的 require 语法允许在文件的任意位置调用),export { ... } 语法保证了导出的变量不会是 getter/setter 之类奇怪的东西(这个 block 不是一个 Object),变量也不能被重新绑定。以上种种设计可以让分析器一定程度上判断出导入和导出变量的关系,让这个插件的实现成为了可能。
基于ES6的静态引用,tree shaking 通过扫描所有 ES6 的export,找出被import 的内容并添加到最终代码中。 webpack 的实现是把所有import 标记为有使用/无使用两种,在后续压缩时进行区别处理。
Tree Shaking 只支持 ESM (ES6 Module)的引入方式,不支持 Common JS 的引入方式。
因为在 CommonJs、AMD、CMD 等旧版本的 JavaScript 模块化方案中,导入导出行为是高度动态,难以预测的
if(process.env.NODE_ENV === 'development'){ require('./bar'); exports.foo = 'foo'; }而 ESM 方案则从规范层面规避这一行为,它要求所有的导入导出语句只能出现在模块顶层,且导入导出的模块名必须为字符串常量,
这意味着下述代码在 ESM 方案下是非法的:
if(process.env.NODE_ENV === 'development'){ import bar from 'bar'; export const foo = 'foo'; }所以,ESM 下模块之间的依赖关系是高度确定的,与运行状态无关,编译工具只需要对 ESM 模块做静态分析,就可以从代码字面量中推断出哪些模块值未曾被其它模块使用,这是实现 Tree Shaking 技术的必要条件。
根本原理则是作用域分析。在编译器领域,还有超级多各种高大上的静态分析方法(比如说数据流分析),但是对于 ES 来说,他们实现的难度太大。据我所知,现在还没有针对 JS 的,能在生产环境能用的,基于数据流分析的优化器。这也是为啥现在这些打包器还不能去除没有用到的类成员方法(class method)。webpack tree shaking 只处理顶层内容,例如类和对象内部都不会再被分别处理。
所谓作用域分析,就是可以分析出代码里面变量所属的作用域以及他们之间的引用关系。有了这些信息,就可以推导出导出变量和导入变量之间的引用关系。
而对于 webpack 来说,webpack 可以通过 entry 和 module 之间的调用得知对于一个 module 来说,哪个变量是会被使用到的。
webpack 会从入口文件开始,对import 的代码进行静态分析,如果发现某个模块没有被任何地方使用,就会将该模块标记为 unused harmony exports,并且在生成产物时不再 export 该模块。最后,再将生成产物交给 uglify 或 terser 这样的压缩工具进行处理,此时未被 export 的代码就会被当成死代码删除。 需要注意的是,tree shaking 并不会直接删除代码,只是分析模块依赖关系并去掉未引用代码的 export,真正进行死代码消除的是 uglify 或 terser 这样的压缩工具。
就如同上文的例子 :我的插件可以从 webpack 得知 file1.js 的导出变量 one 被使用了。我的插件通过分析出模块中的作用域,遍历引用到的作用域,找到真正需要 import 的变量,比如说 isNumber,然后再把结果返回 webpack。
使用 ES6 Module:不仅是项目本身,引入的库最好也是 es 版本,比如用 lodash-es 代替 lodash。另外注意 TypeScript 和 Babel 的配置是否会把代码编译成非 es module 版本。
最纯函数调用使用 PURE 注释:由于无法判断副作用,所以对于导出的函数调用最好使用 PURE 注释,不过一般来说有相关的 babel 插件自动添加。
合理模块设计才是减少代码体积的关键!
启用tree shaking
在 Webpack 中,启动 Tree Shaking 功能必须同时满足三个条件:
使用 ESM 规范编写模块代码
配置 optimization.usedExports 为 true,启动标记功能
启动代码优化功能,可以通过如下方式实现:
配置 mode = production:在Webpack的配置中,将mode设置为production模式。这会自动启用一系列的优化功能,包括摇树优化
配置 optimization.minimize = true
提供 optimization.minimizer 数组
// webpack.config.js module.exports = { entry: "./src/index", mode: "production", devtool: false, optimization: { usedExports: true // 启用tree-shaking } }
首先源码必须遵循 ES6 的模块规范 (import&export),如果是 CommonJS 规范 (require) 则无法使用。
在编写支持 tree-shaking 的代码时,导入方式非常重要。你应该避免将整个库导入到单个 JavaScript 对象中。当你这样做时,你是在告诉 Webpack 你需要整个库, Webpack 就不会摇它。
以流行的库 Lodash 为例。一次导入整个库是一个很大的错误,但是导入单个的模块要好得多。当然,Lodash 还需要其他的步骤来做 tree-shaking,但这是个很好的起点。
// 全部导入 (不支持 tree-shaking) import _ from 'lodash'; // 具名导入(支持 tree-shaking) import { debounce } from 'lodash'; // 直接导入具体的模块 (支持 tree-shaking) import debounce from 'lodash/lib/debounce';
webpack 3 和 4 默认支持,webpack2需要特别配置
webpack2
根据 Webpack 官网的提示,webpack2 支持 tree-shaking,需要修改配置文件,指定 babel 处理 js 文件时不要将 ES6 模块转成 CommonJS 模块,具体做法就是:
在 .babelrc 设置 babel-preset-es2015 的 modules 为 fasle,表示不对 ES6 模块进行处理。
// .babelrc { "presets": [ ["es2015", {"modules": false}] ] }
webpack 负责对代码进行标记,把import&export标记为 3 类:
所有import标记为/* harmony import */
被使用过的export标记为/* harmony export ([type]) */,其中[type]和 webpack 内部有关,可能是binding, immutable等等。
没被使用过的export标记为/* unused harmony export [FuncName] */,其中 [FuncName]为export的方法名称
之后在 Uglifyjs (或者其他类似的工具) 步骤进行代码精简,把没用的都删除。
webpack tree shaking副作用
pure_funcs
webpack.config.js 增加参数pure_funcs,告诉webpack 那些函数是没有副作用的,你可以放心删除:
plugins: [ new UglifyJSPlugin({ uglifyOptions: { compress: { pure_funcs: ['Math.floor'] } } }) ],
Math.floor这类全局方法不会重命名,才会生效。因此适用性不算太强。
package.json 的 sideEffects
webpack 4 在 package.json 新增了一个配置项叫做sideEffects, 值为false表示整个包都没有副作用;或者是一个数组列出有副作用的模块。
请注意,设置sideEffects为false可能会导致一些副作用的代码被错误地删除,因此在使用时要谨慎。确保你了解你的代码,并进行适当的测试,以确保没有重要的副作用被错误地优化掉。
详细的例子可以查看 webpack 官方提供的例子。
{ "name": "your-project", "sideEffects": false }
这种方式是通过 package.json 的 "sideEffects" 属性来实现的。
在Webpack配置文件中的module.rules中使用sideEffects选项:
module.exports = { // ... module: { rules: [ { test: /\.js$/, use: 'babel-loader', sideEffects: false } ] } };
在相应的规则中,通过将sideEffects设置为false来指定该规则匹配的文件没有副作用。
配置副作用文件,避免打包压缩的时候误删
"sideEffects": [ "./src/common.js", "*.css" ]
在实际过程中,我们会发现,有些css模块是全局,因为没有申明副作用,也一起被移除,所以需要单独排除掉,比如 global.css模块都没被打包。因为它们都算是副作用模块,而我们在package.json中声明了没有副作用,所以它们就被移除了。
concatenateModule 压缩输出
webpack 4 `mode = 'production'
使用 -p(production) 这个 webpack 编译标记,来启用 uglifyjs 压缩插件。
把本来“每个模块包裹在一个闭包里”的情况,优化成“所有模块都包裹在同一个闭包里”的情况。本身对于代码缩小体积有很大的提升,这里也能侧面解决副作用的问题。
webpack5中的Tree-Shaking
Webpack 5对Tree Shaking进行了改进,可以更有效地删除未使用的代码,从而使打包后的文件更小,加载更快
webpack5的改进
Webpack 4 中的 Tree Shaking 功能在使用上存在限制:只支持 ES6 类型的模块代码分析,且需要相应的依赖包或需要函数声明为无副作用等。这使得在实际项目构建过程中 Tree Shaking 的优化效果往往不尽如人意。而这一问题在 Webpack 5 中得到了不少改善。
Nested Tree Shaking(嵌套模块)
Webpack 5 增加了对嵌套模块的导出跟踪功能,能够找到那些嵌套在最内层而未被使用的模块属性。
例如下面的示例代码,在构建后的结果代码中只包含了引用的内部模块的一个属性,而忽略了不被引用的内部模块和中间模块的其他属性:
//./src/inner-module.js export const a = 'inner_a' export const b = 'inner_b' //.src/nested-module.js import * as inner from './inner-module' const nested = 'nested' export { inner, nested } //./src/example-tree-nested.js import * as nested from './nested-module' console.log(nested.inner.a) //./dist/tree-nest.js (()=>{"use strict";console.log("inner_a")})();
Inner Module Tree Shaking(内部模块)
Webpack 4 没有分析模块的导出和引用之间的依赖关系。
Webpack 5 中还增加了分析模块中导出项与导入项的依赖关系的功能。通过 optimization.innerGraph(生产环境下默认开启)选项,Webpack 5 可以分析特定类型导出项中对导入项的依赖关系,从而找到更多未被使用的导入模块并加以移除。
import { something } from './something'; function usingSomething() { return something; } export function test() { return usingSomething(); }
内部依赖图算法会找出 something 只有在使用 test 导出时才会使用。这允许将更多的出口标记为未使用,并从代码包中省略更多的代码。
当设置"sideEffects": false时,可以省略更多的模块。在这个例子中,当 test 导出未被使用时,./something 将被省略。
要获得未使用的导出信息,需要使用 optimization.unusedExports。要删除无副作用的模块,需要使用optimization.sideEffects。
可以分析以下标记。
函数声明
类声明
默认导出export default 或定义变量以下的:
函数表达式
类表达式
顺序表达式
/*#__PURE__*/ 表达式
局部变量
引入的捆绑(bindings)
使用 eval() 将为一个模块放弃这个优化,因为经过 eval 的代码可以引用范围内的任何标记。
这种优化也被称为深度范围分析。
webpack5,可以进行根据作用域之间的关系进行优化。比如:
a.js 中到处了两个方法 a 和 b,在 index.js 中引入了 a.js 到处的 a 方法,没有引用 b 方法。那么 webpack4 打包出来的结果包含了 index.js 和 a.js 的内容,包含了没有用到的 b 方法。但是 webpack5 的 treeshaking,会进行作用域分析,打包结果只有 index 和 a 文件中的 a 方法,没有用到的 b 方法是不会被打包进来的。
CommonJS Tree Shaking
Webpack 曾经不进行对 CommonJs 导出和 require() 调用时的导出使用分析
Webpack 5 中增加了对一些 CommonJS 风格模块代码的静态分析功功能,允许消除未使用的 CommonJs 导出,并从 require() 调用中跟踪引用的导出名称。
支持以下构造:
exports|this|module.exports.xxx = ...
exports|this|module.exports = require("...") (reexport)
exports|this|module.exports.xxx = require("...").xxx (reexport)
Object.defineProperty(exports|this|module.exports, "xxx", ...)
require("abc").xxx
require("abc").xxx()
从 ESM 导入
require() 一个 ESM 模块
被标记的导出类型 (对非严格 ESM 导入做特殊处理):
Object.defineProperty(exports|this|module.exports, "__esModule", { value: true|!0 })
exports|this|module.exports.__esModule = true|!0
未来计划支持更多的构造
当检测到不可分析的代码时,webpack 会放弃,并且完全不跟踪这些模块的导出信息(出于性能考虑)
vite中的Tree-Shaking
生产模式下vite会默认开启所有优化通过tree-shaking摇掉未使用代码
webpack
Webpack 会遍历你的应用程序中的所有文件,并启动一个开发服务器,然后将整个代码渲染到开发环境中。
webpack 从一个 entry.js 文件开始,将其依赖的所有 js 或者其他 assets 通过 loader 打包成一个文件, 随后这个打包后的文件将被从 server 传递到客户端浏览器运行。
因为这样的处理规则,当保存文件时,整个 JavaScript 包将由 Webpack 重新构建,这就是为什么更改可能需要长达 10 秒才能反映在浏览器中,更新速度会随着应用体积增长而直线下降。
vite
Vite 的工作方式不同,它不会遍历整个应用程序,Vite 只是转换当时正在使用的文件/模块。
Vite 的核心理念:非捆绑开发构建
Vite 的核心思想:浏览器请求它时,使用 ES 模块转换并提供一段应用程序代码。
开始开发构建时,Vite 首先将 JavaScript 模块分为两类:依赖模块和源码模块。
依赖项模块是第三方依赖的代码,从 node_modules 文件夹中导入的 JavaScript 模块。这些模块将使用 esbuild 进行处理和捆绑,esbuild 是一个用 Go 编写的 JavaScript 打包工具,执行速度比 Webpack 快 10-100 倍。
源码模块是源代码,即业务代码,通常涉及特定库的扩展,如:.jsx、.vue、.scss 文件。
它使用基于路由的代码拆分来了解代码的哪些部分实际需要加载,因此,它不必重新打包所有内容。
它还使用现代浏览器中的原生 ES 模块支持来交付代码,这让浏览器可以在开发中承担打包工作。
在生产方面,虽然现在所有主流浏览器都支持原生 ES 模块,但它实现了诸如 tree-shaking、延迟加载和通用块拆分等性能优化技术,仍然比非打包应用程序带来更好的整体性能。出于这个原因,Vite 附带了一个预先配置的 build 命令,该命令使用 Rollup 打包来打包和实现各种性能优化。
也真是这方面的原因,所以《Bundler取舍:抛弃vite改投rspack?》
参考文章:
Webpack 4 Tree Shaking 终极优化指南 https://juejin.im/post/6844903998634328072
Tree Shaking in Webpack https://juejin.im/post/5c58df43e51d457ffc1bd065
浅谈 ES 模块和 Webpack Tree-shaking https://zhuanlan.zhihu.com/p/43844419
一文了解webpack和vite中Tree-Shaking https://blog.csdn.net/miaomiao_1024/article/details/138678650
关于打包工具webpack和vite的摇树(Tree Shaking)优化 https://juejin.cn/post/7265644932451057725
webpack5中的tree-shaking 升级的那些事 https://juejin.cn/post/7104612134494928910
Webpack 5 实践:你不知道的 Tree Shaking https://juejin.cn/post/7105022295474700295
https://learn.lianglianglee.com/专栏/前端工程化精讲-完/15%20%20版本特性:Webpack%205%20中的优化细节.md
转载本站文章《webpack原理(2):ES6 module在Webpack/vite中如何Tree-shaking构建》,
请注明出处:https://www.zhoulujun.cn/html/tools/Bundler/webpackTheory/8504.html