1

webpack等构建工具提供tree-shaking机制, 利用es6Module的语法的exportimport语法进行静态分析,对无用代码进行剔除,减少打包后的代码量.
启动webpack的tree-shaking,需要:

  • webpack在v2.0以上
  • 开启代码压缩

webpack只是标记语句依赖以及是否使用, tree-shaking的具体实现一般是由压缩器提供实现, 如webpack默认的压缩工具 terser-webpack-plug就支持tree-shaking.本文不是讨论如何启用tree-shaking,也不研究tree shaking的底层原理,只通过案例, 研究tree-shaking对代码的一些影响.

演示中的webpack的配置为:

const path = require('path');

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    // filename: 'bundle.js',
    path: path.join(__dirname, 'dist')
  },
  devtool: 'hidden-source-map',
};
js压缩器的选项采用默认, 在源码中添加特殊的符号>>>来查看效果.

模块接口没有相互依赖

模块中的代码:

export var firstName = '>>>Michael';

export var lastName = '>>>Jackson';

export var year = 1958;

export var person = { name: '>>>joyer' };

export function log(info) {
  console.log(`>>>${info}`);
}

export default function() {
  console.log('>>>i am default');
}

在入口文件(src/index.js)中, 如果导入(包括全量导入, 默认导入, 具名导入)但没有使用的话:

import { firstName } from './mod1.js';

console.log('>>>index.js');

整个模块都会被忽略, 打包后的关键代码:

[
    function (e, t, r) {
        "use strict";
        r.r(t);
        console.log(">>>index.js")
    }
]

如果使用了导入模块的并使用某一接口:

import { firstName } from './mod1.js';

console.log('>>>in index.js');
console.log(firstName);

打包后后的关键代码为:

[
    function (e, t, n) {
        "use strict";
        n.r(t);
        console.log(">>>in index.js"), console.log(">>>Michael")
    }
]

函数也类似:

import { log } from './mod1.js';

console.log('>>>in index.js');

((() => {
  log();
})());

打包后的关键代码:

[
    function (e, t, n) {
        "use strict";
        n.r(t);
        var r;
        console.log(">>>in index.js"), console.log(">>>" + r)
    }
]

复杂的调用情况下:

import { log } from './mod1.js';

console.log('>>>in index.js');
var modLod = log;
var _modLod = modLod();

((() => {
  function callModLog () {
    console.log('callModLog==>', callModLog);
    _modLod();
  }
  _modLod && _modLod();
  callModLog();
})());

打包后的关键代码:

[
    function (e, n, t) {
        "use strict";
        t.r(n);
        console.log(">>>in index.js");
        var r = function (e) {
            console.log(">>>" + e)
        }();
        r && r(),
            function e() {
                console.log("callModLog==>", e), r()
            }()
    }
]

默认导入跟具名导入一样:

import log from './mod1.js';

console.log('>>>i am index.js');
log();

打包后关键代码:

[
    function (e, t, n) {
        "use strict";
        n.r(t);
        console.log(">>>i am index.js"), console.log(">>>i am default")
    }
]

全量导入, 声明式的使用接口, 跟具名导入一致:

import * as Mod from './mod1.js';

((() => {
  console.log('>>>i am index.js');
  console.log(Mod.firstName);
  Mod.log();
})());

打包后的关键代码:

[
    function (e, t, n) {
        "use strict";
        n.r(t);
        var r;
        console.log(">>>i am index.js"), console.log(">>>Michael"), console.log(">>>" + r)
    }
]

如果是动态的使用, 则丧失tree shaking效果:

import * as Mod from './mod1.js';

((() => {
  console.log('>>>i am index.js');
  console.log(Mod['firstName']);
  let methodName = 'log';
  Mod[methodName]();
})());

打包后的关键代码:

[
    function (e, n, t) {
        "use strict";
        t.r(n);
        var r = {};
        t.r(r), t.d(r, "firstName", (function () {
            return o
        })), t.d(r, "lastName", (function () {
            return u
        })), t.d(r, "year", (function () {
            return i
        })), t.d(r, "person", (function () {
            return l
        })), t.d(r, "log", (function () {
            return c
        })), t.d(r, "default", (function () {
            return f
        }));
        var o = ">>>Michael",
            u = ">>>Jackson",
            i = 1958,
            l = {
                name: ">>>joyer"
            };

        function c(e) {
            console.log(">>>" + e)
        }
        var f = function () {
            console.log(">>>i am default")
        };
        (() => {
            console.log(">>>i am index.js"), console.log(o);
            r.log()
        })()
    }
]

动态使用导入模块的接口, 将会丧失tree shaking.

可以看出, 如果模块(mod.js)导出接口(如yearlog)相互之间没有依赖, 且没有依赖模块中其他代码时, 会剔除无用代码.

