• 中文
  • ENGLISH
今天,你升级Webpack2了吗?
2017/02/20

本文作者:彼洋

1. 前言

Web前端打包技术日新月异,从grunt到gulp再到webpack,如今又出现了新的挑战者:rollup,它祭出的杀手锏是tree-shaking,可以减小打包后的体积。为了赶超rollup,webpack也加入了tree-shaking功能,推出了webpack2。

笔者最近在开发移动端web应用,出于减小资源体积的想法,从webpack升级到了webpack2,利用tree-shaking成功将体积减小了14%。鉴于它是公司内部的业务项目,不便把代码公之于众,在这里,笔者把自己的经验分享给大家。

文章整体目录如下:
  1. 前言
  2. tree-shaking
    • 什么是tree-shaking
    • webpack2的tree-shaking实现
  3. 升级
    • 升级版本
    • 修改配置文件
    • 调整模块目录结构
    • 修改模块语法
    • 改变打包流程
  4. 总结
  5. 附录
  6. 参考资料

2. tree-shaking

2.1. 什么是tree-shaking

tree-shaking

tree-shaking这个概念最初是由rollup提出来的,从字面意思来理解,摇动一棵树,枯枝败叶就会掉下来,看起来是指去掉不需要的代码,和DCE(dead code elimination,死代码消除)差不多。不过,它和DCE还是有区别的,用作者自己的话来说,DCE是去除死代码,而tree-shaking是保留活代码,是实现DCE的一种方式。
众所周知,commonjs模块是动态加载的,且可以重命名,要想在静态分析阶段判断哪些代码不会被执行到,有一定难度,需要借助数据流分析。所以tree-shaking是借助了ES6的模块机制,通过import/export等关键字来定义输入输出的方法,且其重命名只能通过as这个关键字,模块一旦被import进来,就是只读的,这样,我们只根据名字,就可以从入口文件一路溯源到模块定义处,只把用到的方法打包进来。

2.2. webpack2的tree-shaking实现

要想使用tree-shaking,需要解析ES6模块语法,webpack2是借助于acorn实现这一点的。在拿到AST之后,webpack2会统计每个模块export的方法被使用的次数,并把没有用到的export语句删掉。至于没有被export的定义,则要在后续的DCE(dead code elimination)过程中消除。import/export之外的ES6代码,要使用Babel进行转码,因为acorn只有解析功能,但没有转换功能。示例代码如下:
helpers.js

export function foo() {
    return 'foo';
}
export function bar() {
    return 'bar';
}

main.js

import {foo} from './helpers';

let elem = document.getElementById('output');
elem.innerHTML = `Output: ${foo()}`;

