做一个 “Webpack 配置工程师”

1,655 阅读12分钟

长文慎读

版本:
  - webpack 4
  - clean-webpack-plugin 3.0.0

本文分为三个部分:
  - 认识 Webpack
  - Webpack 基础
  - Webpack 进阶
  - Webpack 高级

认识 Webpack

Webpack 是一个模块打包工具。

webpack

简单举个例子🌰

新建三个文件,如下:

栗子

上面的例子会报错:

报错

因为浏览器并不支持 JS 的模块化导入。这时候 Webpack 便派上用场了。

事实上,已经有很多浏览器开始支持 JS 的模块化了,你可以在 <script> 标签中加入 type="module" 属性 ,并且将项目运行在服务器上即可。

第一次接触 Webpack

上面的例子中,我们可以将 test.jsindex.js 打包成一个文件 main.js ,然后在 index.html 引入 main.js

打包

📦 Webpack 可以帮我们生成这个 main.js 文件。

我们看一下怎么做。

首先使用 npm init -y 快速生成 package.json 。然后安装 webpackwebpack-cli

npm init -y
npm install webpack webpack-cli --save-dev --registry=https://registry.npm.taobao.org

--registry=https://registry.npm.taobao.org表示使用淘宝的镜像源,可以加速安装过程。

接着,

npx webpack index.js

便会生成 dist 目录及 main.js

dist

可以在 HTML 文件中引用 ./dist/main.js

npx 会在项目中的 node_modules 目录中寻找 webpack

Webpack 除了可以打包 JS 文件,还可以打包 CSS 文件 、图片等。

配置文件

事实上,我们还可以指定打包生成的目录及文件名。这就用到 webpack 配置文件了。

我们在项目根目录下创建 webpack.config.js

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

module.exports = {
    entry: './index.js', // 入口文件
    output: {
        filename: 'bundle.js', // 生成的文件名
        path: path.resolve(__dirname, 'bundle') // 生成的目录
    }
}

接着,

npx webpack

webpack 会根据 webpack.config.js 中的配置来进行打包,然后生成 bundle/bundle.js

bundle

实际项目中,我们经常会在 package.json 中添加 scripts 脚本,如:

package.json

这样,我们便可以直接执行:

npm run build

效果相同。


一些补充

😶 第一点

上面的 webpack.config.js 中,entry 是一种简写:

// webpack.config.js
...
module.exports = {
    entry: './index.js', // 入口文件 ⭐️
    ...
}

等同于:

// webpack.config.js
...
module.exports = {
    entry: {
        main: './index.js' // 入口文件 ⭐️
    },
    ...
}

这种写法,在配置多入口文件时很有用,我们后面会介绍。

😯 第二点

webpack 打包有两种模式:productiondevelopmentproduction 会对生成的代码进行压缩,development不会压缩。

默认是 production。你可以通过 mode 字段进行修改:

// webpack.config.js
...
module.exports = {
    mode: 'development', // 默认是 production ⭐️
    entry: {
        main: './index.js'
    },
    ...
}

😲 第三点

webpack 的默认配置文件是 webpack.config.js ,你也可以通过 webpack --config 指定配置文件。

配置文件

Webpack 基础

webpack 默认是知道怎么打包 JS 文件的,但是其他类型的文件它就不知道了。

我们可以在配置文件中为各种文件类型指定 loader ,告诉 webpack 怎么打包不同的文件。

比如,使用 file-loader 来告诉 webpack 怎么打包图片:

// webpack.config.js
...
module.exports = {
    ...
    output: {
        filename: "bundle.js",
        path: path.resolve(__dirname, 'bundle')
    },
    module: {
        rules: [
            {
                test: /\.jpeg$/, // 正则匹配文件名以 .jpeg 结尾的文件 ⭐️
                use: {
                    loader: 'file-loader' // 使用 file-loader ⭐️
                }
            }
        ]
    }
}

file-loader 需要安装:

npm i file-loader --save-dev

我们 index.js 中引入图片:

// index.js
import avatar from './avatar.jpeg' // avatar.jpeg 是预先放在根目录下的一张图片

console.log('avatar:', avatar)

npm run build 之后,在 bundle 目录中,会生成一个文件名 b417eb0f1c79e606d72c000762bdd6b7.jpeg 图片。

控制台中会打印:

avatar: b417eb0f1c79e606d72c000762bdd6b7.jpeg

打包图片

file-loader 中可以使用 options 指定打包后的图片名。

// webpack.config.js
...
module.exports = {
    ...
    module: {
        rules: [
            {
                test: /\.jpeg$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name]-[hash:7].[ext]' // 占位符 ⭐️
                    }
                }
            }
        ]
    }
}

其中 [name] 代表图片的文件名,[hash:7] 代表 hash 值的前 7 位,[ext] 代表图片的扩展名。

[hash] 代表整个项目的 hash ,只有整个项目中有文件发生变动, hash 值就会变。[contenthash] 代表当前文件的 hash ,只有当前文件变了,contenthash 才会变。但是 contenthash 会与 webpack.HotModuleReplacementPlugin() 插件冲突,因此建议在 production 环境下使用 contenthash 。

npm run build 之后,在 bundle 目录中,会生成一个文件名 avatar-b417eb0.jpeg 图片。

除了 .jpeg ,还可以通过正则表达式匹配其他格式的图片:

// webpack.config.js
...
{
    test: /\.(jpeg|jpg|png|gif)$/, // 匹配更多格式的图片 ⭐️
    use: {
        loader: 'file-loader',
        options: {
            name: '[name]-[hash:7].[ext]'
        }
    }
}
...

options 中除了 name ,还有 outputPath ,指定生成图片的路径。

// webpack.config.js
...
test: /\.(jpeg|jpg|png|gif)$/,
use: {
    loader: 'file-loader',
    options: {
        name: '[name]-[hash:7].[ext]',
        outputPath: 'images/' // 指定生成图片的路径 ⭐️
    }
}
...

npm run build 之后,在 bundle 目录中,会生成一个 images 目录及 avatar-b417eb0.jpeg 图片。

