大家好,我是若川。我持續組織了近一年的源碼共讀活動,感興趣的可以?點此掃碼加我微信?lxchuan12?參與,每周大家一起學習200行左右的源碼,共同進步。同時極力推薦訂閱我寫的《學習源碼整體架構系列》?包含20余篇源碼文章。歷史面試系列。另外:目前建有江西|湖南|湖北
籍前端群,可加我微信進群。
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
?前后時長和體積沒有明顯變化。
對比 | 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)
·················?若川簡介?·················
你好,我是若川,畢業于江西高校。現在是一名前端開發“工程師”。寫有《學習源碼整體架構系列》20余篇,在知乎、掘金收獲超百萬閱讀。從2014年起,每年都會寫一篇年度總結,已經堅持寫了8年,點擊查看年度總結。同時,最近組織了源碼共讀活動,幫助5000+前端人學會看源碼。公眾號愿景:幫助5年內前端人走向前列。
掃碼加我微信 lxchuan12、拉你進源碼共讀群
今日話題
目前建有江西|湖南|湖北?籍 前端群,想進群的可以加我微信 lxchuan12?進群。分享、收藏、點贊、在看我的文章就是對我最大的支持~