陈三

webpack 教程

Published at , by 陈三

在文章正式开始之前,我想播放一个小视频,据说是人们调试 webpack 配置的遭遇:

文中涉及的软件版本号:

  1. node - 8.9.3
  2. npm - 5.6.0
  3. webpack - 3.10.0

为什么选择 webpack

我还清楚记得,自己刚接触 webpack 时的感受:千头万绪,不知从何下手。

我是说,我不想关心那些繁杂的配置,作为工具,我希望它能够开箱即用。总之,几经折腾,我迅速放弃 webpack。

但最后我还是选择 webpack,为什么?

我们先来看 webpack 官网对它的定义:

webpack is a module bundler

什么是 module?我们首先会想到 ES2015/ES6 模块,又或 Node.js 里的 CommonJS 模块。

但在 webpack 领域,所有类型的文件都可以是 module,包括 js、CSS、图片、JSON,等等。

我们甚至都不需要尝试就能知道,我们在 JavaScript 代码里 importrequire 一张图片会发生什么。但在 webpack 下,这一切成为可能,这一切,归功于加载器(loader),通过加载器,我们可以 import/require 各类文件,把它们当成真正的模块来处理 - 这正是 webpack 跨出的与众不同的一步,也是我选择 webpack 的最终原因。

安装 webpack

你可以在全局环境安装 webpack,也可以在项目根目录安装 webpack。在模块化盛行的当下,我推荐后者。因为 webpack 是开发环境必需的依赖,安装在全局,对它的依赖就不能写入 package.json,换个开发环境运行项目,就会出现依赖缺失的问题。

首先,我们来初始化一个 package.json 文件,用 npmyarn 都可以,本文使用 npm

$ cd ~
$ mkdir webpack-demo
$ cd webpack-demo
$ npm init -y

这时,webpack-demo 下会生成一个 package.json 文件,内容如下:

{
  "name": "webpack-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

接着,在 webpack-demo 项目中安装 webpack

$ npm install --save-dev webpack

查看 webpack 版本

安装完 webpack 后,我们可以在命令行下执行 webpack 命令查看我们新安装的 webpack 版本:

$ ./node_modules/.bin/webpack --version
3.10.0

如果你觉得这样执行 webpack 太麻烦,转身就想在全局环境安装 webpack,那么且慢,试试 npm@5.2.0 引入的 npx:

$ npx webpack --version

是不是打消了你在全局环境安装 webpack 的念头?

如果在命令行下直接执行 webpack 会怎样?

$ npx webpack
No configuration file found and no output filename configured via CLI option.
A configuration file could be named 'webpack.config.js' in the current directory.
Use --help to display the CLI options.

输出结果告诉我们,需要一个配置文件 webpack.config.js

不过,图个快捷,我们暂时先跳过配置文件,直接使用命令行。

前面我们曾初始化过一个 package.json 文件,其中有一个 main 字段,定义我们的入口文件 index.js。现在让我们新建 index.js,再执行 webpack 命令:

$ touch index.js
$ npx webpack index.js build.min.js
Hash: d854c92bbbd4401eed3c
Version: webpack 3.10.0
Time: 34ms
       Asset     Size  Chunks             Chunk Names
build.min.js  2.47 kB       0  [emitted]  main
   [0] ./index.js 0 bytes {0} [built]

我们用一张表格来说明下前面的命令:

webpack index.js build.min.js
webpack 命令 要打包的文件 打包后的文件

是的,我们给 webpack 一个 index.js 文件,它返回一个 build.min.js 文件,整个过程耗时 34ms,打包出来的 build.min.js 文件大小为 2.47kB。如果你在困惑,为什么一个空空的 index.js 文件却生成一个 2.47kB 的 build.min.js,想学习一下这炼金术,那么,打开 build.min.js 文件就能看到一切。这里就不展开说明。

我们还需要一个 html 文件,在目录下新建 index.html,添加内容如下:

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>webpack 教程</title>
  </head>
  <body>
  </body>
</html>

浏览器下打开 index.html 页面,暂时只有一个标题。但我们马上引入脚本:

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>webpack 教程</title>
  </head>
  <body>
    <script src="build.min.js"></script>
  </body>
</html>

注意,我们的 script 引用的文件是 build.min.js,而不是 index.js。这正是当下前端开发领域的一个趋势:开发的源文件(例子中的 index.js)与最终部署的文件(例子中的 build.min.js)是区分开的,之所以这样,是因为开发环境与用户的使用环境并不一致。比如我们可以在开发环境使用 ES2017 甚至 ES2018 的特性,而用户的浏览器不见得支持 - 这也是 webpack 等打包工具的一个意义,它们能够构建出在目标用户浏览器上运行的代码。

webpack 配置文件

在前面,我们通过命令行打包 js 文件,因为只是演示,所以并不会觉得麻烦,但真正项目中,因为要配置的东西很多,不可能每个都通过命令行传递,所以我们要把这些配置集中到一个文件中,即 webpack.config.js

首先,在项目下新建一个 webpack.config.js 文件:

$ touch webpack.config.js

然后通过 entry 字段配置入口文件,即 webpack 要打包的文件:

module.exports = {
  entry: './index.js',
}

然后再通过 output 配置一个输出位置:

module.exports = {
  entry: './index.js',
  output: {
    filename: 'build.min.js'
  }
}

这时再执行 npx webpack,不再提示 webpack 配置文件缺失的问题,而会直接输出打包结果:

$ npx webpack
Hash: d854c92bbbd4401eed3c
Version: webpack 3.10.0
Time: 35ms
       Asset     Size  Chunks             Chunk Names
build.min.js  2.47 kB       0  [emitted]  main
   [0] ./index.js 0 bytes {0} [built]

实时刷新

index.html 文件中引用 bundle.min.js 文件后,我们有几个问题需要解决。

  1. 入口文件 index.js 的变化,包括它所引用的其它模块的变化如何通知给 webpack,以便重新构建 bundle.min.js 文件?
  2. bundle.min.js 文件更新后,浏览器中打开的页面该如何自动刷新?

监控文件

第一个问题,webpack 有好几个解决办法,其中 watch 选项最直观,我们可以让 webpack 监控文件变化,一旦文件有变化,就重新构建。但默认情况下,watch 选项是禁用的。

我们可以在命令行下启用它:

$ npx webpack --watch
Webpack is watching the files…

Hash: d854c92bbbd4401eed3c
Version: webpack 3.6.0
Time: 37ms
       Asset     Size  Chunks             Chunk Names
build.min.js  2.47 kB       0  [emitted]  main
   [0] ./index.js 0 bytes {0} [built]

当然也可以在配置文件 webpack.config.js 文件中启用它:

module.exports = {
  watch: true,
  entry: './index.js',
  output: {
    filename: 'build.min.js'
  }
}

此时再执行 npx webpack,命令不会直接结束、退出,而是会进入一个等待状态。

我们试试在 index.js 文件中添加一行 console.log('hello js'),保存文件后就会看到命令行下的变化:

$ npx webpack

Webpack is watching the files…

Hash: 915673b7e187314044b2
Version: webpack 3.10.0
Time: 43ms
       Asset    Size  Chunks             Chunk Names
build.min.js  2.5 kB       0  [emitted]  main
   [0] ./index.js 23 bytes {0} [built]
Hash: 16e8d3bad005cd26fbc7
Version: webpack 3.10.0
Time: 12ms
       Asset    Size  Chunks             Chunk Names
build.min.js  2.5 kB       0  [emitted]  main
   [0] ./index.js 24 bytes {0} [built]

从日志中我们看到,webpack 再次打包 build.min.js,耗时 12ms,最终文件大小 2.5 kB。

刷新浏览器

第二个问题,webpack 提供 webpack-dev-server 来解决,它是一个简单的 web 开发服务器,提供实时刷新浏览器页面的功能。

webpack-dev-server

首先在项目下安装 webpack-dev-server

$ npm install --save-dev webpack-dev-server

然后在命令行下执行 webpack-dev-server

$ npx webpack-dev-server
Project is running at http://localhost:8080/
webpack output is served from /
Hash: b12445477aac52d6322b
Version: webpack 3.10.0
Time: 243ms
       Asset    Size  Chunks                    Chunk Names
build.min.js  324 kB       0  [emitted]  [big]  main
   [2] multi (webpack)-dev-server/client?http://localhost:8080 ./index.js 40 bytes {0} [built]
   [3] (webpack)-dev-server/client?http://localhost:8080 7.63 kB {0} [built]
   [4] ./node_modules/url/url.js 23.3 kB {0} [built]
   [7] ./node_modules/url/util.js 314 bytes {0} [built]
   [8] ./node_modules/querystring-es3/index.js 127 bytes {0} [built]
  [11] (webpack)-dev-server/node_modules/strip-ansi/index.js 150 bytes {0} [built]
  [12] (webpack)-dev-server/node_modules/ansi-regex/index.js 253 bytes {0} [built]
  [13] ./node_modules/loglevel/lib/loglevel.js 7.86 kB {0} [built]
  [14] (webpack)-dev-server/client/socket.js 1.06 kB {0} [built]
  [15] ./node_modules/sockjs-client/dist/sockjs.js 181 kB {0} [built]
  [16] (webpack)-dev-server/client/overlay.js 3.69 kB {0} [built]
  [18] ./node_modules/html-entities/index.js 231 bytes {0} [built]
  [21] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
  [23] (webpack)/hot/emitter.js 77 bytes {0} [built]
  [25] ./index.js 24 bytes {0} [built]
    + 11 hidden modules
webpack: Compiled successfully.

Ooops,让人晕眩的输出结果。

不过且忽视它们,我们现在可以在 http://localhost:8080/ 访问我们的项目。

在入口文件 index.js 里添加一行代码验证下实时刷新功能:

console.log('webpack live reload is working')

我们可以在 chrome 浏览器的控制台看到如下内容:

[WDS] App updated. Reloading...
Navigated to http://localhost:8080/
hello js
webpack live reload is working

瞧,页面不仅自动刷新,连 index.js 都重新打包 - webpack-dev-server 一举解决我们前面提出的两个问题。

截止现在,我们才只是搭起脚手架,没有真正开始编码。接下来,让我们写一个简单的 React.js 单页面应用。在整个过程中,你可以看到 webpack 都发挥什么样作用。

安装第三方包

首先,使用 npm 来安装 reactreact-dom

$ npm install react react-dom

当然,你也可以使用 yarn

加载 React

让我们在 index.html 里添加一个 div

<div id='app'></div>

然后清空之前添加到 index.js 中的内容,新增内容如下:

import React from 'react'
import ReactDOM from 'react-dom'
console.log(React, ReactDOM)

保存文件后,我们马上就能在命令行下看到输出了。噫!webpack 竟支持原生的 ES2015 的 import - 不不,这是 webpack 2 新增的特性,在 webpack 1.x 年代是没有的,那时我们还需要 babel

好了,让我们去掉 index.js 里的 console.log(React, ReactDOM),写点实际的:

import React from 'react'
import ReactDOM from 'react-dom'
class App extends React.Component {
  render () {
    return <div>hello webpack</div>
  }
}
ReactDOM.render(<App />, document.getElementById('app'))

保存代码,我们马上就能在命令行下见到错误:

webpack: Compiling...
Hash: 6deafde1241e527cfbd7
Version: webpack 3.10.0
Time: 8ms
       Asset    Size  Chunks                    Chunk Names
build.min.js  324 kB       0  [emitted]  [big]  main
  [21] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
  [25] ./index.js 233 bytes {0} [built] [failed] [1 error]
    + 24 hidden modules

ERROR in ./index.js
Module parse failed: Unexpected token (5:11)
You may need an appropriate loader to handle this file type.
| class App extends React.Component {
|   render () {
|     return <div>hello webpack</div>
|   }
| }
 @ multi (webpack)-dev-server/client?http://localhost:8082 ./index.js
webpack: Failed to compile.

在前面的代码中,我们用到 JSX 语法,而 webpack 默认并不认识它 - 此时,我们需要一个加载器。这个加载器叫 babel-loader

  1. 安装 babel-loader

    $ npm install --save-dev babel-loader babel-core babel-preset-react
    
  2. webpack.config.js 中添加配置

    新增的配置如下:

    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /(node_modules|bower_components)/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['react']
            }
          }
        }
      ]
    }
    