除了 file-loader ,还有一个 url-loader 也可以用来打包图片。不同的是,使用 url-loader 会生成 Base64 格式的图片。你可以通过指定 limit 参数来限定阈值:小于此阈值则使用 Base64 ,否则使用图片文件的格式。

// webpack.config.js
...
{
    test: /\.(jpeg|jpg|png|gif)$/,
    use: {
        loader: 'url-loader', // 使用 url-loader ⭐️
        options: {
            name: '[name]-[hash:7].[ext]', 
            outputPath: 'images/',
            limit: 2048 // 指定生成Base64 图片的阈值 ⭐️
        }
    }
}
...

当然,使用 url-loader 也是需要安装的:

npm i url-loader --save-dev

推荐使用 url-loader 。

打包样式

打包 CSS 样式,需要用到两个 loader :style-loader 、 css-loader 。

// webpack.config.js
...
module.exports = {
    ...
    module: {
        rules: [
            ...
            {
                test: /\.css$/, // 正则匹配以 .css 结尾的文件 ⭐️
                use: ['style-loader', 'css-loader'] // ⭐️
            }
        ]
    }
}

当然,这两个 loader 是需要我们安装的:

npm i style-loader css-loader -D

接着,我们创建两个 CSS 文件:reset.css 、 index.css ,并在 index.js 中引入 index.css ,如下图:

css

npm run build 之后,webpack 顺利打包。当我们在浏览器中查看元素时,会发现 <head> 标签下,多了一个 <style> 标签:

截图

说明 CSS 样式已经成功导入了项目中。

其中,use: ['style-loader', 'css-loader'] 的顺序不能颠倒,webpack 打包的时候,是按照逆序来处理 CSS 文件的:先使用 css-loader 来处理 CSS 中的 @import 语法,再用 style-loader 将 CSS 样式插入 <style> 标签中。

🤖 css 预处理器

我们还可以使用 scss 这样的 css 预处理器。

// webpack.config.js
...
rules: [
    ...
    {
        test: /\.scss$/, // 正则匹配以 .scss 结尾的文件 ⭐️
        use: ['style-loader', 'css-loader', 'sass-loader'] // ⭐️
    }
]
...

还需要安装 sass-loader 、 node-sass :

npm install sass-loader node-sass --save-dev

这样,在 JS 中就可以直接引入 scss 文件了。

🔌 自动添加厂商前缀

某些特殊的 css3 属性可能需要加浏览器厂商前缀,我们可以通过 postcss-loader 中的 autoprefixer 插件自动帮我们加上:

// webpack.config.js
...
{
    test: /\.scss$/,
    use: [
        'style-loader',
        'css-loader',
        {
            loader: 'postcss-loader', // ⭐️
            options: {
                ident: 'postcss',
                plugins: [
                    require('autoprefixer') // ⭐️
                ]
            }
        },
        'sass-loader'
    ]
}
...

webpack 需要 options 中有一个唯一的标识符 —— identifier (ident) ,ident 可以随意命名。

为此我们需要安装 postcss-loader 和 autoprefixer :

npm i postcss-loader autoprefixer -D

比如我们需要指定 <input> 标签中 placeholder 的颜色,则在 css 中有:

::placeholder {
  color: deepskyblue;
}

npm run build 之后,<style> 标签中会有:

::-webkit-input-placeholder{color:deepskyblue}
::-moz-placeholder{color:deepskyblue}
:-ms-input-placeholder{color:deepskyblue}
::-ms-input-placeholder{color:deepskyblue}
::placeholder{color:deepskyblue}

并不是所有的 css3 中的属性都会被加上厂商前缀,如:transformborder-radiustransition 等。css3 经过这么多年的发展,浏览器对其支持已经很好了,所以 autoprefixer 插件并不会给这些属性加上前缀。autoprefixer 会根据 Can I Use 来决定是否加上前缀。

🐖 注: css-loader 有一个 importLoaders 的选项,当你在 scss 文件中 @import 另一个 scss 文件,你希望被 import 的那个文件也能被之前的 loader 处理,则 importLoaders 可以帮到你:

module.exports = {
  module: {
    rules: [
      {
        test: /\.scss$/i,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 2,
              // 0 => no loaders (default);
              // 1 => postcss-loader;
              // 2 => postcss-loader, sass-loader
            }
          },
          'postcss-loader',
          'sass-loader',
        ]
      }
    ]
  }
}

🧩 CSS 模块化

不同的 CSS 文件中,可能会产生冲突。

比如在 a.css 中设置.side class 的颜色为 red 🔴,而在 b.css 中设置.side class 的颜色为 blue 🔵。接着在 a.js 中引入 a.css,在 b.js 中引入 b.css 。这时冲突就会发生。根据引入位置的顺序不同, .side class 最终的颜色会为 red 或者 blue 中的一种。而不是我们最初设想的,在不同的 JS 文件中会有不同的颜色。

解决的办法就是 CSS 模块化,只需要添加 modules: true 即可:

module.exports = {
  module: {
    rules: [
      {
        test: /\.scss$/i,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 2,
              modules: true // ⭐️
            }
          },
          'postcss-loader',
          'sass-loader',
        ]
      }
    ]
  }
}

引入时,不再像之前那样 import './a.scss' ,而是 import style from './a.scss' 。webpack 会将 css 样式封装成一个对象,使用这个 css 文件中的 .side class 时,变成 style.side。如:

import style from './a.scss'

const wrap = document.getElementById('wrap')
wrap.classList.add(style.side)

npm run build 之后,会发现生成的 DOM 节点为:

<div id="wrap" class="_6VOXwYSkDQv8fWDLvRRIk"></div>

💯 打包字体文件

得益于 css3 中的 @font-face ,使得我们可以在网页中使用我们喜欢的任何字体。还有一些专门的网站(如 iconfont),可以将一些图标制作成字体文件。

如果你使用了 @font-face ,那么你就需要指定 webpack 打包这些字体文件了。同打包图片一样,打包字体文件也使用 file-loader :