上面的代码中, 出现大量模块代码之间的合并.模块mod1.js中的代码直接替换到使用语句,甚至连函数都精简了,这可能是由于webpack或代码压缩器的一些精简策略.

模块中跟导出接口无关的代码

一个模块中, 除了导出的各种接口外, 还有一些额外的没有被导出接口所依赖, 这样代码在tree shaking中的舍弃策略是如何的呢?

模块代码:


let count = 0;
let deep = '123';

function withSideEffect() {
  count ++;
  String.prototype.addOneMethod = () => {
    return deep;
  };
  window.newProp = "new";
  console.log('>>>>withSideEffect.js');
}

function withoutSideEffect() {
  count ++;
  deep = 'new deep';
  return count;
}
withoutSideEffect();
withSideEffect();
console.log('>>>>mod.js');
count++;

export default function() {
}

该模块中有大量的额外代码, 有一些额外的代码还是有副作用的, 但是默认导出没有依赖它们.
入口代码:

import mod from './mod1.js';

((async () => {
  console.log('>>>in index.js');
  mod();
})());

打包后的关键代码:

[
    function (e, t, n) {
        "use strict";
        n.r(t);
        String.prototype.addOneMethod = () => {}, window.newProp = "new", console.log(">>>>withSideEffect.js"), console.log(">>>>mod.js");
        (async() => {
            console.log(">>>in index.js")
        })()
    }
]

分析上面的代码, 发现对于count相关的语句都被舍弃了, 具有副作用的语句(console.log, window., String.prototype)都被保留了, 这表明tree shaking可以分析依赖到语句级, 对于没有被导出接口依赖的语句, 或者具有副作用语句依赖的无副作用代码(比如count ++)统统都舍弃掉. 可以通过一个副作用中依赖正常语句, 来进一步研究.

第三方库默认是看作具有副作用的.

如果模块语句是:

let deep = 'old';
let count = 0;

function withSideEffect() {
  count ++;
  String.prototype.addOneMethod = () => {
    return deep;
  };
  window.newProp = "new";
  console.log('>>>>withSideEffect.js');
}

function withoutSideEffect() {
  count ++;
  deep = 'new';
  return count;
}
withoutSideEffect();
withSideEffect();
console.log('>>>>mod.js');
count++;

export default function() {
}

打包后的关键代码:

[
    function (e, t, n) {
        "use strict";
        n.r(t);
        let o = "old";
        o = "new", String.prototype.addOneMethod = () => o, window.newProp = "new", console.log(">>>>withSideEffect.js"), console.log(">>>>mod.js");
        (async() => {
            console.log(">>>in index.js")
        })()
    }
]

你会发现, 变量deep都被保留下来了. 这是由于deep这个变量会对String.prototype.addOneMethod这个副作用语句产生副作用, 故保留下来.

tree shaking作用域语句级别的依赖分析, 非常强大且智能的深入帮我们剔除无用代码.

模块导出接口为一个对象

如果模块导出为一个对象, 会怎么处理呢?
模块代码:

const api = {};

api.name = '123';

api.foo = () => {
  console.log('>>>foo');
}

api.bar = () => {
  console.log('>>>bar');
}

export default api;

入口文档:

import api from './mod.js';

api.foo();

打包后的关键代码:

[
    function (e, t, r) {
        "use strict";
        r.r(t);
        const n = {
            name: "123",
            foo: () => {
                console.log(">>>foo")
            }, bar: () => {
                console.log(">>>bar")
            }
        };
        n.foo()
    }
]

发现不会剔除对象中没有使用到的namebar, 可以推测tree shaking分析: 由于api.name = '123';, api.bar = ...这两个语句, 对api这个变量进行了赋值, 但入口文件有对api这个变量进行使用, 依赖了所有对api变量进行操作的语句, 因此没有对这些实际上无用的代码进行剔除.

Class也是相同的处理

模块接口有依赖

如果模块的接口有对其他的接口依赖, tree shaking将会怎么处理呢?

模块的代码:

export var firstName = '>>>Michael';

export var lastName = '>>>Jackson';

export var name = firstName + lastName;

export var person = {
  name,
  getName() {
    firstName = '>>>new';
    return firstName;
  }
};

let info = '>>>info';

export function log() {
  console.log(info);
  return firstName;
}

export default function() {
  info = '123';
  log();
}

入口使用:

import { person } from './mod1.js';

console.log('>>>in index.js');
console.log(person);

发现两个接口的源码被并入到入口模块中去了:

[
    function (e, n, t) {
        "use strict";
        t.r(n);
        var r = ">>>Michael",
            o = {
                name: r + ">>>Jackson",
                getName: () => r = ">>>new"
            };
        console.log(">>>in index.js"), console.log(o)
    }
]

如果是使用模块函数接口(log), 该函数引用了模块内部变量和其他接口变量, 打包代码变为:

