Rollup 与 Webpack 的 Tree-shaking http://zoo.zhengcaiyun.cn/blog/article/tree-shaking
Rollup 和 Webpack 是目前项目中使用较为广泛的两种打包工具,去年发布的 Vite 中打包所依赖的也是 Rollup;在对界面加载效率要求越来越高的今天,打包工具最终产出的包体积也影响着开发人员对工具的选择,所以对 Tree-shaking 的支持程度和配置的便捷性、有效性就尤为重要了。本文就来简单分析下两者 Tree-shaking 的流程和效果差异。
Tree-shaking 的目的
Tree-shaking 的目标只有一个,去除无用代码,缩小最终的包体积,至于什么算是无用代码呢?主要分为三类:
- 代码不会被执行,不可到达
- 代码执行的结果不会被用到
- 代码只会影响死变量(只写不读) Tree-shaking 的目的就是将这三类代码在最终包中剔除,做到按需引入。
为什么 Tree-shaking 需要依赖 ES6 module
ES6 module 特点:
- 只能作为模块顶层的语句出现
- import 的模块名只能是字符串常量
- import 之后是不可修改的 例如,在使用 CommonJS 时,必须导入完整的工具 (tool) 或库 (library) 对象,且可带有条件判断来决定是否导入。
// 使用 CommonJS 导入完整的 utils 对象
if (hasRequest) {
const utils = require( 'utils' );
}
但是在使用 ES6 模块时,无需导入整个 utils
对象,我们可以只导入我们所需使用的 request
函数,但此处的 import 是不能在任何条件语句下进行的,否则就会报错。
// 使用 ES6 import 语句导入 request 函数
import { request } from 'utils';
ES6 模块依赖关系是确定的,和运行时的状态无关,因此可以进行可靠的静态分析,这就是 Tree-shaking 的基础。
静态分析就是不执行代码,直接对代码进行分析;在 ES6 之前的模块化,比如上面提到的 CommonJS ,我们可以动态 require 一个模块,只有执行后才知道引用的什么模块,这就使得我们不能直接静态的进行分析。
Wepack5.x Tree-shaking 机制
Webpack 2 正式版本内置支持 ES2015 模块(也叫做 harmony modules)和未使用模块检测能力。Webpack 4 正式版本扩展了此检测能力,通过 package.json
的 "sideEffects"
属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 "pure (纯正 ES2015 模块)",由此可以安全地删除文件中未使用的部分。Webpack 5 中内置了 terser-webpack-plugin 插件用于 JS 代码压缩,相较于 Webpack 4 来说,无需再额外下载安装,但如果开发者需要增加自定义配置项,那还是需要安装。
Wepack 自身在编译过程中,会根据模块的 import
与 export
依赖分析对代码块进行打标。
/**
* @param {Context} context context
* @returns {string|Source} the source code that will be included as initialization code
*/
getContent({ runtimeTemplate, runtimeRequirements }) {
runtimeRequirements.add(RuntimeGlobals.exports);
runtimeRequirements.add(RuntimeGlobals.definePropertyGetters);
// 未使用的模块, 在代码块前增加 unused harmony exports 注释标记
const unusedPart =
this.unusedExports.size > 1
? `/* unused harmony exports ${joinIterableWithComma(
this.unusedExports
)} */n`
: this.unusedExports.size > 0
? `/* unused harmony export ${first(this.unusedExports)} */n`
: "";
const definitions = [];
const orderedExportMap = Array.from(this.exportMap).sort(([a], [b]) =>
a < b ? -1 : 1
);
// 对 harmony export 进行打标
for (const [key, value] of orderedExportMap) {
definitions.push(
`n/* harmony export */ ${JSON.stringify(
key
)}: ${runtimeTemplate.returningFunction(value)}`
);
}
// 对 harmony export 进行打标
const definePart =
this.exportMap.size > 0
? `/* harmony export */ ${RuntimeGlobals.definePropertyGetters}(${
this.exportsArgument
}, {${definitions.join(",")}n/* harmony export */ });n`
: "";
return `${definePart}${unusedPart}`;
}
上面是从 Webpack 中截取的打标代码,可以看到主要会有两类标记,harmony export
和 unused harmony export
分别代表了有用与无用。标记完成后打包时 Teser 会将无用的模块去除。
Rollup Tree-shaking 机制
以下是 rollup 2.77.2
版本的 package.json 文件,我们可以看下它的主要依赖;
{
"name": "rollup",
"version": "2.77.2",
"description": "Next-generation ES module bundler",
"main": "dist/rollup.js",
"module": "dist/es/rollup.js",
"typings": "dist/rollup.d.ts",
"bin": {
"rollup": "dist/bin/rollup"
},
"devDependencies": {
"@rollup/plugin-alias": "^3.1.9",
"@rollup/plugin-buble": "^0.21.3",
"@rollup/plugin-commonjs": "^22.0.1",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.3.3",
"@rollup/pluginutils": "^4.2.1",
"acorn": "^8.7.1", // 生成 AST 语法树
"acorn-jsx": "^5.3.2", // 针对 jsx 语法分析
"acorn-walk": "^8.2.0", // 递归生成对象
"magic-string": "^0.26.2", // 语句的替换
......,
},
......
}
想要详细了解Acorn:A tiny, fast JavaScript parser, written completely in JavaScript.可查看(https://github.com/acornjs/acorn),Magic-string,可查看(https://github.com/rich-harris/magic-string#readme) 。rollup源码中各个模块的执行顺序大致如下图,这也基本表明了它的分析流程。
与 Webpack 不同的是,Rollup 不仅仅针对模块进行依赖分析,它的分析流程如下:
- 从入口文件开始,组织依赖关系,并按文件生成 Module
- 生成抽象语法树(Acorn),建立语句间的关联关系
- 为每个节点打标,标记是否被使用
- 生成代码(MagicString position)去除无用代码
Rollup 的优势
- 它支持导出 ES 模块的包。
- 它支持程序流分析,能更加正确的判断项目本身的代码是否有副作用。
两个 Case
- 案例1:Import 但未调用,不可消除
import pkgjson from '../package.json';
export function getMeta (version: string) {
return {
lver: version || pkgjson.version,
}
}
编译后整个 package.json 都被打了进来,代码块如下:
代码语言:javascript复制var name = "@zcy/xxxxx-sdk";
var version$1 = "0.0.1-beta";
var description = "";
var main = "lib/index.es.js";
var module$1 = "lib/index.cjs.js";
var browser = "lib/index.umd.js";
var types = "lib/index.d.ts";
var scripts = {
test: "jest --color --coverage=true",
doc: "rm -rf doc && typedoc --out doc ./src",
.....
};
var repository = {
type: "git",
url: "......"
};
var author = "";
var license = "ISC";
var devDependencies = {
"@babel/core": "^7.15.5",
"@babel/preset-env": "^7.15.4",
"@babel/runtime-corejs3": "^7.11.2",
"@types/jest": "^24.9.1",
"@typescript-eslint/eslint-plugin": "^2.34.0",
"@typescript-eslint/parser": "^2.34.0",
"babel-loader": "^8.2.2",
eslint: "^6.8.0",
"eslint-config-alloy": "^3.7.2",
jest: "^24.9.0",
"lodash.camelcase": "^4.3.0",
path: "^0.12.7",
prettier: "^1.19.1",
rollup: "^1.32.1",
...
.
};
var dependencies = {
"@babel/plugin-transform-runtime": "^7.10.5",
"@rollup/plugin-json": "^4.1.0",
"core-js": "^3.6.5"
};
var sideEffects = false;
var pkgjson = {
name: name,
version: version$1,
description: description,
main: main,
module: module$1,
browser: browser,
types: types,
scripts: scripts,
repository: repository,
author: author,
license: license,
devDependencies: devDependencies,
dependencies: dependencies,
sideEffects: sideEffects,
};
未 import 的部分可消除
代码语言:javascript复制import { version } from '../package.json';
export function getMeta (ver: string) {
return {
lver: ver || version,
}
}
编译后可以发现,version 作为一个常量被单独打包进来;代码块如下:
代码语言:javascript复制var version$1 = "0.0.1-beta";
- 案例2: 变量影响了全局变量
window.utm = 'a.b.c';
即使 utm
没有任何地方被使用到,在编译打包的过程中,上述代码也不能被去除。因此我们可以得出结论:
- 在 import 三方工具库、组件库时不要全量 import。
- 设置或改动全局变量需谨慎。
Vue3 针对 Tree-shaking 所做的优化
在 Vue2.x 中,你一定见过以下引入方式:
代码语言:javascript复制import Vue from 'vue'
Vue.nextTick(() => {
// 一些和 DOM 有关的东西
})
很可惜的是,像 Vue.nextTick()
这样的全局 API 是不支持 Tree-shaking 的,因为它并没有被单独 export
;无论 nextTick
方法是否被实际调用,都会被包含在最终的打包产物中。但在 Vue3,针对全局和内部 API 进行了改造。如果你想更详细的了解 Vue3.x 全局 API Tree-shaking 带来的改动,可以查看这里,里面详细列出了不再兼容的 API,以及在内部帮助器及插件中的使用变化。
有了这些能力之后,我们可以不再过于关注框架总体的体积了,因为按需打包使得我们只需要关注那些我们已经使用到的功能和代码。
最终效果对比
先分别来看下两种打包工具的配置;
webpack.config.js :
代码语言:javascript复制const webpack = require('webpack');
const path = require('path');
// 删除 const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
entry: path.join(__dirname, 'src/index.ts'),
output: {filename: 'webpack.bundle.js'},
module: {
rules: [
{
test: /.(js|ts|tsx)$/,
exclude: /(node_modules|bower_components|lib)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
},
{
test: /.tsx?$/,
use: 'ts-loader',
exclude: /(node_modules|lib)/,
},
]
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
optimization: { // tree-shaking 优化配置
usedExports: true,
},
plugins: [
new webpack.optimize.ModuleConcatenationPlugin()
]
}
rollup.config.js :
代码语言:javascript复制import resolve from "rollup-plugin-node-resolve";
import commonjs from "rollup-plugin-commonjs";
import typescript from "rollup-plugin-typescript2";
import babel from "rollup-plugin-babel";
import json from "rollup-plugin-json";
import { uglify } from 'rollup-plugin-uglify'
export default {
input: "src/index.ts",
output: [
{ file: "lib/index.cjs.js", format: "cjs" },
],
treeshake: true, // treeshake 开关
plugins: [
json(),
typescript(),
resolve(),
commonjs(),
babel(
{
exclude: "node_modules/**",
runtimeHelpers: true,
sourceMap: true,
extensions: [".js", ".jsx", ".es6", ".es", ".mjs", ".ts", ".json"],
}
),
uglify(),
],
};
最后来看下打包结果的对比。结果发现,本项目在配置 sideEffects:false
前后时长和体积没有明显变化。
对比 | Tree-Shaking 前体积 | Tree-Shaking 后体积 | 打包时长 |
---|---|---|---|
webpack(5.52.0) | 46kb | 44kb | 4.8s |
rollup(1.32.1) | 24kb | 18kb | 3.7s |
另,上述打包效果中的项目是 sdk 工具包。
结束语
你如果想了解 Rollup 会打包更快的原因,可以查看我之前发布的文章《Vite 特性和部分源码解析 》(https://www.zoo.team/article/about-vite)。关于 Tree-shaking 的问题也欢迎你在下面留言讨论。
推荐阅读
《Rollup源码解析》(https://juejin.cn/post/7021115814870810660)
Rollup Tree-shaking 机制 (https://www.rollupjs.com/guide/introduction#tree-shaking)