作者:小boy (沪江前端开发工程师)
本文原创翻译,转载请注明作者及出处。
原文地址:https://www.smashingmagazine.com/2017/02/a-detailed-introduction-to-webpack

JavaSript 模块化打包已混迹江湖许久。2009年,RequireJS 就提交了它的第一个版本,Browserify 接踵而至,随后其他打包工具也开始大行其道。最终,Webpack 从其中脱颖而出。如果你对它不甚了解,希望我的文章能让你上手这件强力打包工具。

什么是模块化打包工具?

在大多数语言(JS 的最新版本 ECMAScript 2015+ 也支持,但并非支持所有浏览器)中,你可以将代码拆分至多个文件,并且通过在业务代码中引用这些文件来使用它们包含的方法。可惜的是浏览器并不拥有这个能力。因此,模块化打包工具应运而生,它以两种形式为浏览器提供这个能力:1.异步加载模块,并且在加载结束后运行它们。2.将需要用到的文件拼凑成单一 JS 文件,最终在 HTML 中使用 <script> 标签加载该 JS 文件。

如果没有模块化加载及打包工具的话,你就得手动拼凑文件或者在 HTML 中加载无数的 <script> 标签了,而且这样干有一些不好的地方:

  • 你需要关心文件的加载顺序,包括哪些文件依赖于其他文件;还需要确认是否引入了不需要的文件。
  • 多个 <script> 标签意味着用多个网络请求来加载代码,同时也意味着更差的性能。
  • 显然,其中有很多本可以交给计算机完成的手工活。

大多数模块化打包工具直接跟 npm 或者 Bower(译者注:两者都是包管理工具)整合,这样可以让你更容易地在业务代码中添加第三方依赖包(dependencies)。你仅需要安装一下,然后写一行代码引入它们,接着运行模块化打包工具,这样就已经将第三方代码整合进自己的业务代码了。或者,如若配置正确,你可以将所有要用的三方代码整合进一个分开的文件,这样一来,当你更新业务代码,用户需要更新缓存的时候,他们就无需重新下载公共库代码(vendor code)了。

为什么选择 Webpack ?

至此,你已对 Webpack 的愿景有了基础的认知,然而为什么在各路豪杰中选择 Webpack 呢?在我看来有这样一些理由:

  • 小鲜肉的特性助了它一臂之力,借此特点它可以绕开或者避免前辈们遇到的问题。
  • 上手简单。如果只是想打包一些JS文件,而没有其他需求的话,你甚至都不需要一份配置文件。
  • 它的插件体系使其能做更多事情,从而十分强大,所以它可能是你需要的唯一构建工具。

据我所知,少有其他的模块打包和构建工具也能做到这些。但 Webpack 仍胜一筹:当你踩坑的时候有庞大的社区支持。 Browserify 的社区可能只是大,如果它不大的话,就会缺少一些 Webpack 的潜在必要特性。说了这么多 Webpack 的优点,估计你就等上代码了吧?那么我们开始。

安装 Webpack

在使用 Webpack 前,我们首先要先把它安装好。为此我们需要 Node.js 和 npm ,我就假设你已经安装过它们了,实在没有的话,请从Node.js 官网开始吧。

有两种方式安装 webpack (或着是其他 CLI 包):全局安装(globally)或者本地安装(locally)。对于全局安装,虽然你可以在任意目录下使用它,但是它不会包括在项目的依赖模块列表(dependencies)中。此外,你也不能在两个不同的项目(有些项目可能需要投入更多工作量才能更新到最新版本,所以这些项目还需要维持老版本)中切换不同版本的 Webpack 。所以我更愿意本地安装 CLI 包,并且用相对路径抑或是 npm 脚本来运行它。如果你不习惯本地安装 CLI 包,可以看一下我之前写的关于摆脱全局安装 npm 包的博文。

不管怎样,在示例项目中,我们就使用 npm 脚本。接下来,先本地安装示例项目。首先:创建一个用来实验和学习 Webpack 的目录。 我在 GitHub 上有一个仓库,你可以将它 clone 到本地,然后在分支间切换来进行下面的学习,或者从零开始创建一个新项目,此后可以与我的仓库代码进行对照。