完成以上两个步骤后,为什么还是报错?因为我们修改了 webpack.config.js,要使修改生效,需要重启 webpack-dev-server。

重启试试:

$ npx webpack-dev-server
Project is running at http://localhost:8080/
webpack output is served from /
Hash: bc87290c4287a6d8163c
Version: webpack 3.10.0
Time: 777ms
       Asset     Size  Chunks                    Chunk Names
build.min.js  1.05 MB       0  [emitted]  [big]  main
   [4] ./node_modules/react/index.js 190 bytes {0} [built]
  [16] multi (webpack)-dev-server/client?http://localhost:8080 ./index.js 40 bytes {0} [built]
  [17] (webpack)-dev-server/client?http://localhost:8080 7.63 kB {0} [built]
  [18] ./node_modules/url/url.js 23.3 kB {0} [built]
  [25] (webpack)-dev-server/node_modules/strip-ansi/index.js 150 bytes {0} [built]
  [26] (webpack)-dev-server/node_modules/ansi-regex/index.js 253 bytes {0} [built]
  [27] ./node_modules/loglevel/lib/loglevel.js 7.86 kB {0} [built]
  [28] (webpack)-dev-server/client/socket.js 1.06 kB {0} [built]
  [30] (webpack)-dev-server/client/overlay.js 3.69 kB {0} [built]
  [31] ./node_modules/ansi-html/index.js 4.26 kB {0} [built]
  [32] ./node_modules/html-entities/index.js 231 bytes {0} [built]
  [35] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
  [37] (webpack)/hot/emitter.js 77 bytes {0} [built]
  [39] ./index.js 282 bytes {0} [built]
  [43] ./node_modules/react-dom/index.js 1.36 kB {0} [built]
    + 37 hidden modules
