tree-shaking

袋鼠雲數棧UED發表於2022-06-15

來源

tree-shaking 最早由 Rich Harrisrollup 中提出。

為了減少最終構建體積而誕生。

以下是 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 一般具有以下幾個特徵:

  • 程式碼不會被執行,不可到達
  • 程式碼執行的結果不會被用到
  • 程式碼只會影響死變數(只寫不讀)

使用 webpackmode: 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。

最後由 TerserUglifyJSDCE 工具“搖”掉這部分無效程式碼。

terser 測試

tree shaking 實現原理

tree shaking 本身也是採用靜態分析的方法。

程式靜態分析(Static Code Analysis)是指在不執行程式碼的方式下,通過詞法分析、語法分析、控制流分析、資料流分析等技術對程式程式碼進行掃描,驗證程式碼是否滿足規範性、安全性、可靠性、可維護性等指標的一種程式碼分析技術

tree shaking 使用的前提是模組必須採用ES6Module語法,因為tree Shaking 依賴 ES6 的語法:importexport

接下來我們來看看遠古版本的 rollup 是怎麼實現 tree shaking 的。

  1. 根據入口模組內容初始化 Module,並使用 acorn 進行 ast 轉化
  2. 分析 ast。 尋找 importexport 關鍵字,建立依賴關係
  3. 分析 ast,收集當前模組存在的函式、變數等資訊
  4. 再一次分析 ast, 收集各函式變數的使用情況,因為我們是根據依賴關係進行收集程式碼,如果函式變數未被使用,
  5. 根據收集到的函式變數識別符號等資訊,進行判斷,如果是 import,則進行 Module 的建立,重新走上幾步。否則的話,把對應的程式碼資訊存放到一個統一的 result 中。
  6. 根據最終的結果生成 bundle

file

原始碼版本: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();
    }

file

遍歷節點資訊。尋找 importexport 關鍵字,這一步就是我們常說的根據 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
                };
            }
        }
    }
}

file

    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 的中。到這一步,已經收集到了各節點上出現的函式和變數。

file

接下來,再一次遍歷 ast。查詢變數函式,是否只是被讀取過,或者只是修改過。

根據 Identifier 型別查詢識別符號,如果當前識別符號能在 Scope 中找到,說明有對其進行過讀取。存放在 _dependsOn 集合中。

file

接下來根據 AssignmentExpressionUpdateExpressionCallExpression 型別節點,收集我們的識別符號,有沒有被修改過或被當前引數傳遞過。並將結果存放在 _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;
}

執行完結果如下:

file

在上一步種,我們為函式,變數,類,塊級作用與等宣告與我們當前節點進行了關聯,現在要把節點上的這些資訊,統一收集起來,放到 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 );
    });
});

file

從中我們可以看到每個 statement 中,依賴了哪些,修改了哪些。

當我們在入口模組的操作完成後,在遍歷 statement 節點,根據 _dependsOn 的中的資訊,執行 define

如果 _dependsOn 的資料,在 this.imports 中,能夠找到,說明該識別符號是一個匯入模組,呼叫 fetchModule 方法,重複上面的邏輯。

如果是正常函式變數之類的,則收集對應 statement 。執行到最後,我們就可以把相關聯的 statement 都收集起來,未被收集到,說明其就是無用程式碼,已經被過濾了。

最後在重組成 bundle,通過 fs 在傳送到我們的檔案。

留在最後

tree shaking 還要很多點值得挖掘,如:

  • css 的 tree shaking
  • webpack 的 tree shaking 實現
  • 如何避免 tree shaking 無效
  • ...

參考資料