经过命令行选择,一进到项目目录,你将用 npm init 命令来初始化项目。接下来要填的信息一点都不重要(译者注:一路回车即可),除非你想把项目发布到 npm 上。

至此 package.json 文件准备就绪(它是通过 npm init 命令创建的),在此文件中,你可以保存依赖包信息。我们通过 npm install webpack -D-D--save-dev 命令的简写,它的作用是将 npm 包作为开发环境的依赖包安装,并将依赖信息保存到 package.json 文件中)命令将 Webpack 作为依赖包安装。

我们需要一个简单的应用来开启运用 Webpack 之旅。所谓的简单就是:首先执行 npm install lodash -S-S == --save) 安装 Lodash,如此一来我们的简单应用就有一个依赖包可以用来加载了。接着我们创建一个 src 目录,再于该目录中创建名为 main.js 的文件,其内容如下:

var map = require('lodash/map');

function square(n) {
    return n*n;
}

console.log(map([1,2,3,4,5,6], square));

很简单对吧?我们仅仅创建了一个包含整数1至6的小数组,然后用 Loadash 库中的 map 函数创建了一个新数组,这个新数组中的数字是原数组中数字的平方。最后,我们在控制台中打印这个新数组。运行命令 node src/main.js 就能看到结果:[1, 4, 9, 16, 25, 36]。你瞧,其实 Node.js 都能运行这个文件。

但如果我们想打包这个小脚本,其中还包括我们能跑在浏览器的 Lodash 代码,使用 Webpack 应该从哪入手?如何做到?

Webpack 命令行

若不想在配置文件上浪费时间,使用 Webpack 命令行是最容易的上手方式。如果不启用配置文件的话,最简洁的命令需要包含输入文件(input file)路径和输出文件(output file)路径。Webpack 会读取输入文件,追踪它的依赖关系树,并将所有依赖文件打包进一个文件,最终在你指定的输出路径下输出该文件。在本例中,输入路径是 src/main.js ,我们要将打包后的文件输出到 dist/bundle.js 下。为此,我们先添加 npm 脚本(我们并没有全局安装 Webpack ,所以不能直接在命令行中运行)。编辑 package.json 文件的 "scripts" 部分如下:

"scripts": {
    "build": "webpack src/main.js dist/bundle.js",
}

现在,执行 npm run build 命令,Webpack 就会运行了。很快,运行完毕的时候会生成 dist/bundle.js 文件。然后你便可以用 Node.js (通过 node dist/bundle.js 命令)运行该文件了。也可以借助简单的 HTML 将其跑在浏览器上,之后可在控制台中看到同样的运行结果。

在继续探索 Webpack 前,我们先把构建脚本调整得更专业一点:在重新构建(rebuilding)前删除 dist 目录及其内容,此外,我们再添加一些用于直接执行 bundle 文件的脚本。首先,安装 del-cli 工具,这样就不用在删除目录的时候顾虑操作系统的区别了(见谅,因为我用的是 Windows)。运行 npm install del-cli -D 命令即可。接着更新 npm 脚本如下:

"scripts": {
    "prebuild": "del-cli dist -f",
    "build": "webpack src/main.js dist/bundle.js",
    "execute": "node dist/bundle.js",
    "start": "npm run build -s && npm run execute -s"
  }

我们保持 "build" 配置同之前一样,但增加了 "prebuild" 配置用以清除目录,这条配置所执行的命令会在每次 "build" 命令执行之前运行。同时增加的还有 "execute" 配置:使用 Node.js 执行已经打包好的脚本。此外,使用 "start" 配置可以通过一条命令执行以上所有命令(-s 的作用仅仅是不让 npm 脚本在控制台打印一些没用的东西)。执行 npm start 命令,就可以在控制台里看到 Webpack 的输出信息,紧接着打印的是平方后的数组。

恭喜!你刚刚完成了 example1 分支里所有的事情。这个分支就在我之前提到的仓库中。