// webpack.config.js
...
{
    test: /\.(eot|ttf|svg)$/,
    use: {
        loader: 'file-loader'
    }
},
...

plugins

插件可以在 webpack 打包的某个过程中,帮我们做一些事情。

接下来,让我们遵循规范,将打包生成的目录改为 dist 。

我们通过 npm run build 生成的 dist 目录下没有 html 文件,只有一个 bundle.js 文件(可能还有你引入的一些图片和字体文件)。

🥜 HtmlWebpackPlugin

事实上,我们可以通过 webpack 中的 HtmlWebpackPlugin 插件帮我们自动生成一个 html 文件。

// webpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin'); // ⭐️

module.exports = {
    ...
    entry: {
        ...
    },
    output: {
        ...
    },
    module: {
        ...
    },
    plugins: [new HtmlWebpackPlugin()] // ⭐️
}

当然,首先要安装:

npm install --save-dev html-webpack-plugin

然后 npm run build 之后,我们会发现在 dist 目录下有一个 index.html 文件:

<!-- dist/index.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Webpack App</title>
  </head>
  <body>
  <script type="text/javascript" src="bundle.js"></script></body>
</html>

并且,这个 index.html 文件中还自动帮我们引入了 bundle.js 。

这就是 HtmlWebpackPlugin 插件的作用。

我们还可以指定一个模板文件:

// webpack.config.js
module.exports = {
    ...
    plugins: [new HtmlWebpackPlugin({
        template: "./index.html" // ⭐️
    })]
}

🧶 AddAssetHtmlWebpackPlugin

这个插件可以帮我们在生成的 html 文件中添加一些静态资源。

先安装

npm i add-asset-html-webpack-plugin --save-dev

接着:

// webpack.config.js

const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin') // ⭐️ 
...
plugins: [
    new HtmlWebpackPlugin({
        template: "./index.html"
    }),
    new AddAssetHtmlWebpackPlugin({
        filepath: path.resolve(__dirname, 'assets/js/your-plugin.js') // ⭐️
    })
]
...

这样,在生成的 index.html 文件中会自动帮你引入 your-plugin.js 。

🧹 CleanWebpackPlugin

每一次 npm run build 之后,都会在 dist 目录下生成一些文件。时间长了,可能会产生很多垃圾文件。我们希望每次 npm run build 的时候,webpack 都能自动帮我们清除这些文件。

这就用到了 CleanWebpackPlugin 。

先安装:

npm install clean-webpack-plugin --save-dev

接着:

// webpack.config.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
  // ⭐️
...
plugins: [
    new HtmlWebpackPlugin({
        template: "./index.html"
    }),
    new CleanWebpackPlugin()  // ⭐️
]
...

再说 entry 和 output

webpack 可以指定多个入口文件:

// webpack.config.js
...
entry: {
    bundle: './index.js', // ⭐️
    other: './other.js' // ⭐️
},
output: {
    filename: "[name].[hash:7].js", // 占位符 ⭐️
    path: path.resolve(__dirname, 'dist')
},
...

通过占位符 [name].[hash:7].js, webpack 会在 dist 目录下生成两个文件:

  • bundle.0fbc091.js
  • other.0fad032.js

并且通过 HtmlWebpackPlugin 自动生成的 dist/index.html 中也引用了这两个文件:

<!-- dist/index.html -->
<html>
<head>
    <meta charset="UTF-8">
    <title>HTML 模板</title>
</head>
<body>
<h1>Hello world</h1>
<script type="text/javascript" src="bundle.0fbc091.js"></script>
<script type="text/javascript" src="other.0fad032.js"></script>
</body>
</html>

output 还有一个 publicPath 属性,可以指定资源的公共域名:

// webpack.config.js
...
output: {
    publicPath: "http://cdn.yourdomain.com", // ⭐️
    filename: "[name].[hash:7].js",
    path: path.resolve(__dirname, 'dist')
},
...

这样生成的 dist/index.html :

<!-- dist/index.html -->
<html>
<head>
    <meta charset="UTF-8">
    <title>HTML 模板</title>
</head>
<body>
<h1>Hello world</h1>
<script type="text/javascript" src="http://cdn.yourdomain.com/bundle.5fc8df1.js"></script>
<script type="text/javascript" src="http://cdn.yourdomain.com/other.5fc8df1.js"></script>
</body>
</html>

为了保证下面的示例正常运行,我们将 publicPath 属性删除。

source-map

如果我们在入口文件 index.js 中写错了代码:

// index.js
console.logo('hello world') // console.log 错写成了 console.logo

npm run build 之后,在浏览器打开 dist/index.html ,报错:

报错

控制台中指示错误的地方为 bundle.83ffb3f.js 中的第 1 行。然而,这个文件是 webpack 打包生成的文件,我们希望控制台告诉我们源文件中的错误。

可以配置 devtool 字段:

// webpack.config.js
...
mode: 'production',
devtool: "source-map", // ⭐️
entry: {
    bundle: './index.js'
},
output: {
...

npm run build 之后,就可以正确的显示错误来源了。

报错

🐖 注:

  1. mode: 'development' 时建议使用 devtool: 'eval-cheap-module-source-map'
  2. mode: 'production'如有需要,建议使用 devtool: 'cheap-module-source-map'

devServer

使用 WebpackDevServer 可以开启本地服务器。当我们更改代码时, 还可以自动重新打包、自动打开浏览器、自动刷新。

自己动

只需做一下更改:

// webpack.config.js
...
module.exports = {
    ...
    entry: {
        ...
    },
    output: {
        ...
    },
    module: {
        ...
    },
    plugins: [
        ...
    ],
    devServer: {  // ⭐️
        contentBase: './dist',
        open: true,
        port: 8000
    }
}

其中 contentBase 指定本地服务器的根目录,open 指定是否自动打开浏览器,port 指定端口号,默认是 8080 。

然后安装:

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

接着,在 package.json 中添加一个 start 脚本:

...
"scripts": {
  "build": "webpack",
  "start": "webpack-dev-server", // ⭐️
  "test": "echo \"Error: no test specified\" && exit 1"
},
...

然后 npm start 便会开启本地服务器,并自动打开浏览器。

只有 start 和 test 可以不用 npm run

在编辑器中更改代码,浏览器会自动刷新。

devServer 还支持代理:

// webpack.config.js
module.exports = {
  //...
  devServer: {
    proxy: {
      '/api': 'http://localhost:3000'
    }
  }
}

这样,发送到 /api/users 的请求将会被代理到 http://localhost:3000/api/users

HRM 热模块更替

HRM 是 Hot Module Replacement (热模块更替)的简写。

devServer 的自动刷新功能很方便,但有时候我们不想自动刷新。比如,我们填写了一大堆表单,这时候自动刷新会让表单中的内容全部消失。

HRM 可以解决这一问题。

// webpack.config.js
...
const webpack = require('webpack') // ⭐️

module.exports = {
    ...
    entry: {
        ...
    },
    output: {
        ...
    },
    module: {
        ...
    },
    plugins: [
        ...
        new webpack.HotModuleReplacementPlugin() // ⭐️
    ],
    devServer: {
        contentBase: './dist',
        open: true,
        hot: true, // ⭐️
        hotOnly: true // ⭐️
    }
}

HotModuleReplacementPlugin 是 webpack 自带的一个插件。hot: true 代表开启 HRM ,hotOnly: true 代表只进行 HRM ,不自动刷新。

这样,当 css 样式更改时,不刷新也能看到更改后的效果。

要想在 js 文件中实现 HRM ,可以使用 module.hot 属性:

// index.js
import Library from './library.js'
...
if (module.hot) { // 检查是否开启了 hot 功能
  module.hot.accept('./library.js', function() {
    // 当 library.js 发生更改时,做一些事情
  });
}

之所以更改 css 文件,不用写上面这样一段代码,是因为 css-loader 帮我们写了。vue 项目中, vue-loader 也做了同样的事。

Babel

使用 Babel ,可以让我们在项目中使用 ES6 的语法。

Babel 会将 ES6 编译成 ES5 。

值得注意的是,使用 Babel ,有两种方案:

  1. polyfill
  2. runtime

二者的区别在于:polyfill 方式生成的 ES5 代码会直接作用于全局,可能会产生污染。runtime 方式生成的 ES5 则不会。

通常,我们使用 polyfill 方式就可以了,它适合于业务代码。但当你在开发第三方库的时候,使用 runtime 更好。

👹 polyfill 方式

安装:

npm install --save-dev babel-loader @babel/core @babel/preset-env
npm install --save @babel/polyfill

配置:

// webpack.config.js
...
module.exports = {
    ...
    module: {
        rules: [
            ...
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: "babel-loader",
                options: {
                    presets: [
                        ['@babel/preset-env', {useBuiltIns: 'usage', corejs: 3}]
                    ]
                }
            }
        ]
    },
    ...
}

在入口文件中使用:

// index.js
import '@babel/polyfill' // ⭐️

const promise = new Promise((resolve) => {
    setTimeout(() => {
        resolve('success')
    }, 2000)
})

🐖注: 由于配置了 "useBuiltIns": "usage" ,所以在 index.js 中也可以不用 import '@babel/polyfill'。此外,"useBuiltIns": "usage"还会使 babel 只生成项目中使用到的 ES6 API。

npm run build 之后,生成的 bundle.e8d5b05.js 中有:

// bundle.e8d5b05.js
...
var promise = new Promise(function (resolve) {
  setTimeout(function () {
    resolve('success');
  }, 2000);
});
...

可以发现,其中的 const 变成了 var ,箭头函数变成了 functionPromise 被重新实现了:

// bundle.e8d5b05.js
...
if (!USE_NATIVE) {
  $Promise = function Promise(executor) {
    ...
    Internal.call(this);
    ...
  };
  Internal = function Promise(executor) {
    ...
  };
  Internal.prototype = __webpack_require__(..., {
    then: function then(onFulfilled, onRejected) { // ⭐️ then
      ...
    },
    'catch': function (onRejected) { // ⭐️ catch
      ...
    }
  });
  ...
}
...
$export(..., {
  reject: function reject(r) { // ⭐️ reject
    ...
  }
});
$export(..., {
  resolve: function resolve(x) { // ⭐️ resolve
    ...
  }
});
$export(..., {
  all: function all(iterable) { // ⭐️ all
    ...
  },
  race: function race(iterable) { // ⭐️ race
    ...
  }
});

Babel 成功的将 ES6 转成了 ES5 。

👺 runtime 方式

安装:

npm install --save-dev babel-loader @babel/core @babel/plugin-transform-runtime
npm install --save @babel/runtime-corejs2

配置:

// webpack.config.js
...
{
    test: /\.js$/,
    exclude: /node_modules/,
    loader: "babel-loader",
    options: {
        /*presets: [
            ['@babel/preset-env', {useBuiltIns: 'usage', corejs: 3}]
        ]*/
        'plugins': [ // ⭐️
            [
                '@babel/plugin-transform-runtime',
                {
                    'corejs': 2
                }
            ]
        ]
    }
}
...

在入口文件中使用:

// index.js
// import '@babel/polyfill'  // ⭐️ 此处无需再引入 @babel/polyfill

const promise = new Promise((resolve) => {
    setTimeout(() => {
        resolve('success')
    }, 2000)
})

npm run build 之后,生成的 bundle.345b70e.js 中有

// bundle.345b70e.js
...
// import '@babel/polyfill'
const promise = new _babel_runtime_corejs2_core_js_promise__WEBPACK_IMPORTED_MODULE_0___default.a(resolve => {
  setTimeout(() => {
    resolve('success');
  }, 2000);
});
...

🐖注:

上面两种方式都可以通过创建 .babelrc 来配置。在项目根目录下,创建 .babelrc 文件:

// .babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ]
  ]
}

这样,webpack.config.js 中只需:

// webpack.config.js
...
{
    test: /\.js$/,
    exclude: /node_modules/,
    loader: "babel-loader"
}
...

😈 打包 React

Babel 还可以编译 React 的 JSX 语法。

首先,安装:

npm install --save-dev babel-loader @babel/core @babel/preset-env
npm install --save-dev @babel/preset-react
npm install --save @babel/polyfill
npm install --save react react-dom

然后, 更改 html 模板文件 index.html :

<!-- index.html -->
<html>
<head>
    <meta charset="UTF-8">
    <title>HTML 模板</title>
</head>
<body>
<div id="app"></div> <!-- ⭐️ -->
</body>
</html>

创建 index.jsx 文件(注意,后缀名为 .jsx):

// index.jsx
// import '@babel/polyfill'

import React, {Component} from 'react'
import ReactDom from 'react-dom'

class App extends Component {
    render() {
        return <h1>你好,世界!</h1>
    }
}

ReactDom.render(<App/>, document.getElementById('app'))

其实后缀名为 .js 也可以,只不过这样的话有些编辑器会不识别 jsx 语法。但尽管这样,仍然能正常打包编译。

配置 webpack.config.js :

// webpack.config.js
...
module.exports = {
    ...
    entry: {
        bundle: './index.jsx' // ⭐️ 
    },
    ...
    module: {
        rules: [
            ...
            {
                test: /\.jsx$/, // ⭐️ 
                exclude: /node_modules/,
                loader: 'babel-loader'
            }
        ]
    },
    ...
}

配置 .babelrc :

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ],
    "@babel/preset-react"
  ]
}

npm start 之后,打开浏览器:

你好,世界!

成功打包 react 。

Webpack 进阶

Tree Shaking

开始讲解 Tree Shaking 之前,先设置:mode: 'production'

假设我们有一个 test.js 文件:

// test.js
function test1() {
    console.log('test1')
}

function test2() {
    console.log('test2')
}

export {
    test1,
    test2
}

在 index.js 中引入:

// index.js
import { test1 } from './test'

test1()

注意,此处我们只引入了 test1 函数,test2 没有引入。

npm run build 之后生成的 bundle.4652aa7.js 中只会包含 test1 的代码。

这就是 Tree Shaking 。它可以帮我们自动删去没有引用的模块。

🐖 注:

  1. 只有在 mode: 'production' 时,才会真正 Tree Shaking 。若 mode: 'development' ,也可以配置 Tree Shaking ,但仍然不会删去未引用的模块。
  2. 只有使用 ES6 的模块化规范,才能 Tree Shaking 。 使用 Common JS 规范会不起作用。

development 和 production

实际开发中,开发环境和生产环境的 webpack 配置通常不一样。

我们可以将开发环境和生产环境的配置分别放在 webpack.development.js 和 webpack.production.js 中,并且将二者之间的公共代码抽离出来,放在 webpack.common.js 中。

首先安装 webpack-merge 插件,用于合并两个 webpack 配置文件:

npm install webpack-merge --save-dev

再创建一个 build 目录,新建上述三个文件:

😺 webpack.common.js

// webpack.common.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
    entry: {
        main: './index.js' // ⭐️ 将打包生成的文件名由 bundle 改为 main
    },
    output: {
        filename: '[name].[hash:7].js',
        path: path.resolve(__dirname, '../', 'dist') // ⭐️ 注意,生成的 dist 目录 应与 build 目录平级
    },
    module: {
        rules: [
            {
                test: /\.(jpeg|jpg|png|gif)$/,
                exclude: /node_modules/,
                use: {
                    loader: 'url-loader',
                    options: {
                        name: '[name]-[hash:7].[ext]',
                        outputPath: 'images/',
                        limit: 2048
                    }
                }
            },
            {
                test: /\.(eot|ttf|svg)$/,
                exclude: /node_modules/,
                use: {
                    loader: 'file-loader'
                }
            },
            {
                test: /\.scss$/,
                exclude: /node_modules/,
                use: [
                    'style-loader',
                    {
                        loader: 'css-loader',
                        options: {
                            importLoaders: 2,
                            modules: true
                        }
                    },
                    {
                        loader: 'postcss-loader',
                        options: {
                            ident: 'postcss',
                            plugins: [
                                require('autoprefixer')
                            ]
                        }
                    },
                    'sass-loader'
                ]
            },
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'babel-loader'
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './index.html'
        }),
        new CleanWebpackPlugin()
    ]
}

😸 webpack.development.js

// webpack.development.js
const webpack = require('webpack')
const merge = require('webpack-merge') // ⭐️ 
const common = require('./webpack.common')

const development = {
    mode: 'development',
    devtool: 'eval-cheap-module-source-map',

    plugins: [
        new webpack.HotModuleReplacementPlugin()
    ],
    devServer: {
        contentBase: './dist',
        // open: true,
        hot: true
        // hotOnly: true
    }
}
module.exports = merge(common, development) // ⭐️ merge 两个 webpack 配置

😹 webpack.production.js

// webpack.production.js
const merge = require('webpack-merge') // ⭐️ 
const common = require('./webpack.common')

const production = {
    mode: 'production',
    devtool: 'cheap-module-source-map'
}

module.exports = merge(common, production) // ⭐️ merge 两个 webpack 配置

之后更改 package.json 中的 scripts :

...
"scripts": {
  "build": "webpack --config ./build/webpack.production.js",
  "serve": "webpack-dev-server --config ./build/webpack.development.js"
},
...

npm run build 用于生产环境,npm run serve 用于开发环境。

代码分割

webpack 默认会将所有的模块打包到一个文件中,如 main.c62b431.js 。

有时,某些第三方模块变动较少,如:jQuery、lodash 。我们可以将这些模块打包到单独的文件中。

模块导入又分为同步和异步。

import _ from 'lodash' 为同步模块,import('lodash').then(...) 为异步模块。

如,在 index.js 中:

// index.js
import {join} from 'lodash' // ⭐️ 同步模块

console.log(join(['hello', 'world'], '_'))

async function getNow() {
    // ⭐️ 异步模块
    const {default: $} = await import(/* webpackChunkName: "jquery" */'jquery')
    return $.now()
}

getNow().then(now => {
    console.log('now: ', now)
})

