减小前端大小

如何使用 webpack 尽可能缩减应用大小

优化应用时,首先要做的一件事就是尽可能缩减应用大小。下面展示了如何使用 webpack 执行此操作。

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

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

在构建要发布的应用时,请务必启用 production 模式。这会使 webpack 应用优化,例如缩减大小、移除库中的仅限开发代码等

深入阅读

启用 Minification

缩减是指通过移除多余的空格、缩短变量名称等方式压缩代码。示例如下:

// 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 的运作方式也类似,它会加载包含警告的开发 build:

// 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 便可执行树摇动。树摇动是指捆绑器遍历整个依赖项树,检查使用了哪些依赖项,并移除未使用的依赖项。因此,如果您使用 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 中生成单独的导出点:

    // 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 会将小型静态文件内嵌到应用中。如果未进行配置,它会获取传递的文件,将其放置在已编译的软件包旁边,并返回该文件的网址。不过,如果我们指定 limit 选项,它会将小于此限制的文件编码为 Base64 数据网址,并返回此网址。这会将图片内嵌到 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 完全相同,只不过它使用网址编码(而非 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 开始)会向 Bundle 添加 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
();
})

您能看出区别吗?在普通 bundle 中,模块 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 编译的,有些代码则不是。例如,视频托管网站,其中播放器 widget 可能使用 webpack 构建,而周围的网页可能未使用 webpack:

视频托管网站的屏幕截图
(完全随机的视频托管网站)

如果这两段代码具有共同的依赖项,您可以共享这些依赖项,以免多次下载它们的代码。这可以通过 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 软件包,并将模块别名为库网址:

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

Webpack 会将 bundle 封装到 define() 中,并使其依赖于以下网址:

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

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

深入阅读

总结

  • 如果您使用 webpack 4,请启用生产模式
  • 使用 bundle 级缩减器和加载器选项缩减代码
  • NODE_ENV 替换为 production,以移除仅限开发的代码
  • 使用 ES 模块启用树摇动
  • 压缩图片
  • 应用特定于依赖项的优化
  • 启用模块串联
  • 如果适用,请使用 externals