Webpack 配置文件

跟使用 Webpack 命令行上手一样有趣的是,一旦开始使用更多 Webpack 的功能, 你就会想要放弃通过命令行传递 Webpack 配置参数,转而投入配置文件的怀抱。使用配置文件虽然会更占位置,但与此同时增加了可读性,因为它是由 JS 写成的。

那我们就来创建配置文件吧。在根目录下创建一个新文件 webpack.config.js。Webpack 默认寻找该文件,但如果想给配置文件取别的名字或者将配置文件放在其他目录,你可以通过传递 --config [filename] 参数来做到。

在本教程中,我们使用默认文件名。现在,我们试着让配置文件起作用,达到与仅使用命令行同样的效果。为此,我们需要在配置文件中添置如下代码:

module.exports = {
    entry: './src/main.js',
    output: {
        path: './dist',
        filename: 'bundle.js'
    }
};

如此前一样,我们规定输入和输出文件。因为这不是 JSON 文件而是 JS 文件,所以我们需要把配置对象(configuration object )导出,故使用 module.exports。虽然现在还看不出写这些配置会比用命令好多少,但文章结尾你肯定会爱上这里的一切。

接下来,移除 package.json 文件中给 Webpack 传的配置,像这样:

"scripts": {
    "prebuild": "del-cli dist -f",
    "build": "webpack",
    "execute": "node dist/bundle.js",
    "start": "npm run build -s && npm run execute -s"
}

像之前一样执行 npm start 命令,运行结果是不是似曾相识呢?以上就是分支 example2 中需要做的事情。

Webpack 加载器(Loaders)

我们主要通过两种方式增强 Webpack: 加载器(loaders)和插件(plugins)。我们先讲加载器,插件稍后再议。加载器用以转换或操作特定类型的文件,你可以将多个加载器串联在一起来处理一种类型的文件。例如,规定 .js 后缀的文件要先通过 ESLint 检查,再通过 Babel 把 ES2015 语法转换为 ES5 语法。ESLint 发出的警报将会在控制台打印出来,而遇到语法错误的时候则会阻止 Webpack 继续打包。

我们这里就不设置语法检查了,但要通过设置 Babel 来把代码转化成 ES5。当然我们得先有些 ES2015 代码吧?把 main.js 文件的代码改成下面的样子:

import { map } from 'lodash';

console.log(map([1,2,3,4,5,6], n => n*n));

实际上这段代码和之前做的事情一样,但有两点:其一,使用箭头函数替代了之前定义的 square 函数。其二,使用了 ES2015 中的 import 语法加载 lodash 库中的 map 函数,但这将会把整个 Lodash 库的代码打包到我们的输出文件中,而不是引入仅仅包含 map 函数相关代码的 'lodash/map' 库。如果乐意的话,你也可以把第一行改成 import map from 'lodash/map'; 但我写成这样有我的理由:

  • 在更具规模的应用里,你可能要用到 Lodash 库的很多部分,所以你最好全加载进来。
  • 如果你正在用 Backbone.js 框架,会发现仅打包你需要的函数是很困难的,因为根本就没有文档告诉你函数依赖哪些函数。
  • 在 Webpack 的下一个大版本中,开发者打算加入一个叫 tree-shaking 的东西,tree-shaking 会排除掉引入模块中没有用到的部分。所以那也是一种办法。
  • 这样写是为了举一个例子,好让你理解我之前提到的要点。

(注:Lodash 这两种加载方式都可以用,因为它的开发者明确规定可以这么做,而不是所有的库都可以通过这种加载方式工作。)