webpack: Compiled successfully.

成了:不再报错,查看 HTML 页面的话,也显示了 hello webpack

从 JSX 一例我们看到,webpack 在处理它不认识的模块时,需要加载器做预处理 - 这正是我最初学习 webpack 觉得麻烦的地方,也恰恰是 webpack 强大、创新的地方。

图片加载器

说完 JSX,我们再来看看 webpack 是怎么处理图片的。

传统的 HTML 里,我们通常会有一个 assetsimages 目录,用于存放图片,然后代码中使用相对路径引用。

但在单页面应用中,我们不再使用相对路径来引用图片,因为开发目录的结构与最终打包的结构并不会一致。另外,我们不打算手动拷贝图片目录 - 我们希望,构建工具帮我们处理这一切,包括图片的拷贝、图片大小的优化等。

在 webpack 里,负责图片预处理的是 file-loader

$ npm install --save-dev file-loader

接着是配置 webpack.config.js

{
  test: /\.(png|jpg|gif)$/,
  use: [
    {
      loader: 'file-loader',
      options: {}  
    }
  ]
}

然后我们就可以在 JavaScript 模块中引用图片模块

import icon from './img/icon.png'
ReactDOM.render(<img src={icon} />, document.getElementById('app))

webpack 在最终构建时,会自动将模块中引用的图片拷贝到相应目录 - 谢天谢地,再也不用手动拷贝。

另外,webpack 在打包图片时,还可以调整图片的名称 - 比如给它们加上一段 hash,这样 webpack.png 图片在打包后就会变成 webpack.d20b780445cf2a8c7b565fb43864c171.png,好处是我们的服务器可以给图片设置一年甚至更长时间的缓存,而不必担心图片缓存的问题。

加载 CSS 文件

CSS 文件同样可以是模块,自然也有相应的加载器:

  1. css-loader - 预处理 CSS 文件
  2. style-loader - 将 CSS 插入到 DOM 中的 style 标签

注意,我们如果只使用了 css-loader,则 webpack 只是将 CSS 文件预处理成模块然后打包到构建文件中,并不会插入到页面 - 这是 style-loader 的作用。

我们先来安装它们:

$ npm install --save-dev css-loader style-loader

然后修改 webpack.config.js 配置:

,
{
  test: /\.css$/,
  use: [
    { loader: 'style-loader' },
    { loader: 'css-loader' }
  ]
}

请注意,loader 的顺序很重要,比如上面新增的这一节,如果把 style-loader 放到 css-loader 后面,我们就会撞见错误:

ERROR in ./index.css
Module build failed: Unknown word (5:1)

  3 | // load the styles
  4 | var content = require("!!./index.css");
> 5 | if(typeof content === 'string') content = [[module.id, content, '']];
    | ^
  6 | // Prepare cssTransformation
  7 | var transform;
  8 |

 @ ./index.js 3:0-21
 @ multi (webpack)-dev-server/client?http://localhost:8080 ./index.js
webpack: Failed to compile

因为 style-loader 无法理解 CSS 文件,需要先经 css-loader 预处理 - 是的,加载器的执行顺序是从后往前的。

接着,我们在 index.js 里可以这样引用样式文件:

import './index.css'

注意,这里的 CSS 仍然是全局的,等效于我们平常使用 <link href="" /> 引用的 CSS。但 webpack 还提供了 CSS Modules,可以将样式真正意义上的模块化。

当然,除了 CSS Modules 外,我们还有很多 CSS in js 方案可选,它们允许我们将样式跟 HTML、JS 放到一起 - 如果你写过 Vue.js 的单文件模块,可能会很喜欢。

脚手架

前面的步骤里,我们几乎是一步、一步手动配置每个类型文件的加载器,一次添加一小节,然后重启 webpack-dev-server,没人愿意这样干活。所以市面上有非常多的 boilerplates、presets 等,其中比较出名的有:

  1. create-react-app react 官方出品的一套,只适用开发 react.js 项目
  2. neutrino.js 这是 Mozilla 出品的一套解决方案,Web、React、Node.js 等方案均有。
  3. Parcel 最近新出的一套方案,追求零配置。

打包与优化

在完成项目开发后,我们需要输出文件给生产环境部署,只要执行:

$ npx webpack -p

即可。-p 参数等效于 --optimize-minimize --define process.env.NODE_ENV="'production'",具体的优化内容可以看 webpack 文档

多数时候,我们会额外再定义一个 webpack.production.config.js 文件,针对生产环境做更细致的调整,比如分离 vendors 文件等。当然,如果你用 creat-react-appneutrino.js 一类工具,通常都已经配置好了,非常方便。