减小前端尺寸

如何使用 webpack 使您的应用尽可能小

优化应用程序时,首先要做的事情之一就是使其尽可能小。以下是如何使用 webpack 实现此目的。

使用生产模式(仅限 webpack 4)

Webpack 4 引入了新的 mode 标志。您可以将此标志设置为 'development''production',以提示 webpack 您正在为特定环境构建应用程序

// webpack.config.js
module.exports = {
  mode: 'production',
};

确保在为生产环境构建应用时启用生产模式。这将使 webpack 应用优化,例如缩小、删除库中仅用于开发的代码、等等

延伸阅读

启用缩小

缩小是指通过删除多余的空格、缩短变量名等来压缩代码。例如这样

// Original code
function map(array, iteratee) {
  let index = -1;
  const length = array == null ? 0 : array.length;
  const result = new Array(length);

  while (++index < length) {
    result[index] = iteratee(array[index], index, array);
  }
  return result;
}

// Minified code
function map(n,r){let t=-1;for(const a=null==n?0:n.length,l=Array(a);++t<a;)l[t]=r(n[t],t,n);return l}

Webpack 支持两种缩小代码的方法:捆绑包级别的缩小加载器特定的选项。它们应该同时使用。

捆绑包级别的缩小

捆绑包级别的缩小在编译后压缩整个捆绑包。以下是它的工作原理

  1. 您编写如下代码

    // comments.js
    import './comments.css';
    export function render(data, target) {
      console.log('Rendered!');
    }
    
  2. Webpack 将其编译为大致如下内容

    // bundle.js (part of)
    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony export (immutable) */ __webpack_exports__["render"] = render;
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css__ = __webpack_require__(1);
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css_js___default =
    __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__comments_css__);
    
    function render(data, target) {
    console.log('Rendered!');
    }
    
  3. 缩小器将其压缩为大致如下内容

    // minified bundle.js (part of)
    "use strict";function t(e,n){console.log("Rendered!")}
    Object.defineProperty(n,"__esModule",{value:!0}),n.render=t;var o=r(1);r.n(o)
    

在 webpack 4 中,捆绑包级别的缩小功能会自动启用 – 无论是否处于生产模式。它在底层使用 UglifyJS 缩小器。(如果您需要禁用缩小功能,只需使用开发模式或将 false 传递给 optimization.minimize 选项。)

在 webpack 3 中,您需要直接使用 UglifyJS 插件。该插件与 webpack 捆绑在一起;要启用它,请将其添加到配置的 plugins 部分

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
  ],
};

加载器特定的选项

第二种缩小代码的方法是加载器特定的选项(什么是加载器)。借助加载器选项,您可以压缩缩小器无法缩小的内容。例如,当您使用 css-loader 导入 CSS 文件时,该文件会被编译成一个字符串

/* comments.css */
.comment {
  color: black;
}
// minified bundle.js (part of)
exports=module.exports=__webpack_require__(1)(),
exports.push([module.i,".comment {\r\n  color: black;\r\n}",""]);

缩小器无法压缩此代码,因为它是一个字符串。为了缩小文件内容,我们需要配置加载器来执行此操作

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          { loader: 'css-loader', options: { minimize: true } },
        ],
      },
    ],
  },
};

延伸阅读

指定 NODE_ENV=production

另一种减小前端尺寸的方法是将代码中的 NODE_ENV 环境变量 设置为 production 值。

库读取 NODE_ENV 变量以检测它们应该在哪种模式下工作 – 开发模式还是生产模式。某些库根据此变量的行为有所不同。例如,当 NODE_ENV 未设置为 production 时,Vue.js 会执行额外的检查并打印警告

// vue/dist/vue.runtime.esm.js
// …
if (process.env.NODE_ENV !== 'production') {
  warn('props must be strings when using array syntax.');
}
// …

React 的工作方式类似 – 它加载包含警告的开发版本

// react/index.js
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

// react/cjs/react.development.js
// …
warning$3(
    componentClass.getDefaultProps.isReactClassApproved,
    'getDefaultProps is only used on classic React.createClass ' +
    'definitions. Use a static property named `defaultProps` instead.'
);
// …

这些检查和警告在生产环境中通常是不必要的,但它们保留在代码中并增加了库的大小。在 webpack 4 中,通过添加 optimization.nodeEnv: 'production' 选项来删除它们

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    nodeEnv: 'production',
    minimize: true,
  },
};

在 webpack 3 中,请改用 DefinePlugin

// webpack.config.js (for webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"'
    }),
    new webpack.optimize.UglifyJsPlugin()
  ]
};