无论如何,ES2015 代码现已在手,我们要把它转化成 ES5 代码,这样它们就能在老式浏览器(事实上,在新版浏览器里 ES2015 的支持度还不错)里跑起来了。因此,我们需要 Babel 及在 Webpack 中运行 Babel 的配套设施。至少要有 babel-core(Babel 的核心功能库),babel-loader(babel-core 的 Webpack 加载器接口),babel-preset-es2015(里面有 ES2015 到 ES5 的转化规则,这是 Babel 需要得知的)。同时我们引进 babel-plugin-transform-runtimebabel-polyfill ,尽管它们实现方式有点不同,但都用于改变 Babel 添加语法填充(polyfills)和辅助函数(helper functions)的方式。正是因此,它们适应于不同种类的项目。你可能不想把它们俩都引入,二者择一即可,但我在这把它俩都引入,这样无论你选择哪个,都能知道引入的方式。想知道更多的话,请访问 polyfill 和 runtime transform 的官方文档吧。

不管怎样,先安装它们:npm i -D babel-core babel-loader babel-preset-es2015 babel-plugin-transform-runtime babel-polyfill。再为它们配置 Webpack。首先,添加一个部分用于增添加载器。更新 webpack.config.js 如下:

module.exports = {
    entry: './src/main.js',
    output: {
        path: './dist',
        filename: 'bundle.js'
    },
    module: {
        rules: [
            
        ]
    }
};

我们增加了一个 module 属性,其中包含了 rules 属性。rules 是一个数组,这个数组囊括每个加载器的配置。我们将把 babel-loader 相关配置加到这里。对于每一个加载器,我们都要配置至少两个参数:testloadertest 通常是一个正则表达式,它用以验证(test)每个文件的绝对路径。我们一般只验证文件后缀,例如:/\.js$/验证所有以 .js 结尾的文件。在这里,我们把这个参数设为 /\.jsx?$/ 这样可以匹配到 .js 文件和 .jsx 文件,以便使用 React。接下来配置 loader 参数,它描述了在相应的 test 参数下,应该使用哪一个加载器处理文件。

将加载器的名字所拼成的字符串传入该参数即可奏效,其中,名字用感叹号隔开,例如 'babel-loader!eslint-loader'eslint-loader 会比 babel-loader 先运行,因为 Webpack 的读取顺序是从右到左。如果某个加载器有特殊参数配置,你可以使用 query string 语法。比如,要给 Babel 配置一个 fakeoption 参数为 true,我们得把前面的例子改为 'babel-loader?fakeoption=true!eslint-loader'。如果你觉得更易阅读和维护的话,也可以使用 use 替代 loader 配置,这样可以传入一个数组替代此前的字符串。把之前的例子改为:use: ['babel-loader?fakeoption=true', 'eslint-loader'],更有甚者,你可以把它们写成多行以提高可读性。

目前我们只用 Babel loader ,所以我们的配置文件看起来像下面这个样子:


rules: [
    { test: /\.jsx?$/, loader: 'babel-loader' }
]

如果只用一个加载器,我们还可以这样配置来替代 query string 的写法:使用 options 配置对象,它就是一个键值对 map。因此,对于 fakeoption 的例子,我们的配置文件可以写成这样:


rules: [
    {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        options: {
            fakeoption: true
        }
    }
]

用上面这种方式来配置我们的 Babel 加载器:

…
rules: [
    {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        options: {
            plugins: ['transform-runtime'],
            presets: ['es2015']
        }
    }
]
…

预设(presets)用于把 ES2015 特性转成 ES5,我们也给 Babel 设置了已经安装的 transform-runtime 插件。如此前所言,该插件并非必要,这里是为了演示。我们也可以另建 .babelrc 文件独立配置这些参数,但那样不利于演示 Webpack。一般我推荐使用 .babelrc 文件,但在这里我们还是保持不变。

万事俱备,只欠东风。我们需要告知 Babel 跳过处理 node_modules 中的文件,这样可以提高我们的构建速度。添置 exclude 属性以告知加载器忽略目标目录下的文件,它的值是一个正则表达式,因此我们这样写:/node_modules/

…
rules: [
    {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
            plugins: ['transform-runtime'],
            presets: ['es2015']
        }
    }
]
…