其中,/* webpackChunkName: "jquery" */ 称为魔法注释,它可以指定打包生成的模块的文件名。

我们可以看到,只有当 getNow 函数执行时, jquery 才会被加载。这种加载方式也称为 “ 懒加载 ”

在 webpack.common.js 中作如下配置:

//webpack.common.js
...
module.exports = {
    entry: {
        main: './index.js'
    },
    ...
    plugins: [
        ...
    ],
    optimization: {
        splitChunks: {
            chunks: 'all', // all 代表同步模块和异步模块都分割
            minSize: 30000, // ⭐️ minSize 表示最小的文件大小,30000 为默认值,超过此值才进行分割
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/, // 匹配 node_modules 目录下的模块
                    automaticNamePrefix: 'vendors',
                    priority: -10
                },
                default: {
                    reuseExistingChunk: true,
                    automaticNamePrefix: 'default',
                    priority: -20
                }
            }
        }
    }
}

npm run build 之后, dist 目录下会生成:

代码分割

其中,main.77fd3cc.js 是 index.js 打包生成的, vendors~jquery.77fd3cc.js 是异步加载的 jquery 模块打包生成的, 其余所有的同步模块都会被打包到 vendors~main.77fd3cc.js 中。

打包分析

安装一个插件:

npm install --save-dev webpack-bundle-analyzer

在 build 目录下新建一个 webpack.analyze.js :

// webpack.analyze.js
const merge = require('webpack-merge')
const production = require('./webpack.production')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

const analyze = {
    plugins: [
        new BundleAnalyzerPlugin()
    ]
}

module.exports = merge(production, analyze)

在 package.json 中添加一个脚本:

"scripts": {
  ...
  "analyze:build": "webpack --config ./build/webpack.analyze.js"
}

npm run analyze:build 之后,自动打开浏览器:

打包分析

通过这个工具可以分析出各个包的大小。比如,从上图可以看出,lodash 和 jquery 体积过大,从而可以进一步优化。

异步模块预加载

有些模块,我们不希望在页面刚加载的时候被下载。

比如点击登录按钮,弹出一个登录弹窗,弹窗部分的 JS 代码就可以被异步加载。为实现这一点,使用前面说过的异步 import('...') 就可以了。但是如果等到点击登录按钮的时候再去下载弹窗的代码,加载时间就会很慢,用户体验不好。

这时就会用到这一个魔法注释: /* webpackPrefetch: true */ 。它会在页面初始加载完成后,自动加载异步模块。

以异步预加载 jquery 为例:

// index.js
async function getNow() {
                                            // ⭐️
    const {default: $} = await import(/* webpackPrefetch: true */ /* webpackChunkName: "jquery" */'jquery')
    return $.now()
}

// ⭐️ 点击页面后再去加载 jquery
document.body.addEventListener('click', function () {
    getNow().then(now => {
        console.log('now: ', now)
    })
})

npm run serve 之后,打开浏览器控制台:

预加载

页面性能优化的一个原则是:只在需要用到某一块代码时才去加载它。 异步 import('...') 使得这一原则得以实现,而 /* webpackPrefetch: true */ 又保证了用户体验。

还有一个魔法注释:/* webpackPreload: true */ 。但是它会使异步模块与同步模块一起加载。不推荐。

Prefetch 在某些浏览器中不支持。

CSS 代码分割

使用 MiniCssExtractPlugin 可以将 CSS 代码单独放在一个文件中,而不是插入 <style> 标签里。

首先安装:

npm install --save-dev mini-css-extract-plugin
npm install --save-dev optimize-css-assets-webpack-plugin

style.scss:

body {
  background: greenyellow;
}

index.js:

import './style.scss';

webpack.common.js:

...
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')

module.exports = {
    ...
    module: {
        rules: [
            ...
            {
                test: /\.scss$/,
                exclude: /node_modules/,
                use: [
                    MiniCssExtractPlugin.loader, // ⭐️ 删除 style-loader ,使用 MiniCssExtractPlugin.loader
                    {
                        loader: 'css-loader',
                        options: {
                            importLoaders: 2,
                            modules: true
                        }
                    },
                    {
                        loader: 'postcss-loader',
                        options: {
                            ident: 'postcss',
                            plugins: [
                                require('autoprefixer')
                            ]
                        }
                    },
                    'sass-loader'
                ]
            },
            ...
        ]
    },
    plugins: [
        ...
        new MiniCssExtractPlugin() // ⭐️ 使用 MiniCssExtractPlugin 插件分割 CSS 代码
    ],
    optimization: {
        ...
        minimizer: [new OptimizeCSSAssetsPlugin({})] // ⭐️ 使用 OptimizeCSSAssetsPlugin 插件压缩 CSS 代码
    }
}

在 webpack 中使用 jQuery 插件

jQuery 的插件比较丰富,但直接在 webpack 中使用,往往会报错。

原因在于,早期的 jQuery 插件并不支持模块化,而是直接使用挂载在 window 对象下的 $ 对象,如:

$('#el').somePlugin()

但使用 webpack 打包时,window 对象下并不会有一个 $ 。故报错。

🙈 可以使用 ProvidePlugin

// webpack.common.js
const webpack = require('webpack')
...
plugins: [
    ...
    new webpack.ProvidePlugin({ // ⭐️ 
        $: 'jquery', // ⭐️ $ 相当于 jquery
        jQuery: 'jquery',
        now: ['jquery', 'now'], // ⭐️ now 相当于 jquery.now
        'window.jQuery': 'jquery',
        _map: ['lodash', 'map'] // ⭐️ _map 相当于 lodash.map
    })
],
...

这样,在 index.js 就可以直接使用 $甚至不用引入 jquery

// index.js
// import $ from 'jquery' // ⭐️ 无需引入 jquery

$('#app').text('骄傲的使用 jQuery')
console.log(now())

实际上,ProvidePlugin 会自动帮你引入 jquery 。

🙉 还可以使用 imports-loader

imports-loader 允许你使用那些依赖于特定全局变量的模块。

安装:

npm install imports-loader

比如你有一个 jquery 插件叫 cool-plugin.js :

