Webpack + Babel 打包文件大小优化

在工作的一个项目中采用 Webpack 进行打包,Babel 作为 ES6 语法转码器,打包后的文件体积偏大,想要做一些文件体积大小优化,措施主要有:

  1. 去掉没用 babel transformer 和 polyfill

  2. 利用 Webpack2 的 Tree-shaking 特性

原始的 Webpack 打包配置

var webpackConfig = {
  // entry: {...},
  // output: {...},
  resolve: {
    extensions: ['', '.js', '.we'],
    fallback: [path.join(__dirname, '../node_modules')]
  },
  module: {
    loaders: [
      {
        test: /\.js(\?[^?]+)?$/,
        loader: 'babel',
        exclude: /node_modules/,
        query: {
          presets: ['es2015'],
          plugins: ['transform-runtime', 'transform-object-rest-spread']
        }
      }
    ]
  }
}

升级 Webpack2

因为后续我们需要用到 Webpack2 的 Tree-shaking 特性,因此我们第一步把 Webpack 升级到 Webpack2

npm install webpack@beta --update-binary --save-dev

另外 Webpack2 的配置形式也有变化,How to Upgrade from Webpack 1?

因此我们的配置修改如下:

var webpackConfig = {
  // entry: {...},
  // output: {...},
  resolve: {
    extensions: ['.js', '.we'],
    modules: [path.join(__dirname, '../node_modules')]
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: [es2015],
          plugins: [
            "transform-runtime", "transform-object-rest-spread"
          ]
        }
      }
    ]
  }
}

去掉没用的 babel polyfill

首先我们关注到 babel 插件 babel-plugin-transform-runtime, 这是一个提供 ES6 API (比如Iterator、Generator、Set、Maps、Proxy、Reflect、Symbol、Promise等全局对象,Array.from or Object.assign 等静态方法和 Array.prototype.includes 等实例方法)转码的工具,目的是模拟出 ES6 的运行环境。


babel-plugin-transform-runtimebabel-polyfill 的区别是:

babel-polyfill 在每个被引用到的文件中都会插入一份,这样通常会造成多份冗余。并且 babel-polyfill 会被添加到全局环境中。

babel-plugin-transform-runtime 会把 polyfill 分别用模块包裹起来,达到整个项目代码只有一份 polyfill 而且不会污染全局环境的目的。


babel-plugin-transform-runtime 分为三个部分:

  • Babel helpers: babel-runtime/helpers
  • Standard library: babel-runtime/helpers
  • Regenerator API: generator函数的转换

至于 babel-plugin-transform-runtime 支持哪些 API 的转换可以在该模块下的 definition.js 文件下找到。


根据现在业务代码中只用到了 generation 函数,因此可以只保留 Regenerator API 的部分,其他需要用到的话日后再单独引入对应的 polyfill 。

因此修改如下:

var webpackConfig = {
  // entry: {...},
  // output: {...},
  // resolve: {...},
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: [es2015],
          plugins: [
            ["transform-runtime", {
              "helpers": false,
              "polyfill": false
            }],
            "transform-object-rest-spread"
          ]
        }
      }
    ]
  }
}

关于 babel-preset-es2015

上面说到的 babel polyfill 是对 ES6 API 的转码,而 babel transformer 是对 ES6 语法(syntax)的转码。我们用到的 babel transformer 包括在了 babel-preset-es2015 预置中。

由于 transformer 对于 ES6 语法的转换是按需的,不会产生冗余或不必要的代码,因此 transformer 没有优化空间。

Webpack2 Tree-shaking

关于 Tree-shaking 请查看这篇知乎讨论 如何评价 Webpack 2 新引入的 Tree-shaking 代码优化技术?

大概原理是去掉不可能执行的代码,从而减少文件体积。

首先需要修改 babel 的 es2015 preset 使其不包含 commonjs,不然无法利用 Tree-shaking 进行优化。

先来说一下为什么需要把 commonjs 模块去掉。

有这样一个 helper.js

// helpers.js
export function foo() {
  return 'foo';
}
export function bar() {
  return 'bar';
}

另外有一个 main.js, 是程序入口:

// main.js
import {foo} from './helpers';

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

经过 transform-es2015-modules-commonjs 转码后,会变成:

function(module, exports) {

    'use strict';

    Object.defineProperty(exports, "__esModule", {
        value: true
    });
    exports.foo = foo;
    exports.bar = bar;
    function foo() {
        return 'foo';
    }
    function bar() {
        return 'bar';
    }

}

bar() 虽然并没有被使用到,但还是会被挂到了 exports 上,这会影响到 bar() 被检测成为无用代码。

如果我们把 transform-es2015-modules-commonjs 插件去掉,helpers 就会长这样:

function(module, exports, __webpack_require__) {

    /* harmony export */ exports["foo"] = foo;
    /* unused harmony export bar */;

    function foo() {
        return 'foo';
    }
    // 注:这里的 bar() 会被后续的 UglifyJs 干掉的。
    function bar() {
        return 'bar';
    }
}

所以我们的配置为修改为:

var webpackConfig = {
  // entry: {...},
  // output: {...},
  // resolve: {...},
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: [
           ["es2015", { "modules": false }] // 把 commonjs 插件排除
          ],
          plugins: [
            ["transform-runtime", {
              "helpers": false,
              "polyfill": false
            }],
            "transform-object-rest-spread"
          ]
        }
      }
    ]
  }
}

对于没有被用到的 exports 会有 /* unused harmony export */ 标示,但是在我们的 bundle 内搜索一下,竟然没有! What!

让我们来慢慢分析一下。

在我们的项目中绝大部分文件我们都是这样的姿势:

// lib.js
export default {
    foo: () => {},
    bar: () => {},
};
// main.js
import {foo,bar} from './lib';

其实这样是不符合 ES6 的规范的,为什么呢? 这里就不详述了,请看:Misunderstanding ES6 Modules, Upgrading Babel, Tears, and a Solution,主要原因是 ES6 规范中写明了 exportimport 是可以被静态检查的。

另外一个问题,因为我们把 commonjs 干掉了,考虑有一个 foo.js:

const foo = {}
export default foo

babel 后:

function(module, exports, __webpack_require__) {

  "use strict";
  var foo = {};
  /* harmony def`ault export */ exports["default"] = foo;

/***/ }

注意到 exports["default"] = foo 了么! 因此如果我们要使用的话,还要这样搞:

import foo from 'foo.js'
console.log(foo.default) // 要加一个 .default

基本上思路就是这样子了...

参考资料

Babel 入门教程

Babel: configuring standard library and helpers

Tree-shaking with webpack 2 and Babel 6

How To Clean Up Your JavaScript Build With Tree Shaking

tree shaking (dead code elimination)

Babel and CommonJS modules

Misunderstanding ES6 Modules, Upgrading Babel, Tears, and a Solution

webpack example: harmony-unused

« 返回