optimization.nodeEnv 选项和 DefinePlugin 的工作方式相同 – 它们将所有出现的 process.env.NODE_ENV 替换为指定的值。使用上面的配置

  1. Webpack 会将所有出现的 process.env.NODE_ENV 替换为 "production"

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if (process.env.NODE_ENV !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    
  2. 然后缩小器将删除所有此类 if 分支 – 因为 "production" !== 'production' 始终为 false,并且插件理解这些分支内的代码永远不会执行

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    

    // vue/dist/vue.runtime.esm.js (without minification)
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    }
    

延伸阅读

使用 ES 模块

下一种减小前端尺寸的方法是使用 ES 模块

当您使用 ES 模块时,webpack 能够进行 tree-shaking。Tree-shaking 是指 bundler 遍历整个依赖树,检查使用了哪些依赖项,并删除未使用的依赖项。因此,如果您使用 ES 模块语法,webpack 可以消除未使用的代码

  1. 您编写一个具有多个导出的文件,但应用程序仅使用其中一个

    // comments.js
    export const render = () => { return 'Rendered!'; };
    export const commentRestEndpoint = '/rest/comments';
    
    // index.js
    import { render } from './comments.js';
    render();
    
  2. Webpack 理解 commentRestEndpoint 未被使用,并且不会在捆绑包中生成单独的导出点

    // bundle.js (part that corresponds to comments.js)
    (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    const render = () => { return 'Rendered!'; };
    /* harmony export (immutable) */ __webpack_exports__["a"] = render;
    
    const commentRestEndpoint = '/rest/comments';
    /* unused harmony export commentRestEndpoint */
    })
    
  3. 缩小器删除未使用的变量

    // bundle.js (part that corresponds to comments.js)
    (function(n,e){"use strict";var r=function(){return"Rendered!"};e.b=r})
    

这甚至适用于用 ES 模块编写的库。

虽然您不需要精确地使用 webpack 的内置缩小器 (UglifyJsPlugin)。任何支持死代码删除的缩小器(例如 Babel Minify 插件Google Closure Compiler 插件)都可以奏效。

延伸阅读

优化图像

图像占页面大小的一半以上。虽然它们不像 JavaScript 那样关键(例如,它们不会阻止渲染),但它们仍然会消耗大部分带宽。使用 url-loadersvg-url-loaderimage-webpack-loader 在 webpack 中优化它们。

url-loader 将小型静态文件内联到应用程序中。在没有配置的情况下,它会获取传递的文件,将其放在编译后的捆绑包旁边,并返回该文件的 url。但是,如果我们指定 limit 选项,它会将小于此限制的文件编码为 Base64 数据 url 并返回此 url。这会将图像内联到 JavaScript 代码中,并节省 HTTP 请求

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/,
        loader: 'url-loader',
        options: {
          // Inline files smaller than 10 kB (10240 bytes)
          limit: 10 * 1024,
        },
      },
    ],
  }
};
// index.js
import imageUrl from './image.png';
// → If image.png is smaller than 10 kB, `imageUrl` will include
// the encoded image: 'data:image/png;base64,iVBORw0KGg…'
// → If image.png is larger than 10 kB, the loader will create a new file,
// and `imageUrl` will include its url: `/2fcd56a1920be.png`

svg-url-loader 的工作方式与 url-loader 类似 – 只是它使用 URL 编码而不是 Base64 编码对文件进行编码。这对于 SVG 图像很有用 – 因为 SVG 文件只是纯文本,所以这种编码更具尺寸效率。

module.exports = {
  module: {
    rules: [
      {
        test: /\.svg$/,
        loader: "svg-url-loader",
        options: {
          limit: 10 * 1024,
          noquotes: true
        }
      }
    ]
  }
};

image-webpack-loader 压缩通过它的图像。它支持 JPG、PNG、GIF 和 SVG 图像,因此我们将将其用于所有这些类型。

此加载器不会将图像嵌入到应用程序中,因此它必须与 url-loadersvg-url-loader 配对使用。为了避免将其复制粘贴到两个规则中(一个用于 JPG/PNG/GIF 图像,另一个用于 SVG 图像),我们将使用 enforce: 'pre' 将此加载器包含为单独的规则

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        loader: 'image-webpack-loader',
        // This will apply the loader before the other ones
        enforce: 'pre'
      }
    ]
  }
};

加载器的默认设置已经很好 – 但如果您想进一步配置它,请参阅 插件选项。要选择要指定的选项,请查看 Addy Osmani 关于图像优化的优秀指南

延伸阅读

优化依赖项

平均 JavaScript 大小的一半以上来自依赖项,其中一部分大小可能只是不必要的。

例如,Lodash(截至 v4.17.4)向捆绑包添加了 72 KB 的缩小代码。但是,如果您只使用其 20 种方法,那么大约 65 KB 的缩小代码就没有任何作用。