helpers.bundle.js (// after webpack)

function(module, exports, __webpack_require__) {
    /* harmony export */ exports["foo"] = foo;
    /* unused harmony export bar */;

    function foo() {
        return 'foo';
    }
    function bar() {
        return 'bar';
    }
}

helpers.bundle.min.js (// after uglify)

function (t, n, r) {
    function e() {
        return "foo"
    }
    n.foo = e
}

可见,经过webpack2打包之后,未使用的export bar会被标记为/* unused harmony export bar */,然后,再经过uglify,未被export的bar定义会被删除。

3. 升级

看起来webpack2很好很强大,我们应该升级,就连它的官方网站都在催促升级。

webpack v1 is deprecated. We encourage all developers to upgrade to webpack 2.

还给出了一份升级指南(这里还有一份第三方的),具体我就不再赘述了。当然,这些指南只涉及了配置文件的修改,而对大多数的工程来说,只修改配置是无法充分利用tree-shaking的。下面,我就以自己的项目为例,从升级版本,到修改配置文件,再到改写模块语法,改变打包流程等方面,来说明一下webpack2 tree-shaking优化升级的过程。

3.1. 升级版本

首先把webpack更新为webpack2,默认安装v2版本

tnpm ii webpack --save-dev

查看版本:

./node_modules/.bin/webpack --version   //2.2.1

然后升级babel-core@6.22.1、babel-preset-es2015@6.22.0、babel-loader@6.2.10的版本。

3.2. 修改配置文件

3.2.1. .babelrc

首先更新.babelrc,主要是更新babel-preset-es2015这个预设,因为它包含了babel-plugin-transform-es2015-modules-commonjs这个变换插件,会把ES6模块变换为commonjs模块。前面我已经说过了,tree-shaking是利用ES6模块机制实现的,所以不可以使用这个babel插件进行转换。

Axel的例子中,他把这个预设中的其他插件一一手动列出,后来有人把这些插件放在一起作为新的预设babel-preset-es2015-native-modules,再后来babel-preset-es2015本身就支持禁用这个插件,只要写成["es2015", { "modules": false }]即可。

然后,如果你还在使用babel-plugin-add-module-exports,那么现在可以去掉了。因为它是为了支持错误的import/export方法而存在的,而这现在是由webpack2接管的。

另外,如果你的开发环境是chrome等现代浏览器,原生就已经支持ES6的大部分特性,就可以在开发时去掉大部分的preset和plugin,只保留babel-preset-react等浏览器无法支持的预设,以提升编译速度,上线时再加上剩余的babel转换。

3.2.2. webpack.config.js

然后更新webpack的配置文件,大部分改动都在这个文件中。结合官方迁移教程,对照自己的配置,一一修改即可。如果遗漏,webpack会提示的。

loader方面改动较大。首先是把loaders改名为rules。然后,为了支持loader的传参,增设了options参数;为了方便多loader,增设了use字段,其参数为loader的数组。写loader名字的时候,不再能省略后面的-loader

在webpack2中使用happypack,如果需要向loader传递参数,需要通过query参数,可以去github仓库自寻官方示例

值得一提的是,webpack2去掉了OccurenceOrderPlugin和DedupePlugin,前者的功能是调整模块顺序,把经常用到的放在前面,可以减小require时的数字长度,去掉是因为已经默认支持,不再像之前那样会影响速度;后者的功能是去除重复模块,去掉是因为重复模块应该靠npm去除,而不是靠webpack来比较hash。

3.3. 调整模块目录结构

要想用npm实现模块去重,应该使用npm@3,或者tnpm@3/4,前者会把依赖的模块安装在同级目录,后者会维持node_modules目录结构,但是通过软链接指向同级的实际模块目录。不管用哪种方式,都实现了只有一个模块实体。

值得注意的是,如果你使用npm link,则仍然保持原有的目录结构,可能会带来重复模块,这时可以使用alias把依赖的模块指向最外层。

3.4. 修改模块语法

考察下面这种写法:

export function setTitle() {
  ...
}

export function addButton() {
  ...
}

export default {
  setTitle,
  addButton
};

它的目的是把setTitle和addButton作为单独的方法输出,同时也把模块整体作为对象输出。这种写法其实是反模式的。根据ES6模块规范,可以认为export default是把后面的变量先赋给default再输出,所以用export default {}的写法,输出的其实就是个Object,没法利用ES6模块系统的多输入多输出特性,也没法利用tree-shaking。要想输出多个方法,应该分别输出,或者先定义,再统一以下面的形式输出:

export {
  setTitle,
  addButton
};

你可能想问,不输出default的话,如果想要整体import该怎么办。

ES6提供了下面这种语法(namespace import):

import * as a from 'a'

然后即使调用a.setTitlea.addButton也能正常tree-shaking。

3.5. 改变打包流程

之前我们的打包流程,一般是先经过babel-loader转码,然后webpack打包,最后使用uglify压缩,这一切都可以在webpack.congif.js中配置。但现在,出于一点考虑,我要改变这个流程了,这一点就是副作用。

3.5.1. 副作用

3.5.1.1. IIFE的副作用

我们看一下经过DCE的代码,以其中的一个class定义为例:

(function() {
    function LocalStore() {
        _classCallCheck(this, LocalStore), this.LocalStoreObj = null;
    }
    return _createClass(LocalStore, [
        ...
    ]), LocalStore;
})()

用到这个class的export已经被去除了,但class仍然存在。到uglify的warning信息中搜索LocalStore,可以发现这么一句:

Side effects in initialization of unused variable LocalStore [ubanner.js:10902,4]

它后面的行号是uglify之前的位置,所以我们去掉uglify插件,再打包一次,然后定位到这个位置:

var LocalStore = function () {
    function LocalStore() {
        _classCallCheck(this, LocalStore);
        this.LocalStoreObj = null;
    }
    _createClass(LocalStore, [
      ...
    ])
    return LocalStore;
}();

结合报错信息,问题应该出在后面的IIFE上。关于这个问题,有人已经踩过坑。简单来说,就是,对某个变量的属性赋值操作无法被消除,因为不确定这个变量是否在其他地方被使用。在没有数据流分析的情况下,只能做这种保守处理。他给的例子中,是对prototype属性进行了赋值;而我的这个例子中,既存在对对象属性的赋值:this.LocalStoreObj = null;,又存在函数调用:_classCallCheck_createClass。(同样是class的转换结果,之所以会和例子中的不一样,是因为loose mode

要想解决这个问题,需要在class未被转换为ES5的情况下就进行DCE,具体来说,就是去掉babel-preset-es2015中的babel-plugin-transform-es2015-classes。前面说过,开发阶段可以在现代浏览器中运行ES6的代码,只保留react预设和其他少量plugin,那么,DCE就可以在其基础上进行。

对于ES6代码的uglify,不可以使用webpack的uglify插件,因为它其实是UglifyJS2,其harmony branch尚未发布,不能压缩ES6的代码,所以要寻找其他的uglify工具,我选择了babel-plugin-minify-dead-code-elimination。它隶属于babel的babel-preset-babili,虽然支持的配置项比较少,但胜在代码规范且简单,方便后续修改。

使用上述工具进行DCE之后的代码还是ES6的,所以在发布时,需要使用babel对打包之后的代码进行转换,开启preset-es2015及preset-stage-0,然后再用Uglify进行压缩。

3.5.1.2. getter的副作用

查看下面的代码:

(class extends __WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"] {
  ...
});

它是MessageCon类的定义,很明显,前面的let MessageCon =已经被去掉了,但是后面的initialization还保留着,原因是副作用。具体来说,是__WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"]可能会调用__WEBPACK_IMPORTED_MODULE_0_weex_rx__的getter方法,所以被判断有副作用。要想解决它,只要把__WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"]先赋值给某个变量,比如_ref1,然后再把_ref1作为MessageCon的父类。要实现这个变换,可以使用正则表达式,当然更好的方法是自己实现babel插件来做转换。

然后,__WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"]需要通过UglifyJS2去除,把pure_getters选项置为true,即可提示UglifyJS2形如foo.barfoo["bar"]的属性访问是没有副作用的。

3.5.2. 3轮DCE

这里要说明一点,要想有效消除死代码,我们需要至少3轮DCE。为何要3轮?以Message组件为例,其代码如下:

let _ref10 = __WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"];
let MessageCon = class MessageCon extends _ref10 {
    constructor() {
        super();
    }
    ...
};

function success(children, onClosed) {
    expression containing MessageCon
}

function warn(children) {
    expression containing MessageCon
}

function error(children) {
    expression containing MessageCon
}

这是经过webpack的tree-shaking和上面提到的babel-super-class-plugin处理过的代码,显然,这个模块的export已被webpack去掉,所有代码都应该去除,但是它们之间还有依赖关系,如下图所示:

DCE dependency.png

因为有3层,所以要经过3轮DCE。实际上,应该经过N轮,直到代码不再变化为止,但大多数文件3轮足矣。

3.5.3. 新的打包流程

综上所述,新的打包流程就是:
1. 使用preset-react(或其他类似preset/plugin)和super-class-plugin将jsx转码为ES6(开发环境可直接使用这个代码)
2. 使用babel-plugin-minify-dead-code-elimination对ES6代码进行3轮DCE
3. 使用preset-es2015和preset-state-0转码为ES5
4. 使用Uglify对ES5代码进行DCE

4. 总结

总结一下,本文所采取的升级步骤为:
1. 升级webpack和babel版本
2. 改写webpack和babel配置
3. 升级npm/tnpm版本以确保node_modules不包含重复模块
4. 修改import/export语法
5. 改变打包流程
1. 使用preset-react(或其他类似preset/plugin)和super-class-plugin将jsx转码为ES6(开发环境可直接使用这个代码)
2. 使用babel-plugin-minify-dead-code-elimination对ES6代码进行3轮DCE
3. 使用preset-es2015和preset-state-0转码为ES5
4. 使用Uglify对ES5代码进行DCE

经过以上步骤,我的项目体积减小了14%,当然,还尚未对一些第三方库进行tree-shaking,而且一些二方库放到了CDN,也限制了tree-shaking的效果。接下来,我会继续研究对于第三方库的tree-shaking,欢迎同道者交流。

5. 附录

5.1. happypack

如果你使用了happypack,在升级时可能需要清空happypack的缓存目录,默认是.happypack。
happypack是一个webpack插件,用来实现多线程打包。它默认使用缓存,判断缓存是否生效是看上次编译时的最后修改时间和当前的最后修改时间是否一致,如果一致就使用缓存。这就带来了一个问题:如果只改变打包配置,但不改变文件内容,则会使用缓存,但实际上我们是需要它重新打包的,这时就要删除缓存目录: .happypack。

5.2. stats.json

有时,你不知道打出来的包体积为何会这么大,这时,你可以在webpack analyze网站分析模块重复情况。

首先,我们要生成打包信息的记录文件stats.json,注意,如果你使用命令行的webpack,可以通过配置来开启stats,但如果使用Node.js的webpack,则要参照这个文档

把记录文件上传到网站之后,可以看到下图这个画面:

screenshot.png

点击modules,即可查看各个模块,点击size,按从大到小排序,去寻找那些不该出现,或者出现了多次的模块。想要知道模块在哪里被引用,点击issuer即可。

5.3. babel-plugin-transform-imports

这个插件会把形如

import { Text } from 'nuke';

的引用转换为下面的形式:

import Text from 'nuke/lib/Text';

如果你在使用一个组件库,那么,把大包引用改为小包就可以节省绝大部分的体积,从而不需要tree-shaking。

5.4. uglify参数

要想看哪些部分没有tree-shaking,需要对uglify的参数进行定制。要想DCE,需要开启compress,但是要想查看代码,需要关闭uglify以及保留comment。具体参数如下:

{
  beautify: true, // 添加适当的空格和换行
  compress: {     // 开启代码压缩,包括DCE等
    warnings: true,   // 当因为副作用等原因DCE失败时,会在命令行中给出警告
    drop_console: true,   // 不用解释了吧
  },
  output: { comments: true },  // 保留注释,方便寻找`unused harmony`标签
  mangle: false   // 禁用变量混淆,以方便分析
}  

5.5. 第三方ES6模块

现在,大部分的模块输出的代码都是commonjs标准的,没办法进行tree-shaking,但是,可以通过在package.json中添加module字段来指定es6的代码输出,如redux的做法。但要注意的是,由于现阶段大部分环境都不支持ES6特性,而webpack2又只能转换import/export部分的代码,所以其余的ES6特性需要模块提供者来进行转换,参见redux的babel配置。另外,鉴于上面提到的副作用,被转换为ES5代码后,有些代码无法被DCE消除,如转码后的class。所以,如果可能的话,直接解析模块的源码可以获得更好的体积压缩。或者,采用激进的DCE策略,这一点有待研究。

6. 参考资料

订阅我们