Rollup 与 Webpack 的 Tree-shaking http://zoo.zhengcaiyun.cn/blog/article/tree-shaking
Rollup 和 Webpack 是目前项目中使用较为广泛的两种打包工具,去年发布的 Vite 中打包所依赖的也是 Rollup;在对界面加载效率要求越来越高的今天,打包工具最终产出的包体积也影响着开发人员对工具的选择,所以对 Tree-shaking 的支持程度和配置的便捷性、有效性就尤为重要了。本文就来简单分析下两者 Tree-shaking 的流程和效果差异。
Tree-shaking 的目标只有一个,去除无用代码,缩小最终的包体积,至于什么算是无用代码呢?主要分为三类:
ES6 module 特点:
// 使用 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 一个模块,只有执行后才知道引用的什么模块,这就使得我们不能直接静态的进行分析。
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 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 不仅仅针对模块进行依赖分析,它的分析流程如下:
import pkgjson from '../package.json';
export function getMeta (version: string) {
return {
lver: version || pkgjson.version,
}
}
编译后整个 package.json 都被打了进来,代码块如下:
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 的部分可消除
import { version } from '../package.json';
export function getMeta (ver: string) {
return {
lver: ver || version,
}
}
编译后可以发现,version 作为一个常量被单独打包进来;代码块如下:
var version$1 = "0.0.1-beta";
window.utm = 'a.b.c';
即使 utm
没有任何地方被使用到,在编译打包的过程中,上述代码也不能被去除。因此我们可以得出结论:
在 Vue2.x 中,你一定见过以下引入方式:
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 :
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 :
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)