此外,我们本应使用 include 属性来描述我们仅读 src 目录,但我觉得应该保持原样。于是,你应该可以再次执行 npm start 命令,然后获取为浏览器准备的 ES5 代码了。若想使用 polyfill 替代 transform-runtime 插件,你需要做一两处改动。首先删除 plugins: ['transform-runtime], 这行(如果不打算再用了,你也可以直接用 npm 卸载该插件)。接下来,编辑 Webpack 配置文件的 entry 部分如下:

entry: [
    'babel-polyfill',
    './src/main.js'
],

我们把描述单一入口的字符串替换成了描述多入口的数组,新添的入口乃 语法填充(polyfill)。我们将其置于首位,这样语法填充将会率先出现在打包后的文件里,因为我们在代码里使用语法填充前,要确保它们已经存在。

除了借助 Webpack 配置文件,我们本可以通过在 src/main.js 的首行加上 import 'babel-polyfill; 来达到相同的目的。而我们却使用了配置文件,除了用于服务本例,更是为了用作一个演示多入口打包至单一文件的范例。好吧,那便是仓库里的 example3 分支。容我再说一遍,你可以运行 npm start 命令来确认项目正常运行。

另一个例子:Handlebars 加载器

我们再为项目添置一个加载器:Handlebars。Handlebars 加载器用以将 Handlebars 模版编译成函数,当你在 JS 中引入(import)一个 Handlebars 文件时,该文件编译成的函数就会被引入 JS 文件。这便是我喜欢 Webpack 加载器的地方:即便引入非 JS 文件,该文件也会在打包时被转化为 JS 里可用的东西。接下来的例子将会使用另一个加载器:允许引入图片文件并将图片文件转化成 base64 编码的 URL 字符串,该字符串可被用于在 JS 中为页面添加內联图片。这也意味着,如果你串联多个加载器,其中一个甚至能优化把图片的文件大小。

同样,我们首先安装这个加载器:执行 npm install -D handlebars-loader 命令。当你用的时候会发现 Handlebars 本身也是不可或缺的:执行 npm install -D handlebars 命令。这样你就可以在不更新加载器版本的情况下控制 Handlebars 的版本,它们可以分别独立迭代。

二者现已安装完毕,我们弄一个 Handlebars 模板来用。在 src 目录下创建一个 numberlist.hbs 文件,其内容如下:

<ul>
  
    <li></li>
  
</ul>

该模板描绘了一个数组(变量名为 numbers ,也可以是别的变量名),创建了一个无序列表。

接下来,我们调整此前的 JS 文件来使用模板输出一个列表,不再止步于打印数组本身。main.js 看起来会像下面一样:

import { map } from 'lodash';
import template from './numberlist.hbs';

let numbers = map([1,2,3,4,5,6], n => n*n);

console.log(template({numbers}));

可惜目前为止 Webpack 并不知道如何引入 numberlist.hbs ,因为它并非 JS 文件。我们可以在 import 的路径前加点东西通知 Webpack 要使用 Handlebars 加载器:

import { map } from 'lodash';
import template from 'handlebars-loader!./numberlist.hbs';

let numbers = map([1,2,3,4,5,6], n => n*n);

console.log(template({numbers}));

通过给路径增添加载器名字,并将名字和路径以感叹号隔开的前缀,我们告知 Webpack 那个文件应该使用那个加载器。这样,我们不必在配置文件里添置任何东西。然而,在颇有规模的项目里,你极有可能加载不止一个模板,所以,在配置文件里告知 Webpack 我们使用 Handlebars ,以免去引入模板时在路径前添加前缀,这样做会更有意义。那我们就更新一下配置文件:


rules: [
    {/* babel loader config… */},
    { test: /\.hbs$/, loader: 'handlebars-loader' }
]

这部分相当简单。我们所需要做的就是指定用 handlebars-loader 去处理以 .hbs 结尾的文件,仅此而已。我们搞定了 Handlebars 同时也搞定了 example4 分支。现在,一旦运行 npm start ,你会看到 Webpack 打包输出如下内容:

<ul>
<li>1</li>
<li>4</li>
<li>9</li>
<li>16</li>
<li>25</li>
<li>36</li>
</ul>

Webpack 插件

插件是另一种用来自定义 Webpack 功能的方式。你可以更自由地把它们添加到 Webpack 工作流(workflow)中,因为,除加载特殊文件类型之外,它们几乎不受限制。它们可被植入到任何地方,正因如此,他们更加强劲。我很难定义 Webpack 插件到底能做多少事情,因此我仅给出一个 npm 上的搜索结果列表 npm packages that have “webpack-plugin”,那应该不失为一个好的答案。

本教程中我们只接触两个插件(其中一个马上揭晓)。行文已至此你也知道我的风格,过多的例子我们就不需要了。我们首先上 HTML Webpack Plugin ,它的作用很纯粹:生成 HTML 文件 —— 终于可以开始进军浏览器了!

在使用该插件之前,我们首先更新 npm 脚本来运行一个能够测试示例应用的简单服务器。先安装一个服务器:运行 npm i -D http-server 命令。接着,仿照下面的代码将此前的 execute 脚本改成 server 脚本。


"scripts": {
  "prebuild": "del-cli dist -f",
  "build": "webpack",
  "server": "http-server ./dist",
  "start": "npm run build -s && npm run server -s"
},

Webpack 完成构建后,npm start 会同时启动一个 web 服务器,将浏览器跳转到 localhost:8080 可以访问到你的页面。自然,我们仍然需要靠插件来创建该页面,所以接下来,我们需要安装插件:npm i -D html-webpack-plugin

安装完毕以后,我们移步 webpack.config.js 并作如下修改:

var HtmlwebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: [
        'babel-polyfill',
        './src/main.js'
    ],
    output: {
        path: './dist',
        filename: 'bundle.js'
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/, loader: 'babel-loader', exclude: /node_modules/,
                options: { plugins: ['transform-runtime'], presets: ['es2015'] }
            },
            { test: /\.hbs$/, loader: 'handlebars-loader' }
        ]
    },
    plugins: [
        new HtmlwebpackPlugin()
    ]
};

