來源 tree-shaking 最早由 Rich Harris 在 rollup 中提出。 為了減少最終構建體積而誕生。 以下是 MDN 中的說明: tree-shaking 是一個通常用於描述移除 JavaScript 上下文中的未引用代碼(dead-code) 行為的術語。 它依賴於 ES201 ...
來源
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 無效
- ...