另一个例子是 Moment.js。它的 2.19.1 版本占用 223 KB 的缩小代码,这非常大 – 2017 年 10 月页面上 JavaScript 的平均大小为 452 KB。但是,其中 170 KB 的大小是本地化文件。如果您不使用多种语言的 Moment.js,这些文件将毫无目的地膨胀捆绑包。

所有这些依赖项都可以轻松优化。我们已在 GitHub 存储库中收集了优化方法 – 快去看看吧

为 ES 模块启用模块串联(又名作用域提升)

当您构建捆绑包时,webpack 会将每个模块包装到一个函数中

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// bundle.js (part  of)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
  var __WEBPACK_IMPORTED_MODULE_0__comments_js__ = __webpack_require__(1);
  Object(__WEBPACK_IMPORTED_MODULE_0__comments_js__["a" /* render */])();
}),
/* 1 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  __webpack_exports__["a"] = render;
  function render(data, target) {
    console.log('Rendered!');
  }
})

过去,这是隔离 CommonJS/AMD 模块所必需的。但是,这为每个模块增加了大小和性能开销。

Webpack 2 引入了对 ES 模块的支持,与 CommonJS 和 AMD 模块不同,ES 模块可以在不将每个模块包装在函数中的情况下进行捆绑。Webpack 3 通过模块串联使这种捆绑成为可能。以下是模块串联的作用

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// Unlike the previous snippet, this bundle has only one module
// which includes the code from both files

// bundle.js (part of; compiled with ModuleConcatenationPlugin)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });

  // CONCATENATED MODULE: ./comments.js
    function render(data, target) {
    console.log('Rendered!');
  }

  // CONCATENATED MODULE: ./index.js
  render();
})

看到区别了吗?在普通捆绑包中,模块 0 需要模块 1 中的 render。通过模块串联,require 只是被替换为所需的函数,模块 1 被删除。捆绑包的模块更少 – 模块开销也更少!

要启用此行为,在 webpack 4 中,启用 optimization.concatenateModules 选项

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    concatenateModules: true
  }
};

在 webpack 3 中,使用 ModuleConcatenationPlugin

// webpack.config.js (for webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.ModuleConcatenationPlugin()
  ]
};

延伸阅读

如果您同时拥有 webpack 代码和非 webpack 代码,请使用 externals

您可能有一个大型项目,其中某些代码是使用 webpack 编译的,而某些代码则不是。例如视频托管站点,其中播放器小部件可能使用 webpack 构建,而周围的页面可能不是

A screenshot of a video hosting site
(一个完全随机的视频托管站点)

如果这两段代码具有共同的依赖项,您可以共享它们以避免多次下载其代码。这是通过 webpack 的 externals 选项完成的 – 它将模块替换为变量或其他外部导入。

如果依赖项在 window 中可用

如果您的非 webpack 代码依赖于在 window 中作为变量可用的依赖项,请将依赖项名称别名为变量名称

// webpack.config.js
module.exports = {
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM'
  }
};

使用此配置,webpack 不会捆绑 reactreact-dom 包。相反,它们将被替换为如下内容

// bundle.js (part of)
(function(module, exports) {
  // A module that exports `window.React`. Without `externals`,
  // this module would include the whole React bundle
  module.exports = React;
}),
(function(module, exports) {
  // A module that exports `window.ReactDOM`. Without `externals`,
  // this module would include the whole ReactDOM bundle
  module.exports = ReactDOM;
})

如果依赖项作为 AMD 包加载

如果您的非 webpack 代码没有将依赖项公开到 window 中,事情会变得更加复杂。但是,如果非 webpack 代码以 AMD 包的形式使用这些依赖项,您仍然可以避免两次加载相同的代码。

为此,请将 webpack 代码编译为 AMD 捆绑包,并将模块别名为库 URL

// webpack.config.js
module.exports = {
  output: {
    libraryTarget: 'amd'
  },
  externals: {
    'react': {
      amd: '/libraries/react.min.js'
    },
    'react-dom': {
      amd: '/libraries/react-dom.min.js'
    }
  }
};

Webpack 会将捆绑包包装到 define() 中,并使其依赖于这些 URL

// bundle.js (beginning)
define(["/libraries/react.min.js", "/libraries/react-dom.min.js"], function () {  });

如果非 webpack 代码使用相同的 URL 加载其依赖项,则这些文件将仅加载一次 – 其他请求将使用加载器缓存。

延伸阅读

  • 关于 externals 的 Webpack 文档

总结

  • 如果您使用 webpack 4,请启用生产模式
  • 使用捆绑包级别的缩小器和加载器选项来缩小您的代码
  • 通过将 NODE_ENV 替换为 production 来删除仅用于开发的代码
  • 使用 ES 模块来启用 tree-shaking
  • 压缩图像
  • 应用特定于依赖项的优化
  • 启用模块串联
  • 如果这对您有意义,请使用 externals