我们有作两处改动:其一在文件顶部引入新安装的插件,其二在配置对象尾部添置了一个 plugins 部分,并在此处传入了插件的实例对象。

目前我们并没有为该插件实例传入配置对象,默认使用它的基础模板,除了我们打包好的脚本文件以外,该基础模版并没有包含很多东西。在运行 npm start 后在浏览器访问相应 URL ,你会看到一空白页,但若在开发者工具中打开控制台,应该会看到里面打印出了 HTML。

我们可能要获得模板并将 HTML 吐(spit out)到页面上而不是控制台里,这样一个“正常人”就能真正从页面上得到信息了。我们先在 src 目录下创建 index.html 文件,这样就能定义自己的模板了。默认情况下,该插件用的是 EJS 模板语法,不过,你也可以配置该插件使其使用其它受到支持的模板语言。在这里我们就用 EJS 因为用什么语法都没有实质区别,index.html 的内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
    <h2>This is my Index.html Template</h2>
    <div id="app-container"></div>
</body>
</html>

请注意几点:

  • 我们将为插件传入一个配置对象来定义标题(仅仅因为我们能做到)。
  • 没有具体指定该在哪里插入我们的脚本文件,因为该插件默认会在 body 元素结尾前添加脚本。
  • 这里 div 的 id 并非特定,我们在这里随便取了一个。

现在我们得到了想要的模板,最终不会只是一个空白页了。接下来更新 main.js ,把 HTML 结构加入那个 div 里以替代此前打印在控制台里。为此,我们仅需更新 main.js 的最后一行:document.getElementById("app-container").innerHTML = template({numbers});

同时,我们也需要更新 Webpack 配置文件,为插件传入两个参数。配置文件现在应改成这样:

var HtmlwebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: [
        'babel-polyfill',
        './src/main.js'
    ],
    output: {
        path: './dist',
        filename: 'bundle.js'
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/, loader: 'babel-loader', exclude: /node_modules/,
                options: { plugins: ['transform-runtime'], presets: ['es2015'] }
            },
            { test: /\.hbs$/, loader: 'handlebars-loader' }
        ]
    },
    plugins: [
        new HtmlwebpackPlugin({
            title: 'Intro to webpack',
            template: 'src/index.html'
        })
    ]
};

