关于前端:treeshaking

34次阅读

共计 16873 个字符,预计需要花费 43 分钟才能阅读完成。

起源

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 个别具备以下几个特色:

  • 代码不会被执行,不可达到
  • 代码执行的后果不会被用到
  • 代码只会影响死变量(只写不读)

应用 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 执行相干的能够参考一下链接:

  • Tree shaking class methods
  • 你的 tree-shaking 并没什么卵用
  • 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

源码版本: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();}

遍历节点信息。寻找 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
                };
            }
        }
    }
}

    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 汇合中。

接下来依据 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;
}

执行完后果如下:

在上一步种,咱们为函数,变量,类,块级作用与等申明与咱们以后节点进行了关联,当初要把节点上的这些信息,对立收集起来,放到 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 有效

参考资料

  • Tree shaking class methods
  • 你的 tree-shaking 并没什么卵用
  • Webpack 原理系列九:tree-shaking 实现原理
  • tree-shaking 成果探讨

正文完
 0