來源
tree-shaking
最早由 Rich Harris 在 rollup
中提出。
為了減少最終構建體積而誕生。
以下是 MDN 中的說明:
tree-shaking 是一個通常用於描述移除 JavaScript 上下文中的未引用程式碼(dead-code) 行為的術語。
它依賴於 ES2015 中的 import 和 export 語句,用來檢測程式碼模組是否被匯出、匯入,且被 JavaScript 檔案使用。
在現代 JavaScript 應用程式中,我們使用模組打包(如 webpack 或 Rollup)將多個 JavaScript 檔案打包為單個檔案時自動刪除未引用的程式碼。這對於準備預備釋出程式碼的工作非常重要,這樣可以使最終檔案具有簡潔的結構和最小化大小。
tree-shaking VS dead code elimination
說起 tree-shaking
不得不說起 dead code elimination
,簡稱 DCE
。
很多人往往把 tree-shaking
當作是一種實現 DCE
的技術。如果都是同一種東西,最終的目標是一致的(更少的程式碼)。為什麼要重新起一個名字叫做 tree-shaking
呢?
tree-shaking
術語的發明者 Rich Harris 在他寫的一篇《tree-shaking versus dead code elimination》告訴了我們答案。
Rich Harris 引用了一個做蛋糕的例子。原文如下:
Bad analogy time: imagine that you made cakes by throwing whole eggs into the mixing bowl and smashing them up, instead of cracking them open and pouring the contents out. Once the cake comes out of the oven, you remove the fragments of eggshell, except that’s quite tricky so most of the eggshell gets left in there.
You’d probably eat less cake, for one thing.
That’s what dead code elimination consists of — taking the finished product, and imperfectly removing bits you don’t want. tree-shaking, on the other hand, asks the opposite question: given that I want to make a cake, which bits of what ingredients do I need to include in the mixing bowl?
Rather than excluding dead code, we’re including live code. Ideally the end result would be the same, but because of the limitations of static analysis in JavaScript that’s not the case. Live code inclusion gets better results, and is prima facie a more logical approach to the problem of preventing our users from downloading unused code.
簡單來說:DCE
好比做蛋糕時,直接放入整個雞蛋,做完時再從蛋糕中取出蛋殼。而 tree-shaking
則是先取出蛋殼,在進行做蛋糕。兩者結果相同,但是過程是完全不同的。
dead code
dead code
一般具有以下幾個特徵:
- 程式碼不會被執行,不可到達
- 程式碼執行的結果不會被用到
- 程式碼只會影響死變數(只寫不讀)
使用 webpack
在 mode: development
模式下對以下程式碼進行打包:
function app() {
var test = '我是app';
function set() {
return 1;
}
return test;
test = '無法執行';
return test;
}
export default app;
最終打包結果:
eval(
"function app() {\n var test = '我是app';\n function set() {\n return 1;\n }\n return test;\n test = '無法執行';\n return test;\n}\n\napp();\n\n\n//# sourceURL=webpack://webpack/./src/main.js?"
);
可以看到打包的結果內,還是存在無法執行到的程式碼塊。
webpack
不支援 dead code elimination
嗎?是的,webpack
不支援。
原來,在 webpack
中實現 dead code elimination
功能並不是 webpack
本身, 而是大名鼎鼎的 uglify。
通過閱讀原始碼發現,在 mode: development
模式下,不會載入 terser-webpack-plugin
外掛。
// lib/config/defaults.js
D(optimization, 'minimize', production);
A(optimization, 'minimizer', () => [
{
apply: (compiler) => {
// Lazy load the Terser plugin
const TerserPlugin = require('terser-webpack-plugin');
new TerserPlugin({
terserOptions: {
compress: {
passes: 2
}
}
}).apply(compiler);
}
}
]);
// lib/WebpackOptionsApply.js
if (options.optimization.minimize) {
for (const minimizer of options.optimization.minimizer) {
if (typeof minimizer === 'function') {
minimizer.call(compiler, compiler);
} else if (minimizer !== '...') {
minimizer.apply(compiler);
}
}
}
而 terser-webpack-plugin
外掛內部使用了 uglify
實現的。
我們在 mode: production
模式下進行打包。
// 格式化後結果
(() => {
var r = {
225: (r) => {
r.exports = '我是app';
}
},
// ...
})();
可以看到最終的結果,已經刪除了不可執行部分的程式碼。除此之外,還幫我們壓縮了程式碼,刪除了註釋等功能。
tree shaking 無效
tree shaking
本質上是通過分析靜態的 ES 模組,來剔除未使用程式碼的。
_ESModule_
的特點只能作為模組頂層的語句出現,不能出現在 function 裡面或是 if 裡面。(ECMA-262 15.2)
import 的模組名只能是字串常量。(ECMA-262 15.2.2)
不管 import 的語句出現的位置在哪裡,在模組初始化的時候所有的 import 都必須已經匯入完成。(ECMA-262 15.2.1.16.4 - 8.a)
import binding 是 immutable 的,類似 const。比如說你不能 import { a } from ‘./a’ 然後給 a 賦值個其他什麼東西。(ECMA-262 15.2.1.16.4 - 12.c.3)
—–引用自尤雨溪
我們來看看 tree shaking
的功效。
我們有一個模組
// ./src/app.js
export const firstName = 'firstName'
export function getName ( x ) {
return x.a
}
getName({ a: 123 })
export function app ( x ) {
return x * x * x;
}
export default app;
底下是 7 個例項。
// 1*********************************************
// import App from './app'
// export function main() {
// var test = '我是index';
// return test;
// }
// console.log(main)
// 2*********************************************
// import App from './app'
// export function main() {
// var test = '我是index';
// console.log(App(1))
// return test;
// }
// console.log(main)
// 3*********************************************
// import App from './app'
// export function main() {
// var test = '我是index';
// App.square(1)
// return test;
// }
// console.log(main)
// 4*********************************************
// import App from './app'
// export function main() {
// var test = '我是index';
// let methodName = 'square'
// App[methodName](1)
// return test;
// }
// console.log(main)
// 6*********************************************
// import * as App from './app'
// export function main() {
// var test = '我是index';
// App.square(1)
// return test;
// }
// console.log(main)
// 7*********************************************
// import * as App from './app'
// export function main() {
// var test = '我是index';
// let methodName = 'square'
// App[methodName](1)
// return test;
// }
// console.log(main)
使用 最簡單的webpack
配置進行打包
// webpack.config.js
module.exports = {
entry: './src/index.js',
output: {
filename: 'dist.js'
},
mode: 'production'
};
通過結果可以看到,前 6 中的打包結果,都對死程式碼進行了消除,只有第 7 種,消除失敗。
/* ... */
const r = 'firstName';
function o(e) {
return e.a;
}
function n(e) {
return e * e * e;
}
o({ a: 123 });
const a = n;
console.log(function () {
return t.square(1), '我是index';
});
本人沒有詳細瞭解過,只能猜測下,由於 JavaScript
動態語言的特性使得靜態分析比較困難,目前的的解析器是通過靜態解析的,還無法分析全量匯入,動態使用的語法。
對於更多 tree shaking
執行相關的可以參考一下連結:
當然了,機智的程式設計師是不會被這個給難住的,既然靜態分析不行,那就由開發者手動來將檔案標記為無副作用(side-effect-free)。
tree shaking 和 sideEffects
sideEffects
支援兩種寫法,一種是 false
,另一種是陣列
- 如果所有程式碼都不包含副作用,我們就可以簡單地將該屬性標記為
false
- 如果你的程式碼確實有一些副作用,可以改為提供一個陣列
可以在 package.js
中進行設定。
// boolean
{
"sideEffects": false
}
// array
{
"sideEffects": ["./src/app.js", "*.css"]
}
也可以在 module.rules
中進行設定。
module.exports = {
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader',
},
sideEffects: false || []
}
]
},
}
設定了 sideEffects: false
,後在重新打包
var e = {
225: (e, r, t) => {
(e = t.hmd(e)).exports = '我是main';
}
},
只剩下 main.js
模組的程式碼,已經把 app.js
的程式碼消除了。
usedExports
webpack
中除了 sideEffects
還提供了一種另一種標記消除的方式。那就是通過配置項 usedExports
。
由 optimization.usedExports 收集的資訊會被其它優化手段或者程式碼生成使用,比如未使用的匯出內容不會被生成,當所有的使用都適配,匯出名稱會被處理做單個標記字元。 在壓縮工具中的無用程式碼清除會受益於該選項,而且能夠去除未使用的匯出內容。
mode: productions
下是預設開啟的。
module.exports = {
//...
optimization: {
usedExports: true,
},
};
usedExports
會使用 terser
判斷程式碼有沒有 sideEffect
,如果沒有用到,又沒有 sideEffect
的話,就會在打包時替它標記上 unused harmony。
最後由 Terser
、UglifyJS
等 DCE
工具“搖”掉這部分無效程式碼。
tree shaking 實現原理
tree shaking
本身也是採用靜態分析的方法。
程式靜態分析(Static Code Analysis)是指在不執行程式碼的方式下,通過詞法分析、語法分析、控制流分析、資料流分析等技術對程式程式碼進行掃描,驗證程式碼是否滿足規範性、安全性、可靠性、可維護性等指標的一種程式碼分析技術
tree shaking
使用的前提是模組必須採用ES6Module
語法,因為tree Shaking
依賴 ES6 的語法:import
和 export
。
接下來我們來看看遠古版本的 rollup
是怎麼實現 tree shaking
的。
- 根據入口模組內容初始化
Module
,並使用acorn
進行ast
轉化 - 分析
ast
。 尋找import
和export
關鍵字,建立依賴關係 - 分析
ast
,收集當前模組存在的函式、變數等資訊 - 再一次分析 ast, 收集各函式變數的使用情況,因為我們是根據依賴關係進行收集程式碼,如果函式變數未被使用,
- 根據收集到的函式變數識別符號等資訊,進行判斷,如果是
import
,則進行Module
的建立,重新走上幾步。否則的話,把對應的程式碼資訊存放到一個統一的result
中。 - 根據最終的結果生成
bundle
。
原始碼版本:v0.3.1
通過 entry
入口檔案進行建立 bundle
,執行 build
方法,開始進行打包。
export function rollup ( entry, options = {} ) {
const bundle = new Bundle({
entry,
resolvePath: options.resolvePath
});
return bundle.build().then( () => {
return {
generate: options => bundle.generate( options ),
write: ( dest, options = {} ) => {
let { code, map } = bundle.generate({
dest,
format: options.format,
globalName: options.globalName
});
code += `\n//# ${SOURCEMAPPING_URL}=${basename( dest )}.map`;
return Promise.all([
writeFile( dest, code ),
writeFile( dest + '.map', map.toString() )
]);
}
};
});
}
build
內部執行 fetchModule
方法,根據檔名,readFile
讀取檔案內容,建立 Module
。
build () {
return this.fetchModule( this.entryPath, null )
.then( entryModule => {
this.entryModule = entryModule;
if ( entryModule.exports.default ) {
let defaultExportName = makeLegalIdentifier( basename( this.entryPath ).slice( 0, -extname( this.entryPath ).length ) );
while ( entryModule.ast._scope.contains( defaultExportName ) ) {
defaultExportName = `_${defaultExportName}`;
}
entryModule.suggestName( 'default', defaultExportName );
}
return entryModule.expandAllStatements( true );
})
.then( statements => {
this.statements = statements;
this.deconflict();
});
}
fetchModule ( importee, importer ) {
return Promise.resolve( importer === null ? importee : this.resolvePath( importee, importer ) )
.then( path => {
/*
快取處理
*/
this.modulePromises[ path ] = readFile( path, { encoding: 'utf-8' })
.then( code => {
const module = new Module({
path,
code,
bundle: this
});
return module;
});
return this.modulePromises[ path ];
});
}
根據讀取到的檔案內容,使用 acorn
編譯器進行進行 ast
的轉化。
//
export default class Module {
constructor ({ path, code, bundle }) {
/*
初始化
*/
this.ast = parse(code, {
ecmaVersion: 6,
sourceType: 'module',
onComment: (block, text, start, end) =>
this.comments.push({ block, text, start, end })
});
this.analyse();
}
遍歷節點資訊。尋找 import
和 export
關鍵字,這一步就是我們常說的根據 esm
的靜態結構進行分析。
把 import
的資訊,收集到 this.imports
物件中,把 exports
的資訊,收集到 this.exports
中.
this.ast.body.forEach( node => {
let source;
if ( node.type === 'ImportDeclaration' ) {
source = node.source.value;
node.specifiers.forEach( specifier => {
const isDefault = specifier.type === 'ImportDefaultSpecifier';
const isNamespace = specifier.type === 'ImportNamespaceSpecifier';
const localName = specifier.local.name;
const name = isDefault ? 'default' : isNamespace ? '*' : specifier.imported.name;
if ( has( this.imports, localName ) ) {
const err = new Error( `Duplicated import '${localName}'` );
err.file = this.path;
err.loc = getLocation( this.code.original, specifier.start );
throw err;
}
this.imports[ localName ] = {
source, // 模組id
name,
localName
};
});
}
else if ( /^Export/.test( node.type ) ) {
if ( node.type === 'ExportDefaultDeclaration' ) {
const isDeclaration = /Declaration$/.test( node.declaration.type );
this.exports.default = {
node,
name: 'default',
localName: isDeclaration ? node.declaration.id.name : 'default',
isDeclaration
};
}
else if ( node.type === 'ExportNamedDeclaration' ) {
// export { foo } from './foo';
source = node.source && node.source.value;
if ( node.specifiers.length ) {
node.specifiers.forEach( specifier => {
const localName = specifier.local.name;
const exportedName = specifier.exported.name;
this.exports[ exportedName ] = {
localName,
exportedName
};
if ( source ) {
this.imports[ localName ] = {
source,
localName,
name: exportedName
};
}
});
}
else {
let declaration = node.declaration;
let name;
if ( declaration.type === 'VariableDeclaration' ) {
name = declaration.declarations[0].id.name;
} else {
name = declaration.id.name;
}
this.exports[ name ] = {
node,
localName: name,
expression: declaration
};
}
}
}
}
analyse () {
// imports and exports, indexed by ID
this.imports = {};
this.exports = {};
// 遍歷 ast 查詢對應的 import、export 關聯
this.ast.body.forEach( node => {
let source;
// import foo from './foo';
// import { bar } from './bar';
if ( node.type === 'ImportDeclaration' ) {
source = node.source.value;
node.specifiers.forEach( specifier => {
const isDefault = specifier.type === 'ImportDefaultSpecifier';
const isNamespace = specifier.type === 'ImportNamespaceSpecifier';
const localName = specifier.local.name;
const name = isDefault ? 'default' : isNamespace ? '*' : specifier.imported.name;
if ( has( this.imports, localName ) ) {
const err = new Error( `Duplicated import '${localName}'` );
err.file = this.path;
err.loc = getLocation( this.code.original, specifier.start );
throw err;
}
this.imports[ localName ] = {
source, // 模組id
name,
localName
};
});
}
else if ( /^Export/.test( node.type ) ) {
// export default function foo () {}
// export default foo;
// export default 42;
if ( node.type === 'ExportDefaultDeclaration' ) {
const isDeclaration = /Declaration$/.test( node.declaration.type );
this.exports.default = {
node,
name: 'default',
localName: isDeclaration ? node.declaration.id.name : 'default',
isDeclaration
};
}
// export { foo, bar, baz }
// export var foo = 42;
// export function foo () {}
else if ( node.type === 'ExportNamedDeclaration' ) {
// export { foo } from './foo';
source = node.source && node.source.value;
if ( node.specifiers.length ) {
// export { foo, bar, baz }
node.specifiers.forEach( specifier => {
const localName = specifier.local.name;
const exportedName = specifier.exported.name;
this.exports[ exportedName ] = {
localName,
exportedName
};
if ( source ) {
this.imports[ localName ] = {
source,
localName,
name: exportedName
};
}
});
}
else {
let declaration = node.declaration;
let name;
if ( declaration.type === 'VariableDeclaration' ) {
name = declaration.declarations[0].id.name;
} else {
name = declaration.id.name;
}
this.exports[ name ] = {
node,
localName: name,
expression: declaration
};
}
}
}
}
// 查詢函式,變數,類,塊級作用與等,並根據引用關係進行關聯
analyse( this.ast, this.code, this );
}
接下來查詢函式,變數,類,塊級作用與等,並根據引用關係進行關聯。
使用 magicString
為每一個 statement
節點增加內容修改的功能。
遍歷整顆 ast
樹,先初始化一個 Scope
,作為當前模組的名稱空間。如果是函式或塊級作用域等則新建一個 Scope
。各 Scope
之間通過 parent
進行關聯,建立起一個根據名稱空間關係樹。
如果是變數和函式,則與當前的 Scope
進行關聯, 把對應的識別符號名稱增加到 Scope
的中。到這一步,已經收集到了各節點上出現的函式和變數。
接下來,再一次遍歷 ast
。查詢變數函式,是否只是被讀取過,或者只是修改過。
根據 Identifier
型別查詢識別符號,如果當前識別符號能在 Scope
中找到,說明有對其進行過讀取。存放在 _dependsOn
集合中。
接下來根據 AssignmentExpression
、UpdateExpression
和 CallExpression
型別節點,收集我們的識別符號,有沒有被修改過或被當前引數傳遞過。並將結果存放在 _modifies
中。
function analyse(ast, magicString, module) {
var scope = new Scope();
var currentTopLevelStatement = undefined;
function addToScope(declarator) {
var name = declarator.id.name;
scope.add(name, false);
if (!scope.parent) {
currentTopLevelStatement._defines[name] = true;
}
}
function addToBlockScope(declarator) {
var name = declarator.id.name;
scope.add(name, true);
if (!scope.parent) {
currentTopLevelStatement._defines[name] = true;
}
}
// first we need to generate comprehensive scope info
var previousStatement = null;
var commentIndex = 0;
ast.body.forEach(function (statement) {
currentTopLevelStatement = statement; // so we can attach scoping info
Object.defineProperties(statement, {
_defines: { value: {} },
_modifies: { value: {} },
_dependsOn: { value: {} },
_included: { value: false, writable: true },
_module: { value: module },
_source: { value: magicString.snip(statement.start, statement.end) }, // TODO don't use snip, it's a waste of memory
_margin: { value: [0, 0] },
_leadingComments: { value: [] },
_trailingComment: { value: null, writable: true } });
var trailing = !!previousStatement;
// attach leading comment
do {
var comment = module.comments[commentIndex];
if (!comment || comment.end > statement.start) break;
// attach any trailing comment to the previous statement
if (trailing && !/\n/.test(magicString.slice(previousStatement.end, comment.start))) {
previousStatement._trailingComment = comment;
}
// then attach leading comments to this statement
else {
statement._leadingComments.push(comment);
}
commentIndex += 1;
trailing = false;
} while (module.comments[commentIndex]);
// determine margin
var previousEnd = previousStatement ? (previousStatement._trailingComment || previousStatement).end : 0;
var start = (statement._leadingComments[0] || statement).start;
var gap = magicString.original.slice(previousEnd, start);
var margin = gap.split('\n').length;
if (previousStatement) previousStatement._margin[1] = margin;
statement._margin[0] = margin;
walk(statement, {
enter: function (node) {
var newScope = undefined;
magicString.addSourcemapLocation(node.start);
switch (node.type) {
case 'FunctionExpression':
case 'FunctionDeclaration':
case 'ArrowFunctionExpression':
var names = node.params.map(getName);
if (node.type === 'FunctionDeclaration') {
addToScope(node);
} else if (node.type === 'FunctionExpression' && node.id) {
names.push(node.id.name);
}
newScope = new Scope({
parent: scope,
params: names, // TODO rest params?
block: false
});
break;
case 'BlockStatement':
newScope = new Scope({
parent: scope,
block: true
});
break;
case 'CatchClause':
newScope = new Scope({
parent: scope,
params: [node.param.name],
block: true
});
break;
case 'VariableDeclaration':
node.declarations.forEach(node.kind === 'let' ? addToBlockScope : addToScope); // TODO const?
break;
case 'ClassDeclaration':
addToScope(node);
break;
}
if (newScope) {
Object.defineProperty(node, '_scope', { value: newScope });
scope = newScope;
}
},
leave: function (node) {
if (node === currentTopLevelStatement) {
currentTopLevelStatement = null;
}
if (node._scope) {
scope = scope.parent;
}
}
});
previousStatement = statement;
});
// then, we need to find which top-level dependencies this statement has,
// and which it potentially modifies
ast.body.forEach(function (statement) {
function checkForReads(node, parent) {
if (node.type === 'Identifier') {
// disregard the `bar` in `foo.bar` - these appear as Identifier nodes
if (parent.type === 'MemberExpression' && node !== parent.object) {
return;
}
// disregard the `bar` in { bar: foo }
if (parent.type === 'Property' && node !== parent.value) {
return;
}
var definingScope = scope.findDefiningScope(node.name);
if ((!definingScope || definingScope.depth === 0) && !statement._defines[node.name]) {
statement._dependsOn[node.name] = true;
}
}
}
function checkForWrites(node) {
function addNode(node, disallowImportReassignments) {
while (node.type === 'MemberExpression') {
node = node.object;
}
// disallow assignments/updates to imported bindings and namespaces
if (disallowImportReassignments && has(module.imports, node.name) && !scope.contains(node.name)) {
var err = new Error('Illegal reassignment to import \'' + node.name + '\'');
err.file = module.path;
err.loc = getLocation(module.code.toString(), node.start);
throw err;
}
if (node.type !== 'Identifier') {
return;
}
statement._modifies[node.name] = true;
}
if (node.type === 'AssignmentExpression') {
addNode(node.left, true);
} else if (node.type === 'UpdateExpression') {
addNode(node.argument, true);
} else if (node.type === 'CallExpression') {
node.arguments.forEach(function (arg) {
return addNode(arg, false);
});
}
// TODO UpdateExpressions, method calls?
}
walk(statement, {
enter: function (node, parent) {
// skip imports
if (/^Import/.test(node.type)) return this.skip();
if (node._scope) scope = node._scope;
checkForReads(node, parent);
checkForWrites(node, parent);
//if ( node.type === 'ReturnStatement')
},
leave: function (node) {
if (node._scope) scope = scope.parent;
}
});
});
ast._scope = scope;
}
執行完結果如下:
在上一步種,我們為函式,變數,類,塊級作用與等宣告與我們當前節點進行了關聯,現在要把節點上的這些資訊,統一收集起來,放到 Module
中
//
this.ast.body.forEach( statement => {
Object.keys( statement._defines ).forEach( name => {
this.definitions[ name ] = statement;
});
Object.keys( statement._modifies ).forEach( name => {
if ( !has( this.modifications, name ) ) {
this.modifications[ name ] = [];
}
this.modifications[ name ].push( statement );
});
});
從中我們可以看到每個 statement
中,依賴了哪些,修改了哪些。
當我們在入口模組的操作完成後,在遍歷 statement
節點,根據 _dependsOn
的中的資訊,執行 define
。
如果 _dependsOn
的資料,在 this.imports
中,能夠找到,說明該識別符號是一個匯入模組,呼叫 fetchModule
方法,重複上面的邏輯。
如果是正常函式變數之類的,則收集對應 statement
。執行到最後,我們就可以把相關聯的 statement
都收集起來,未被收集到,說明其就是無用程式碼,已經被過濾了。
最後在重組成 bundle
,通過 fs
在傳送到我們的檔案。
留在最後
tree shaking 還要很多點值得挖掘,如:
- css 的 tree shaking
- webpack 的 tree shaking 實現
- 如何避免 tree shaking 無效
- ...