Rollup 與 Webpack 的 Tree-shaking
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 都被打了進來,代碼塊如下:
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";
- 案例 2: 變量影響了全局變量
window.utm = 'a.b.c';
即使 utm
沒有任何地方被使用到,在編譯打包的過程中,上述代碼也不能被去除。因此我們可以得出結論:
-
在 import 三方工具庫、組件庫時不要全量 import。
-
設置或改動全局變量需謹慎。
Vue3 針對 Tree-shaking 所做的優化
在 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
前後時長和體積沒有明顯變化。
另,上述打包效果中的項目是 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)
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/R68sDHMZiizYGX2m1qdHTw