[
    function (e, n, t) {
        "use strict";
        t.r(n);
        var r = ">>>Michael";
        let o = ">>>info";

        function u() {
            return console.log(o), r
        }
        console.log(">>>in index.js"), console.log(u())
    }
]

使用模块默认接口函数也是一样, 打包代码未:

[
    function (e, n, t) {
        "use strict";
        t.r(n);
        let r = ">>>info";
        var o = function () {
            r = "123", console.log(r)
        };
        console.log(">>>in index.js"), console.log(o())
    }
]

在简单的使用模块中, 没有使用的代码将会被舍弃(语句级).
如果模块中多个接口依赖同一个模块变量, 且有副作用, 将会如何?
模块代码:

let count = 0;

export function addCount() {
  console.log('>>>addCount');
  count ++;
}

export function getCount() {
  console.log('>>>getCount');
  return count;
}

export function setCount(_count) {
  console.log('>>>setCount');
  count = _count;
}

入口代码:

import { addCount, getCount } from './mod1.js';

((async () => {
  console.log('>>>in index.js');
  addCount();
  console.log(getCount());
})());

打包后关键代码:

[
    function (e, t, n) {
        "use strict";
        n.r(t);
        let o = 0;
        (async() => {
            console.log(">>>in index.js"), console.log(">>>addCount"), o++, console.log((console.log(">>>getCount"), o))
        })()
    }
]

可以发现, 依旧会删除无用代码.

上面的探究示例中会把setCount这个接口给删除掉, 且只要addCountaddCount中无console.log时, 整个模块都会被舍弃掉,就算有console.log语句,也会舍弃模块中关于count相关的代码:

import { addCount } from './mod1.js';

((async () => {
  console.log('>>>in index.js');
  addCount();
})());

打包后的关键代码:

[
    function (e, t, n) {
        "use strict";
        n.r(t);
        (async() => {
            console.log(">>>in index.js"), console.log(">>>addCount")
        })()
    }
]

这是由于只使用addCount时, count++并不会行影响整个程序(无副作用), 所以count相关的代码都被舍弃了, 而使用了getCount, 那么count相关语句就会对程序产生影响, 故而都保留了下来.

可以得出结论: tree shaking会分析对模块中接口的语句层次的依赖分析,对没有依赖的语句或者无副作用的依赖语句进行删除.

异步导入模块

上面都只分析了一个入口, 一份打包文件的情况, 但实际场景下, 可能有多份入口, 或者多个异步导入导致打包出来多份文件.但是这里只探究异步导入的情况, 因为多份入口类似.

模块代码(mod1.js):

export var firstName = '>>>Michael';

export var lastName = '>>>Jackson';

export var year = 1958;

export var person = { name: '>>>joyer' };

export function log(info) {
  console.log(`>>>${info}`);
}

export default function() {
  console.log('>>>i am default');
}

入口代码:

((async () => {
  const mod = await import('./mod1.js');
  console.log(mod.firstName);
})());

打包后的关键代码:

[
    function (e, t, r) {
        (async() => {
            const e = await r.e(1).then(r.bind(null, 1));
            console.log(e.firstName)
        })()
    }
]

[,
        function (n, o, e) {
            "use strict";
            e.r(o), e.d(o, "firstName", (function () {
                return t
            })), e.d(o, "lastName", (function () {
                return r
            })), e.d(o, "year", (function () {
                return u
            })), e.d(o, "person", (function () {
                return c
            })), e.d(o, "log", (function () {
                return i
            }));
            var t = ">>>Michael",
                r = ">>>Jackson",
                u = 1958,
                c = {
                    name: ">>>joyer"
                };

            function i(n) {
                console.log(">>>" + n)
            }
            o.default = function () {
                console.log(">>>i am default")
            }
        }
    ]

可以发现, tree shaking已经失效.被异步导入的模块不具有剔除代码的效果.

如果被导入的异步模块中在导入一个模块呢?这是平常开发中, spa项目的标准模块导入结构.
在模块(mod2.js)中导入(mod1.js)代码:

import { log } from './mod1.js';

export function log2() {
  log(20);
}

export function extra() {
}

模块(mod1.js)代码:

export var firstName = '>>>Michael';

export var lastName = '>>>Jackson';

export var year = 1958;

export var person = { name: '>>>joyer' };

export function log(info) {
  console.log(`>>>${info}`);
}

export default function() {
  console.log('>>>i am default');
}

入口文件代码:

import { firstName } from './mod1.js';

console.log('>>>index.js');
((async () => {
  const mod = await import('./mod2.js');
  console.log(mod.log2());
  console.log(firstName);
})());

打包后的关键代码:

// main.js
[
    function (e, n, t) {
        "use strict";
        t.d(n, "a", (function () {
            return r
        })), t.d(n, "b", (function () {
            return o
        }));
        var r = ">>>Michael";

        function o(e) {
            console.log(">>>" + e)
        }
    },
    function (e, n, t) {
        "use strict";
        t.r(n);
        var r = t(0);
        console.log(">>>index.js"), (async() => {
            const e = await t.e(1).then(t.bind(null, 2));
            console.log(e.log2()), console.log(r.a)
        })()
    }
]
// 1.js
{
        2: function (n, t, o) {
            "use strict";
            o.r(t), o.d(t, "log2", (function () {
                return u
            })), o.d(t, "extra", (function () {
                return i
            }));
            var c = o(0);

            function u() {
                Object(c.b)(20)
            }

            function i() {}
        }
    }

可以发现, 对于异步导入的模块mod2.js中,尽管extra接口没有使用, 也会被导入进来, 也就是说异步导入模块会进行全量导入.对于模块mod1.js来说, 无论是直接在入口文件直接导入使用, 还是在异步模块mod2.js导入, 会根据使依赖情况, 进行语句依赖分析, 剔除无用的语句代码.

可以把异步导入也作为一个入口文件来看待的话.

聚合模块分析

在一些支持tree shaking的第三方库中, 为了支持导入方便, 都有一个模块聚合了其他的所有模块.如antd库, 在index.js中, 聚合了其他的所有模块的default接口,类似于

export { default as mod1 } from './mod1.js';
export { default as mod2 } from './mod2.js';

模块1mod1.js:

export function foo() {
  console.log('>>>mod1 foo');
}

export default function() {
  console.log('>>>mod1.js');
}

模块2mod2.js:

export function bar() {
  console.log('>>>mod2 bar');
}

export default function() {
  console.log('>>>mod2.js');
}

入口文件:

import { mod1 } from './mod.js';

mod1();

打包后的关键代码:

([
    function (e, t, r) {
        "use strict";
        r.r(t);
        console.log(">>>mod1.js")
    }
]

只打包了模块1的default接口的代码, tree shaking成功.

入口文件进行全量导入的场景:

import * as M from './mod.js';

M.mod1();

打包后的关键代码:

[
    function (e, t, r) {
        "use strict";
        r.r(t);
        console.log(">>>mod1.js")
    }
]

一样的效果,可以推测, tree shaking在模块依赖过程中进行语句依赖的传递.

在聚合模块中, 尝试默认导入和具名导入:

export { default as mod1, foo } from './mod1.js';
export { default as mod2, bar } from './mod2.js';

入口文件修改为:

import {foo, bar} from './mod.js';

foo();
bar();

打包后的关键代码:

[
    function (e, t, r) {
        "use strict";
        r.r(t);
        console.log(">>>mod1 foo"), console.log(">>>mod2 bar")
    }
]

具有tree shaking特性.

在聚合模块中, 全量导入:

export * from './mod1.js';
export * from './mod2.js';

入口文件修改为:

import { foo } from './mod.js';

foo();

打包后的关键代码:

[
    function (e, t, r) {
        "use strict";
        r.r(t);
        console.log(">>>mod1 foo"), (void 0)()
    }
]

具有tree shaking

注意, 全量导入不会导入default接口

es6中还有一种命名空间式的导入导出(聚合模块中代码):

import * as mod1 from './mod1.js';
import * as mod2 from './mod2.js';
export {
  mod1,
  mod2,
};
虽然es2020中有上面导入的简写export * as mod1 from "./mod1.js";, 但这在最新的webpack中还不被支持.

入口文件代码:

import { mod1 } from './mod.js';

mod1.foo();

打包后关键代码:

[
    function (e, t, n) {
        "use strict";
        n.r(t);
        var r = {};

        function o() {
            console.log(">>>mod1 foo")
        }
        n.r(r), n.d(r, "foo", (function () {
            return o
        })), n.d(r, "default", (function () {
            return u
        }));
        var u = function () {
            console.log(">>>mod1.js")
        };
        r.foo()
    }
]

发布具有tree shaking效果, 但是不彻底, 携带了mod1.jsdefaule接口, 但是这个接口在整个应用中并没有用到. 流行的element-ui就是采用这种方式聚合所有的组件.

element-ui组件库并不支持tree shaking, 一是由于它并没有设置sideEffects, 二是, 在element-ui的聚合模块中, 还有一个注册所有组件为全局组件的副作用, 这回导致tree shaking失效.

写在最后

总结:

  • webpack的tree shaking非常强大. 剔除代码是语句级别,并可以根据模块依赖进行深层次的依赖分析.但这会导致代码侵入性非常高.
  • 对于类或者对象, 不会做到无用代码剔除.

joyerli
158 声望5 粉丝

前端搬砖一枚,会分享一些对技术的个人理解和思考,还会分享一些自己解决实际碰到的业务需而设计的奇葩技术方案。