// cool-plugin.js
$("img").cool()

接着,我们在入口文件 index.js 中引入 cool-plugin.js :

// index.js
require("imports-loader?$=jquery!./cool-plugin.js");

实际上,webpack 会在 cool-plugin.js 前面插入 var $ = require("jquery")

也可以传多个值,使用 , 分隔:

// index.js
require("imports-loader?$=jquery,angular,config=>{size:50}!./file.js")

你也可以在 webpack.config.js 中配置,这样就不用手动 require("imports-loader?...) 了:

// ./webpack.config.js

module.exports = {
    ...
    module: {
        rules: [
            {
                test: require.resolve("some-module"),
                use: "imports-loader?this=>window" // ⭐️ 引入 some-module 时,自动将 this 指向 window
            }
        ]
    }
};

🙊 还有一个最干脆的方法

引入旧的第三方库,最鲁莽干脆的办法是直接在 index.html 模板文件中引入:

<!-- index.html -->
<html>
<head>
    <meta charset="UTF-8">
    <title>HTML 模板</title>
</head>
<body>
<div id="app"></div>
<script src="http://some.path.to.plugin"></script> <!--  ⭐️  -->
</body>
</html>

Webpack 高级

打包分享自己的库

入口文件:

// index.js
import * as myAwesomeLibrary from './myAwesomeLibrary'
import $ from 'jquery'

console.log($.now())

export default myAwesomeLibrary

webpack 配置:

// webpack.config.js
...
module.exports = {
    externals: ['jquery'], //  ⭐️  
    entry: {
        main: './index.js'
    },
    output: {
        filename: 'myAwesomeLibrary.js',
        path: path.resolve(__dirname, '../', 'dist'),
        libraryTarget: 'umd', //  ⭐️  
        library: 'awesome' //  ⭐️  
    },
    ...
}

其中 externals: ['jquery'] 表示不打包 jquery。外界在使用本模块的时候,需要额外引入 jquery 。

libraryTarget: 'umd' 表示支持 ES6 模块化、CommonJS 规范、AMD 等方式引入本模块。

library: 'awesome' 表示会在 window 下挂载一个全局变量:window.awesome 。即你可以通过<script src="./dist/myAwesomeLibrary.js"></script> 方式引入本库。

libraryTargetlibrary 可以相互配合:

  • libraryTarget: 'this' 表示 this.awesome
  • libraryTarget: 'window' 表示 window.awesome
  • libraryTarget: 'global' 表示 global.awesome

在 package.json 中:

{
  "name": "webpack-test",
  "version": "1.0.0",
  "main": "./dist/myAwesomeLibrary.js", //  ⭐️  
  ...
}

之后就可以将项目发布到 npm 上去了。

PWA

PWA 可以实现离线上网。当用户第一次访问过你的网站,第二次访问时,即使断网,也能访问。

只有 production 环境下才需要实现 PWA 。

安装:

npm install workbox-webpack-plugin --save-dev

在 webpack.production.js 中:

// webpack.production.js
...
const WorkBoxPlugin = require('workbox-webpack-plugin') //  ⭐️  

const production = {
    mode: 'production',
    devtool: 'cheap-module-source-map',
    plugins: [
        new WorkBoxPlugin.GenerateSW({ //  ⭐️  
            clientsClaim: true,
            skipWaiting: true
        })
    ]
}

module.exports = merge(common, production)

在入口文件 index.js 中:

// index.js
import $ from 'jquery'

if ('serviceWorker' in navigator) { // 检查浏览器是否支持 serviceWorker
    window.addEventListener('load', () => {
        navigator.serviceWorker.register('/service-worker.js').then(registration => {
            console.log('serviceWorker 注册成功')
        }).catch(error => {
            console.log('serviceWorker 注册失败')
        })
    })
}
$('#app').html('<h1>PWA 成功运行</h1>')

npm run build 之后:

build

为了演示,我们使用 http-server ,开启一个本地服务器:

npm install http-server --save-dev
npx http-server dist

代开浏览器,输入 http://127.0.0.1:8080/ ,页面正常运行。然后,在终端按下 Ctrl + c ,终止 http-server 进程,刷新浏览器,发现页面依然正常运行。

这就实现了 PWA 。

为了避免后面开发出现问题,我们先将本地相应网站的 serviceworker 注销:

注销

打包 TypeScript

避免冲突,我们新建一个项目。

首先安装:

npm install ts-loader typescript --save-dev

配置:

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

module.exports = {
    mode: "production",
    entry: './index.ts',
    output: {
        filename: "bundle.js",
        path: path.resolve(__dirname, 'bundle')
    },
    module: {
        rules: [
            {
                test: /\.ts$/,
                use: 'ts-loader',
                exclude: /node_modules/
            }
        ]
    }
}

创建入口文件:

// index.ts
let user: string = 'zhangsan'
console.log(user)

在根目录下创建 tsconfig.json :

{
  "compilerOptions": {
    "module": "es6",
    "target": "es5",
    "allowJs": true,
    "sourceMap": true
  },
  "exclude": [
    "node_modules"
  ]
}

npx webpack 打包之后,会在 bundle 目录下创建 bundle.js 。

打包完成。

如果需要在 typescript 中引入第三方库,需要安装该第三方库对应的类型描述文件,如引入 lodash :

首先需要安装 lodash 对应的类型文件:

npm install @types/lodash --save-dev

然后

// index.ts
import * as _ from 'lodash'

console.log(_.join(['hello', 'world'], '-'))

之后 typescript 就可以对 lodash 进行类型约束了。

你可以通过 TypeSearch 搜索 typescript 支持哪些第三方库。

本地开发代理

开发环境下,devServer 可以配置代理:

// webpack.config.js
...
devServer: {
    contentBase: './dist',
    // open: true,
    hot: true,
    hotOnly: true,
    proxy: {
        '/api': {
            target: "https://other-server.example.com",
            secure: false,
            changeOrigin: true,
            headers:{
                // ...
            }
        }
    }
}
...

则对 /api 的 ajax 请求会被转发到 'https://other-server.example.com'

更多配置,参考 devServer.proxyhttp-proxy options

devServer 实现单页面路由

单页面路由的功能,在 React、Vue 之类的框架中已经帮我们集成了。

但它们集成的路由功能是代码层面的,并不能阻止浏览器对路由的默认实现。比如你通过 VueRouter 配置:/home 对应 Home 组件,/about 对应 About 组件。这是代码层面的。但是浏览器的默认行为是到服务器上寻找/home/about 页面,当然找不到,于是就会报 404 。

然而当你使用 React、Vue 的脚手架工具创建一个项目的时候,在本地开发环境下似乎从没遇到过服务器路由方面的问题,这是因为这些脚手架已经帮你默认处理好了 devServer 上的相应配置。

我们来看一下它们是怎么配置的。

非常简单,只要加上 historyApiFallback 即可

// webpack.config.js
devServer: {
    contentBase: './dist',
    // open: true,
    hot: true,
    hotOnly: true,
    historyApiFallback: true //  ⭐️  
}

还可以传入一个对象

// webpack.config.js
...
historyApiFallback: {
  rewrites: [
    { from: /^\/$/, to: '/views/landing.html' },
    { from: /^\/subpage/, to: '/views/subpage.html' },
    { from: /./, to: '/views/404.html' }
  ]
}
...

更多选项和信息,查看 connect-history-api-fallback

生产环境下,需要后端配合,更改 Apache 或者 Nginx 相应配置。

ESLint

ESLint 用于规范代码。

安装

npm install eslint --save-dev

快速生成 eslint 配置文件

npx eslint --init

根据你选择的不同,根目录下会生成不同后缀名的配置文件:

// .eslintrc.js
module.exports = {
    "env": {
        "browser": true,
        "es6": true,
        "node": true
    },
    "extends": "eslint:recommended",
    "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
    },
    "parserOptions": {
        "ecmaVersion": 2018,
        "sourceType": "module"
    },
    "rules": {}
};