template 配置指定了模板文件的位置,title 配置被传入了模板。现在,运行 npm start,你将会在浏览器里看到下面的内容:

template

假如你一直跟着做的话,example5 分支便在此结束。不同插件传入的参数或者配置项也大异其趣,其原因在于插件种类繁多且涵盖范围广阔,但殊途同归的是,他们最终都会被添加到 webpack.config.jsplugins 数组中。同样,也有其他方式可以处理 HTML 页面的生成和文件名填充,一旦你开始为打包后的文件添加清缓存哈希值(cache-busting hashes)后缀,这些事情就会变得非常简单。

观察示例仓库,你会发现有一个 example6 分支,在该分支里我通过添加插件实现了 JS 代码压缩,但这不是必须的,除非你想改动 UglifyJS 配置。如果你不爽 UglifyJS 的默认配置,可将仓库切换 (check out)至该分支下(只需要查看 webpack.config.js )去找到如何使用该插件并加以配置。但如果默认配置正合你意,你只需要在命令行运行 webpack 时传入 -p 参数。该参数是 production 的简写,与使用 --optimize-minimize--optimize-occurence-order 参数的效果一样,前者用以压缩 JS 代码,后者用以优化已引入模块的顺序,着眼于稍小的文件尺寸和稍快的执行速度。在示例仓库完成一段时间后我才知道 -p 这个参数,所以我决定保存该插件示例,可以用来提醒你还有更简单的方法(除了添加插件之外)。另一可供使用的快捷命令参数是 -d-d 会展示更多 Webpack 打印出的信息,并且可不借助其他参数生成资料图(source map)。还有很多其他命令行快捷参数可供使用。

懒加载数据块

懒加载(lazy-loading)模块是我在 RequireJS 中用得舒适但在 Browserify 中难以工作的模块。一个颇具规模的 JS 文件固然可以从减少网络请求中受益,但也几乎坐实了在一次会话中,某些用户不必用到的代码会被下载下来。

Webpack 可以将打包文件拆分成可被懒加载的若干块(chunks),而且还不需要任何配置。你仅需要从两种书写方式中挑一种来书写代码,剩下的则交给 Webpack。这两种方式其一基于 CommonJS ,其二则基于 AMD。如果使用前者懒加载,需要这样写:

require.ensure(["module-a", "module-b"], function(require) {
    var a = require("module-a");
    var b = require("module-b");
    // …
});

require.ensure 需要确保模块是可用的(但并非运行模块),然后传入一个由模块名构成的数组,接着传入一个回调函数(callback)。真正想要在回调函数里使用模块,你需要显式 require 数组里传入的相应模块。

私以为这种方式相麻烦,所以,我们来看 AMD 的写法。

require(["module-a", "module-b"], function(a, b) {
    // …
});

AMD 模式下,使用 require 函数,传入包含依赖模块名的数组,接着再传入回调函数。该回调函数的参数就是依赖模块的引用,它们的排列顺序与依赖模块在数组中的排列顺序相同。

Webpack 2 同时也支持 System.import,其借助于 promises 而非回调函数。尽管将回调内容包裹在 promise 下并非难事,但我仍以为该提升非常有用。不过需要注意的是, System.import 现已过时,较新的规范推荐使用 import()。不过,这里告诫一下, Babel (以及 TypeScript)会在你使用System.import的时候抛出语法异常。你可以借助于 babel-plugin-dynamic-import-webpack 插件,但该插件将会将其转化为 require.ensure,而不是让 Babel 合法处理新 import 或者任之由 Webpack 处置。我认为 AMD 或 require.ensure 在很久之后才会被弃置,且 Webpack 直到第三个版本才会支持 System.import ,那还远着呢,所以用你顺眼的那个就好了。

扩充我们的代码,令其停滞两秒,然后再将 Handlebars 模板懒加载进来并输出到屏幕上。为此,我们移除顶部 import 模板的语句,然后将最后一行包裹到 setTimeout 和 AMD 模式的 require 中引入模板。