通过 npx eslint src/ (或 npx eslint index.js) 便可以检查 src 目录下的文件是否符合代码规范。

这里我们更改一下 eslint 的解析器为 babel-eslint :

// .eslintrc.js
module.exports = {
    ...
    "parser": "babel-eslint", //  ⭐️  
    "rules": {
        "semi": "error"
        ...
    }
}

当然,要先安装:

npm install babel-eslint --save-dev

到目前为止,eslint 与 webpack 都没有多大关系,不使用 webpack ,eslint 也可以正常使用,甚至配合 vs code 或者 webstorm 这样的 IDE ,可以直接在编辑器中发现代码中的错误。

但配合 webpack ,eslint 可以更方便。

安装 loader :

npm install eslint-loader --save-dev

配置:

// webpack.config.js
...
{
    test: /\.js$/,
    exclude: /node_modules/,
    use: ['babel-loader', 'eslint-loader']
}
...

这样,当你 npm run serve 时,便会自动进行 eslint 的检查。

你还可以在 devServer 中添加 overlay: true,让错误在浏览器中显示:

// webpack.config.js
devServer: {
    overlay: true, //  ⭐️  
    contentBase: './dist',
    // open: true,
    hot: true,
    ...
}

overlay

多页面打包

比如我们生成两个页面:index.html 和 about.html 。

则 webpack 配置:

// webpack.config.js
...
module.exports = {
    entry: {
        index: './index.js', //  ⭐️ index 页面的入口文件
        about: './about.js' //  ⭐️ about 页面的入口文件
    },
    ...
    plugins: [
        new HtmlWebpackPlugin({
            template: './index.html',
            filename: "index.html", //  ⭐️ 将生成的 html 文件名设为 index.html
            chunks: ['vendors', 'default', 'index'] //  ⭐️ chunks 配置需要在 index.html 引入的 js
        }),
        new HtmlWebpackPlugin({
            template: './index.html',
            filename: "about.html", //  ⭐️ 将生成的 html 文件名设为 about.html
            chunks: ['vendors', 'default', 'about'] //  ⭐️ chunks 配置需要在 about.html 引入的 js
        }),
        ...
    ],
    optimization: {
        splitChunks: {
            ...
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/, // 匹配 node_modules 目录下的模块
                    name: 'vendors', //  ⭐️  chunk 的名字为 vendors
                    priority: -10
                },
                default: {
                    reuseExistingChunk: true,
                    name: 'default', //  ⭐️  chunk 的名字为 default
                    priority: -20
                }
            }
        },
        minimizer: [new OptimizeCSSAssetsPlugin({})]
    }
}

webpack 性能优化

当我们的项目越来越大,webpack 打包的速度就会显得重要。

这里有一些提高 webpack 打包速度的方法。

  1. 及时更新 webpack 版本
  2. 少用一些可有可无的 loader
  3. 合理添加 exclude: /node_modules/
  4. 使用官方推荐的或者社区认可的 loader 和 plugin
  5. 使用 DllPlugin 插件 ⭐️
  6. 合理使用 sourceMap
  7. 合理的使用 resolve

大型项目中,DllPlugin 配合 DllReferencePlugin 提高打包速度用的比较多。但是内容较多,我写烦了,不想说了。请参考其他资源吧。


关于 resolve ,补充如下

resolve.extensions

webpack 中还有一个 resolve.extensions 属性,用于指定默认的文件后缀名:

// webpack.config.js
...
entry: {
    ...
},
output: {
    ...
},
resolve: {
    extensions: ['.js', '.jsx', '.ts', '.vue']  //  ⭐️ 
},
...

这样在 index.js 中就可以

import Test from './test' //  ⭐️  无需加上后缀 .js

resolve.alias

设置别名

// webpack.config.js
...
resolve: {
    extensions: ['.js', '.jsx', '.ts', '.vue'],
    alias: {
        '@': path.resolve(__dirname, 'src/'),
        'components': path.resolve(__dirname, 'src/components')
    }
},
...

这样在引用的时候,就可以直接使用 @components

// index.js
import Home from 'components/home/home.vue' //  ⭐️ 
...

终于写完了。😑