运行 npm start ,你会发现生成了另外一个名为 1.bundle.js 的资源文件(asset)。在浏览器打开该页面,然后在开发者工具中监听网络流量,2秒之后你会发现新的资源文件最终被加载并且运行了。以上这些实现起来并不困难,但提升用户体验可不止一点。

注意,这些二级打包文件(sub-bundles)或曰数据块(chunks),内部囊括了他们的所有依赖模块(dependencies),但不包含其主数据块(parent chunks)已引入的依赖模块。(你可以有多个入口文件,每个都懒加载一个数据块,因此该数据块在其主数据块中加载的依赖模块也会不同。)

创建公共库数据块 (Vendor Chunk)

我们再说一个优化的点:公共库数据块。你可以定义一个单独用以打包的 bundle,该 bundle 中存放不常改动的 “common” 库或第三方代码。该策略可使用户独立缓存你的公共库文件,以区别于业务代码,以便在你迭代应用时让用户无需重新下载该库文件。

为此,我们使用 Webpack 官方插件:CommonsChunkPlugin。它已附带在 Webpack 中,所以我们无需安装。仅对 webpack.config.js 稍作修改即可:

var HtmlwebpackPlugin = require('html-webpack-plugin');
var UglifyJsPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
var CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');

module.exports = {
    entry: {
        vendor: ['babel-polyfill', 'lodash'],
        main: './src/main.js'
    },
    output: {
        path: './dist',
        filename: 'bundle.js'
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/, loader: 'babel-loader', exclude: /node_modules/,
                options: { plugins: ['transform-runtime'], presets: ['es2015'] }
            },
            { test: /\.hbs$/, loader: 'handlebars-loader' }
        ]
    },
    plugins: [
        new HtmlwebpackPlugin({
            title: 'Intro to webpack',
            template: 'src/index.html'
        }),
        new UglifyJsPlugin({
            beautify: false,
            mangle: { screw_ie8 : true },
            compress: { screw_ie8: true, warnings: false },
            comments: false
        }),
        new CommonsChunkPlugin({
            name: "vendor",
            filename: "vendor.bundle.js"
        })
    ]
};

我们在第三行引入该插件。此后,在 entry 部分修改配置,将其换成了一个对象字面量(literal),用以指定多入口。vendor 入口记录了会在公共库数据块中——这里包含了 polyfill 和 Lodash ——被引入的库并将我们的主要入口放置在 main 入口里。接着,我们仅需将 CommonsChunkPlugin 添加到 plugins 部分,指定 “vendor” 数据块作为该插件生成数据块的索引,同时指定 vendor.bundle.js 文件用以存放公共库代码(译者注:这里插件配置中的 name: "vendor" 对应 entry 中的 vendor 入口,入口数组中指定的依赖模块即最终存放于 vendor.bundle.js 文件中的依赖模块)。

通过指定 “vendor” 数据块,该插件将拉取此数据块所有的依赖模块,并将其存放于公共库数据块內,这些依赖模块在一个单独入口文件里被指定。如果不在入口对象字面量中指定数据块名,插件会基于多入口文件之间公用的依赖模块来生成独立文件。

运行 Webpack ,你将看到3份 JS 文件:bundle.js, 1.bundle.jsvendor.bundle.js。如果愿意的话也可以运行 npm start 命令来在浏览器中查看结果。看起来 Webpack 甚至会把自身加载不同模块的主要代码放进公共库数据块,此举极为实用。

至此我们结束了 example8 分支之旅,同时本篇教程也接近尾声。我所谈颇多,但仅让你对 Webpack 的能力浅尝辄止。Webpack 实现了更简便的 CSS module、清缓存、图片优化等等很多事情——多到即便书巨著一本,我也无法说穷道尽,且在我成书之前,大多数已写的内容也将被更新替代。So,尝试一下 Webpack 吧,且告诉我它有没有提升工作流。祝吾主保佑,编程愉快!