Webpack5详细教程-入门篇,带你搭建 Vue3 项目

3,775 阅读30分钟

导读

上一篇文章(Webpack5详细教程-导读篇)主要讲述了模块化规范和自动化构建工具发展历史,以及它们的优缺点。今天我们开始系统地对 Webpack5 一些核心特性进行讲解,文章后面也会带大家一起搭建一个 Vue3 项目。相关源代码已上传 Github:webpack-basic

本文 Webpack 相关版本如下:

  • webpack:5.62.1
  • webpack-cli:4.9.1

大家跟着一起动手实践吧~

Webpack使用指南

核心概念:构建依赖图

上一篇文章我们花了大篇幅讲述了自动化构建工具发展历程,以及它们产生的背景,这些自动化构建工具有一个共性:将源代码经过一系列操作之后得到宿主环境可识别代码

宿主环境可识别代码:宿主环境如浏览器平台,只认识 html/css/js/img 等资源,其他如 sass/jsx/vue 都不识别,需要特殊处理。

这个过程有的是分成一个个任务,有的则是“管道流”机制,而 Webpack 跟 “流” 这种机制很类似,它可以做到和自动化构建工具一样的工作,经过它的 loader 机制,处理完后得到目标代码。但它不被称为自动化构建工具的原因是,在 Webpack 眼里一切皆模块(js/css/img/...),从入口开始通过模块化找到其他依赖模块,依次构建,最后得到目标产物,而这个过程就是构建依赖图的过程。

image.png

入口/出口(entry/output)

这节开始我们便正式进入动手环节,首先我们需要创建一个空项目 webpack-basic,安装 webpackwebpack-cli

mkdir webpack-basic
cd webpack-basic
yarn init -y # 或者 npm init -y
yarn add webpack webpack-cli -D # 或 npm i webpack webpack-cli -D

接下来我们创建以下目录结构:

├── package.json
├── src
|  ├── index.js
|  └── js
|     └── createTitle.js
└── yarn.lock

其中 createTitle.js 代码如下:

export default (content) => {
  const h2 = document.createElement('h2')
  h2.innerText = content
  return h2
}

index.js导入createTitle.js模块:

import createTitle from './js/createTitle'

const h2 = createTitle('hello webpack')

document.body.appendChild(h2)

从上面依赖图可以看出, webpack 会从入口开始,然后建立依赖图,最后经过处理之后得到一个目标产物,默认情况下如果我们不做任何配置,webpack 默认会找到 src/index.js,并且输出到 dist/main.js,我们可以使用 yarn webpack (或npx webpack)进行测试。

image.png

从上图可以看到,我们在不做任何配置的前提下,打包是正常的。不过控制台会有一个提示告诉我们 mode 没有配置,并且在默认情况下被设置为了 production 模式,这个是 webpack5 新加的一个提示,后续我们再讲解。

假如我们需要自定义入口文件,以及输出目录名称,该怎么做呢?

我们可以在项目根目录下创建一个 webpack.config.js,当然你也可以自定义名称然后在命令行配置一下 config 参数,这个我们后续会讲到。

const path = require('path')
module.exports = {
  entry: {
    main: './src/main.js',
  },
  output: {
    filename: 'js/[name].[fullhash:8].bundle.js',
    path: path.resolve('output'),
  },
}

entry 也可以配置成字符串形式,表示单入口打包,而在上面配置中它被配置成了一个对象,key 就是我们要打包的文件名称,value 是一个相对路径,它相对的是 process.cwd() 的目录,也就是我们执行 webpack 这个命令所在的目录,当然如果不配置 key 的话可以使用数组。

output 用于配置出口,主要是有两个属性:filename 用于配置输出文件的名称,可以使用/来增加目录;path 是输出文件所在的目录,一般都是绝对路径。

fullhash:8 表示输出文件的哈希位数为8位,在以前的版本是 hash ,还可以配置 contenthashchunkhash 等值,主要是为了更好的缓存。

此时我们在项目再创建一个index.html文件,放在根目录下的 public 目录,引入打包文件就可以看到结果了(我这里使用的是 VSCode Live Server 插件,也可以使用 serve 工具预览查看效果)。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="../output/js/main.0b48a4fc.bundle.js"></script>
  </body>
</html>

当然,我们实际项目当然不止 js 文件,还有样式、图片、ts、vue等模块,还需要将高版本 js 处理成兼容性良好的低版本 js 代码,这个时候就要借助 webpack 提供一个核心功能:loader。

它本质上就是一个函数,接受字符串/buffer数据,然后经过处理返回 js字符串/buffer,后续我们会专门对 loader 进行系统化讲解,下面是一些常见的 loader 使用场景介绍。

样式处理

实际开发中我们大部分都会使用到 CSS 预处理和后处理工具,如:sass/less/stylus/postcss,而要想利用这些工具构建我们的代码就需要 loader 处理。首先我们来创建一个styles目录存放样式文件:

    ├── package.json
    ├── public
    |  └── index.html
    ├── src
    |  ├── js
    |  |  └── createTitle.js
    |  ├── main.js
+   |  └── styles
+   |     ├── global.css
+   |     ├── title.css
+   |     └── title.less
    ├── webpack.config.js
    └── yarn.lock

title.less

@fontColor: #1a5f0611;

h2 {
  font-size: 20px;
  color: @fontColor;
}

title.css

h2 {
  display: grid;
  transition: all 0.2s;
}

global.css

@import './title.css';

body {
  background: orange;
}

接下来安装所需要的依赖包:

yarn add style-loader css-loader postcss-loader less-loader less postcss -D

注意:less-loader 可以处理 less 文件,但是编译需要借助 less ,同样 postcss-loader 也依赖 postcss。下面是 loader 配置:

    const path = require('path')
    module.exports = {
      entry: {
        main: './src/main.js',
      },
      output: {
        filename: 'js/[name].bundle.js',
        path: path.resolve(__dirname, 'output'),
      },
+     module: {
+       rules: [
+         {
+           test: /\.css$/,
+           use: ['style-loader', 'css-loader', 'postcss-loader'],
+         },
+         {
+           test: /\.less$/,
+           use: ['style-loader', 'css-loader', 'postcss-loader', 'less-loader'],
+         },
+       ],
+     },
    }

注意点:

  • 所有 loader 都在 module.rules 进行配置,该选项是一个对象数组。
  • 规则配置使用正则表达式匹配文件。
  • use 可以是字符串、数组、对象,用于对 loader 进行配置。
  • loader 应用规则是从右到左。

配置完成后进行打包,发现页面是能正常应用样式的,说明我们 loader 是应用成功的:

image.png

虽然样式成功编译了,但是好像 postcss 并没有工作,这是为啥呢?

下面我介绍一下上述配置文件工作的流程:

  • 首先在入口 main.js 导入了 lesscss 文件,webpack 并不认识这些模块,接着去查找 loader 去处理。

  • 匹配 less-loader,处理 less 文件,内部使用 less 编译样式,最后输出 JS 字符串,交给下一个 loader 处理。

  • 匹配 postcss-loader,处理编译好的样式,内部使用 postcss 并且查找插件,发现并未配置插件,不处理,返回 JS 字符串,交给下一个 loader 处理。

  • 匹配 css-loader,解析文件中的@import and url(),处理完成后返回 JS 字符串。

  • 匹配 style-loader,创建 style 标签,将样式添加到里面。

可以看到应用postcss时,并未添加插件,所以我们需要安装相关插件。我们的需求是,希望添加一些兼容性的CSS前缀,而且想对八位十六进制颜色这种 CSS 新语法进行处理(有很多浏览器不识别),这时候可以借助 postcss-preset-env 来做这个事情。

先安装:

yarn add postcss-preset-env -D

然后在 webpack 进行配置:

    const path = require('path')
    module.exports = {
      entry: {
        main: './src/main.js',
      },
      output: {
        filename: 'js/[name].bundle.js',
        path: path.resolve(__dirname, 'output'),
      },
      module: {
        rules: [
          {
            test: /\.css$/,
            use: [
              'style-loader',
              'css-loader',
-             'postcss-loader',
+             {
+               loader: 'postcss-loader',
+               options: {
+                 postcssOptions: {
+                   plugins: [require('postcss-preset-env')],
+                 },
+               },
+             },
            ],
          },
          {
            test: /\.less$/,
            use: [
              'style-loader',
              'css-loader',
-             'postcss-loader',
+             {
+               loader: 'postcss-loader',
+               options: {
+                 postcssOptions: {
+                   plugins: [require('postcss-preset-env')],
+                 },
+               },
+             },
              'less-loader',
            ],
          },
        ],
      },
    }

此时我们再次打包,可以看到样式已经兼容成了:color: rgba(26,95,6,0.06667);

当然我更喜欢单独拆分成一个文件来配置,这样更好去管理项目:

postcss.config.js

module.exports = {
  plugins: [
    require('postcss-preset-env')
  ]
}

但是我们从编译后结果可以看到,相关的 css 前缀并未添加:

image.png

这又是为何?

我们现在知道了 postcss 可以利用插件来处理兼容性,但是要兼容哪些平台呢?这个虽然可以单独配置,但是我想介绍一下 browserslist ,它可以告诉 postcss 去兼容哪些平台,不仅如此,它还可以为 babel 提供兼容平台参考,所以只要有需要提供兼容平台的相关构建工具都可以使用这个文件。

而且,Webpack 在安装的同时也会安装 browerslist 这个包,我们只需要配置一下就可以了。在根目录下面创建 .browserslistrc :

> 0.01% # 市场占有率超过0.01%的浏览器
last 2 version # 最近两个版本的浏览器
not dead # 未停止更新,还活着

browerslist 会利用Can I use的数据来筛选一些浏览器,这里我为了测试兼容性,把占有率设置成了 0.01% ,这是因为现在大多浏览器兼容性已经很好了,不过实际项目不推荐,会带来性能开销。如果要查看更多的配置用法,可以查看browerslist官方文档

我们可以使用 yarn browserslist查看匹配了哪些浏览器。

此时再次打包按理来说,应该能看到效果了,但是其实并没有效果,这又是为何?我们再回看一下 global.css 内容:

@import './title.css';

body {
  background: orange;
}

文件使用了 @import 导入css模块,而这个规则在 css-loader 才会去解析,而我们的 postcss-loader 早就处理完了,也就是说 css-loader 不可能再走“回头路”了,这该怎么办?

解决方法也很简单,对 css-loader 进行配置:

    const path = require('path')
    module.exports = {
      // ... entry/ouput
      module: {
        rules: [
          {
            test: /\.css$/,
            use: [
              'style-loader',
-             'css-loader',
+             {
+               loader: 'css-loader',
+               options: {
+                 importLoaders: 1,
+               },
+             },
              'postcss-loader',
            ],
          },
          {
            test: /\.less$/,
            use: [
              'style-loader',
-             'css-loader',
+             {
+               loader: 'css-loader',
+               options: {
+                 importLoaders: 1,
+               },
+             },
              'postcss-loader',
              'less-loader',
            ],
          },
        ],
      },
    }

这个 importLoaders 设置为 1 指的是往后找一个 loader 进行处理,如果再后面其他位置如后两位,就设置 2 。到这为止,我们的样式处理就成功了,下面是成功后的效果:

image.png

图片/字体处理

实际开发中,我们最常见使用图片的场景一般有两种:

  • img 标签引入图片。
  • css 背景图片。

在 Webpack 4.x 版本中,我们处理图片通常使用的是 url-loaderfile-loader

  • file-loader:将文件发送到输出文件夹,并返回(相对)URL。
  • url-loader: 像 file loader 一样工作,但如果文件小于限制,可以返回 data URL
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|svg|gif|jpe?g)$/,
        use: [
          {
            loader: 'url-loader', // 内部会使用 file-loader
            options: {
              name: 'img/[name].[fullhash:6].[ext]', // 自定义文件输出名称
              limit: 4 * 1024, // 图片小于 4kb 转换成 base64
            },
          },
        ],
      },
      {
        test: /\.(ttf|woff2?)$/,
        use: 'file-loader'
      },
    ],
  },
}

而在 Webpack 5,我们不必再安装这两个 loader 了,它提供了一个新特性 type ,可以配置资源模块类型,它有四个值:

  • asset/resource 替代 file-loader ,发送一个单独的文件并导出URL。
  • asset/inline 替代 url-loader,导出 data URL
  • asset/source替代 raw-loader,导出资源源代码。
  • asset 代替 url-loader ,可以根据文件大小决定是输出 data URL 还是发送单独文件。

上面的配置可以改造成下面这样:


module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|svg|gif|jpe?g)$/,
        type: 'asset',
        generator: {
          filename: 'img/[name].[fullhash:4][ext]',
        },
        parser: {
          dataUrlCondition: {
            maxSize: 4 * 1024,
          },
        },
      },
      {
        test: /\.(ttf|woff2?)$/,
        type: 'asset/resource',
        generator: {
          filename: 'font/[name].[fullhash:4][ext]',
        },
      },
    ],
  },
}

自定义输出资源名称还可以在 output.assetModuleFilename 进行配置,不过个人建议还是给每个资源进行单独配置。

下面我们改造一下代码,让项目支持图片处理。

目录结构:

    ├── package.json
    ├── postcss.config.js
    ├── public
    |  └── index.html
    ├── src
+   |  ├── img
+   |  |  ├── bg.png # > 4kb
+   |  |  └── vue.png # < 4kb
    |  ├── js
+   |  |  ├── createImg.js
    |  |  └── createTitle.js
    |  ├── main.js
    |  └── styles
    |     ├── global.css
    |     ├── title.css
    |     └── title.less
    ├── webpack.config.js
    └── yarn.lock

createImg.js

export default (content) => {
  const img = document.createElement('img')
  img.src = require('../img/vue.png')
  return img
}

global.css

    @import './title.css';

    body {
      background: orange;
+     background-image: url('../img/bg.png');
    }

main.js

+   import createImg from './js/createImg'
    import createTitle from './js/createTitle'
    import './styles/global.css'
    import './styles/title.less'

    const h2 = createTitle('hello webpack')
+   const img = createImg()

    document.body.appendChild(h2)
+   document.body.appendChild(img)

此时再次打包,图片就可以正常处理了:

  • 大于 4kb 的图片直接输出到目录。
  • 小于 4kb 的图片输出 data URL

image.png

注意:要保证安装的 webpackcss-loader 最新版本的 ,否则会出现 [object Object] 的情况,这是因为在某些 5.x 版本中通过 require 引入的图片默认是以 ESM 导出的,而在某些版本的 css-loader 处理 url 引入的图片也是以 ESM 导出的图片,这时候如果出现问题可以进行以下处理:

  • 通过 require 导入的图片可以改成 import 导入,或者在 require 之后加上 default
// 方法一:import logo from '../img/vue.png'
export default (content) => {
  const img = document.createElement('img')
  // 方法一:img.src = logo
  img.src = require('../img/vue.png').default
  return img
}
  • css 通过 url 引入的背景图片,需要配置 css-loader :
    module.exports = {
      module: {
        rules: [
          {
            test: /\.css$/,
            use: [
              'style-loader',
              {
                loader: 'css-loader',
                options: {
                  importLoaders: 1,
+                 esModule: false
                },
              },
              'postcss-loader',
            ],
          },
          {
            test: /\.less$/,
            use: [
              'style-loader',
              {
                loader: 'css-loader',
                options: {
                  importLoaders: 1,
+                 esModule: false
                },
              },
              'postcss-loader',
              'less-loader',
            ],
          },
        ],
      },
    }

脚本文件处理

在项目中使用 TypeScriptES6+ 是很常见的需求,所以我们可以借助 babel 来帮我们一次性搞定。安装以下包:

  • @babel/core:babel 核心库。
  • @babel/preset-env:一些常见的语法转换插件集合。
  • @babel/preset-typescript:TypeScript 转换插件。
  • babel-loader。
  • typescript:支持 TS 类型校验功能。

注意: 也可以使用 ts-loader 来处理,但是速度会慢一些,原因是使用 ts-loader 之后可能还是需要 babel 去编译一次,流程就变成了TS > TS 编译器 > JS > Babel > JS (再次)。使用 @babel/preset-typescript 只需要管理一个编译器即可。

yarn add  @babel/core @babel/preset-env @babel/preset-typescript babel-loader typescript -D

配置 loader :

    module.exports = {
      mode: 'development', // 开启开发模式打包,方便待会查看打包后代码。
      devtool: false, // 关闭默认的 eval 代码块。
      resolve: {
        extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
      }, // 文件省略后缀时的查找规则,默认只能查找 `.js`、`.json` 类型文件。
      module: {
        rules: [
          {
            test: /\.(js|ts)x?$/,
            exclude: /node_modules/, // 排除 node_modules 检测
            use: [
              {
                loader: 'babel-loader',
                options: {
                  cacheDirectory: true, // 开启目录缓存功能
                },
              },
            ],
          },
        ],
      },
    }

上面多了一些前面未提及的配置,主要是为了待会说明打包后代码,大家可以看看注释。

在 Babel 中 集成 TS 插件并不具有类型检测功能,所以需要单独配置一个检测命令在打包前进行类型检查(也可以利用 ESLint + VSCode 强大的检测能力,后续会提到)。

  • 初始化 tsconfig.json
yarn tsc --init

修改 tsconfig.json 配置:

{
    "compilerOptions": {
        // 不输出文件
        "noEmit":true 
    }
}

配置一下脚本:

{
    "scripts": {
        "check-type": "tsc"
    }
}

有了这些准备之后,接下来进行下面的操作进行测试:

  • src 目录下所有 js 后缀文件改成 ts,其中 main.ts 增加一个由 Promise 封装的 sleep 函数:
import createImg from './js/createImg'
import createTitle from './js/createTitle'
import './styles/global.css'
import './styles/title.less'

function sleep(time = 1) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('done')
    }, time * 1000);
  })
}

const h2 = createTitle('hello webpack')
const img = createImg()

document.body.appendChild(h2)
document.body.appendChild(img)
sleep().then(console.log)

  • webpack.config.js 中的 entry 改成 main.ts
  • 根目录下创建 babel.config.js 导入预设插件。
module.exports = {
  presets: [['@babel/preset-env'], ['@babel/preset-typescript']],
}

然后打包,这时候可以正常编译 ts 了,但是我们打开编译后源码后文件搜索 Promise 它依然使用的是原生的 API,这是因为预设并不能实现一些新的特性 ,如 Promise、Map、Set 、Generator 等 API,这时候就需要借助 polyfill 来实现,在 babel 7.4.0 以前,是默认把 polyfill 加进来的,但是这样会导致包的体积增大,所以需要额外的配置。如果需要在某些文件下使用这些 API 的 polyfill 需要导入两个包:

  • core-js 3:实现一些新特性的 polyfill。
  • regenerator-runtime:Generator API 的实现。

在使用的模块导入:

import "core-js/stable";
import "regenerator-runtime/runtime";

当然,我们也可以在 babel.config.js 中进行配置,然后根据 .browserslist 中的目标浏览器实现,这里只需要安装一下最新版本的 core-js 就可以了,它会自动导入上面的两个包。

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        useBuiltIns: 'usage',
        corejs: 3,
      },
    ],
    ['@babel/preset-typescript'],
  ],
}

useBuiltIns 有三个值:

  • usage:找到源代码使用最新 ES 新特性的地方,然后根据 broswserslist 配置的目标平台进行填充。
  • false : 默认。啥也不干。
  • entry:找到 broswserslist 所有目标平台进行填充。

此时我们再次打包,此时就能看到构建后的代码 Promise 实现了,如果没有效果查看 .browserslist 市场占有率是否配置成 > 0.01% (这里配置这么低主要是为了测试)。

image.png

pluginWebpack 最强大的功能,它也是 Webpack 的核心,后续的文章我也会着重介绍,下面主要是给大家介绍插件的使用。

html-webpack-plugin 使用

前面我们打完包后,需要将 js 文件引入到 html 才可以使用,而 html-webpack-plugin 很好地解决了这个问题,它可以将脚本自动注入 html 文件 ,我们也可以自定义一个模板,它支持 ejs 语法。

public/index.html

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
</html>

安装:

yarn add html-webpack-plugin -D

在 webpack 配置 plugins 属性:

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  // 其他配置...
  plugins: [
    new HtmlWebpackPlugin({
      title: 'hello webpack',
      template: path.resolve(__dirname, './public/index.html'),
    }),
  ],
}

此时我们再次打包就能看到输出目录多了一个 index.html 文件,而且脚本也自动注入了。

clean-webpack-plugin 使用

每次打包后我们都需要手动清除 dist 就很麻烦(配置 hash 每次都不一样),这个插件一般是和上面插件配套的,它在每次打包输出目录前会删除以前的文件。

安装使用:

yarn add clean-webpack-plugin -D

配置:

const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
    // 其他配置...
    plugins: [new CleanWebpackPlugin()],
}

开启 devServer

前文我们修改一次源代码就需要手动打包一次很是麻烦,而且实际开发中我们需要本地开发服务器去预览我们的页面效果,除此之外我们还需要在本地解决跨域问题,所以我们可以使用 webpack-dev-server 去解决。

安装 webpack-dev-server

yarn add webpack-dev-server -D

webpack.config.js

const path = require('path')
module.exports = {
  // 其他配置...
  devServer: {
    static: path.resolve(__dirname, 'public'), // 设置静态服务器目录
    hot: 'only', // 防止 error 导致整个页面刷新
    compress: true, // 开启本地服务器 gzip 压缩
    historyApiFallback: true, // 防止 history 路由刷新后空白
    // 配置接口代理
    proxy: {
      '/api': {
        target: 'https://api.github.com',
        pathRewrite: { '^/api': '' },
        changeOrigin: true,
      },
    },
  },
}

注意:在某些 webpack v5.x 版本中,开发服务器提供的静态目录配置是 contentBase,而 hot: 'only' 则是 hotOnly: true

然后配置脚本:

package.json

{
  "scripts": {
    "dev": "webpack serve"
  },
}

接着在 main.ts 增加一个请求代码:

    import createImg from './js/createImg'
    import createTitle from './js/createTitle'
    import './styles/global.css'
    import './styles/title.less'

    function sleep(time = 1) {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve('done')
        }, time * 1000);
      })
    }

+   async function fetchData(url: string) {
+     return (await fetch(url)).json()
+   }

    const h2 = createTitle('hello webpack')
    const img = createImg()

    document.body.appendChild(h2)
    document.body.appendChild(img)
    sleep().then(console.log)

+   fetchData('/api/users').then(console.log).catch(console.log)

此时我们只需要使用 yarn dev 就可以了,如果能在浏览器控制台看到打印的数据就说明代理配置成功了。

image.png

注意:webpack-dev-server v4 版本之后默认启动 HMR (HotModuleReplacement 热模块替换,一种不需要刷新页面只需要按需更新的机制),我们上面配置 hot: 'only' 主要是防止错误会导致浏览器刷新的情况。

source-map

source-map 是开发阶段必备的一个功能(由谷歌浏览器提供),它可以帮我们去定位错误源代码的位置。在 webpack 中可以通过 devtool 进行配置,主要有以下几大类:

  • eval:使用 eval 包裹模块代码,通过在 eval 包裹的模块末尾添加//# sourceURL来找到原始代码位置,不产生 .map 文件,定位的是经过 babel-loader 处理后的代码。
  • source-map: 未经 loader 处理的源代码(完整行列信息),产生.map文件,并在打包后文件末尾加上 //# sourceMappingURL=main.bundle.js.map 来引入 map 文件。
  • cheap-source-map:经过 loader 处理后的源代码。
  • cheap-module-source-map:未经loader处理的源代码,只有行信息。
  • inline-cheap-source-map: 将.map作为DataURL嵌入,不单独生成.map文件,经过 loader 处理后的源代码,只有行信息。
  • inline-cheap-module-source-map: 将.map作为 DataURL 嵌入,不单独生成.map文件,未经过 loader 处理后的源代码,只有行信息。

后面还有其他的类型,不过大体看下来无非就是就是[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map模式的组合,详细信息可以查阅文档,根据需求进行定制。

模式与环境变量

前面我们提到了 mode 选项,它是用于区分 webpack 中打包模式的,不同的模式下会做不同的优化。主要有三个值:

  • development:会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 development. 为模块和 chunk 启用有效的名。
  • production:会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 production。为模块和 chunk 启用确定性的混淆名称,并设置一些优化插件如 TerserPlugin 。
  • none:不使用任何优化选项。

详细配置说明可以查看webpack中文文档

这就是为啥我们经常能在 webpack 搭建的项目中可以直接使用 process.env.NODE_ENV 的原因了,我们也可以自己注入环境变量,比如 html 文件中的 favicon.ico 文件的 BASE_URL 变量:

<link rel="icon" href="<%= BASE_URL %>favicon.ico" />

可以这么配置:

  plugins: [
    new HtmlWebpackPlugin({
      title: 'hello webpack',
      template: path.resolve(__dirname, './public/index.html'),
    }),
    new DefinePlugin({
      BASE_URL: JSON.stringify(''),
    }),
  ],

注意:注入变量的值必须是 JS 字符串。

区分打包环境

有了模式和环境变量,我们就可以区分打包环境了,我们希望:

  • 开发环境提供开发服务器、HMR、开发调试功能。
  • 生产环境提供优化等功能。

下面我们将对配置文件进行拆分,然后利用 webpack-merge 这个包进行配置合并(不演示安装了)。

根目录新建以下目录结构:

├── config
|  ├── utils.js # 一些通用方法
|  ├── webpack.common.js # 公共配置
|  ├── webpack.dev.js # 开发环境配置
|  └── webpack.prod.js # 生产环境配置

我们先来抽离出一些常用的路径:

utils.js

const path = require('path')

// 工作目录
const WORK_PATH = process.cwd()

// 解析路径
function resolvePath(target) {
  return path.join(WORK_PATH, target)
}

module.exports = {
  SRC_PATH: resolvePath('src'),
  OUTPUT_PATH: resolvePath('dist'),
  PUBLIC_PATH: resolvePath('public'),
  WORK_PATH,
  resolvePath,
}

webpack.common.js

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { DefinePlugin } = require('webpack')
const { OUTPUT_PATH, PUBLIC_PATH } = require('./utils')

module.exports = {
  entry: {
    main: './src/main.ts',
  },
  output: {
    filename: 'js/[name].bundle.js',
    path: OUTPUT_PATH,
  },
  resolve: {
    extensions: ['.js', '.json', '.ts', '.vue'],
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
            },
          },
          'postcss-loader',
        ],
      },
      {
        test: /\.less$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
            },
          },
          'postcss-loader',
          'less-loader',
        ],
      },
      {
        test: /\.(png|svg|gif|jpe?g)$/,
        type: 'asset',
        generator: {
          filename: 'img/[name].[fullhash:4][ext]',
        },
        parser: {
          dataUrlCondition: {
            maxSize: 4 * 1024,
          },
        },
      },
      {
        test: /\.(ttf|woff2?)$/,
        type: 'asset/resource',
        generator: {
          filename: 'font/[name].[fullhash:4][ext]',
        },
      },
      {
        test: /\.(js|ts)x?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              cacheDirectory: true,
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'hello webpack',
      template: path.join(PUBLIC_PATH, 'index.html'),
    }),
    new DefinePlugin({
      BASE_URL: JSON.stringify(''),
    }),
  ],
}

webpack.dev.js

const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const { PUBLIC_PATH } = require('./utils')

module.exports = merge(baseConfig, {
  mode: 'development',
  devtool: 'cheap-module-source-map',
  devServer: {
    static: PUBLIC_PATH,
    hot: 'only', // 防止 error 导致整个页面刷新
    compress: true, // 开启本地服务器 gzip 压缩
    historyApiFallback: true, // 防止 history 路由刷新后空白
    // 配置接口代理
    proxy: {
      '/api': {
        target: 'https://api.github.com',
        pathRewrite: { '^/api': '' },
        changeOrigin: true,
      },
    },
  },
})

webpack.prod.js

const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = merge(baseConfig, {
  mode: 'production',
  plugins: [new CleanWebpackPlugin()],
})

此外,如果我们需要给 webpack 传递操作系统级别的环境变量可以通过 cross-env 这个包来帮我们处理,它和 DefinePlugin 的区别在于一个是运行时使用,一个是编译时使用。

安装:

yarn add cross-env -D

完成配置后,还需要变更一下脚本,因为配置文件已经不在根目录了:

{
  "scripts": {
    "dev": "cross-env NODE_ENV=development webpack serve --config config/webpack.dev.js",
    "build": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
  },
}

有了上述配置之后,接下来我们就可以很方便地对不同环境进行配置了。

代码拆分

在上面我们不管是什么模块最终都打包到一个文件中,这样看似 http 请求减少了,但是这个文件将变得十分庞大,而且在网络传输上也会带来一定的延时,所以需要进行代码拆分的操作。

使用多入口

代码拆分最简单的方式就是配置多入口,webpack 会为每个入口单独打包一个文件。

webpack.common.js

module.exports = {
  entry: {
    main: './src/main.ts',
    main2: './src/main.ts',
  },
}

此时就能看到输出目录多了一个 main2.bundle.js 的文件。在此基础上,假如两个入口都依赖了第三方模块,我们希望第三方依赖打包到其他文件,就可以这么配置:

webpack.common.js

module.exports = {
  entry: {
    // 依赖共享模块 
    main: { import: './src/main.ts', dependOn: 'shared' },
    // 依赖共享模块
    main2: { import: './src/main.ts', dependOn: 'shared' },
    shared: ['lodash-es'],
  },
}

需要安装 lodash-es @types/lodash-es 两个包,然后在入口文件导入就可以测试了。

此时就能发现打包后目录变成这样了:

image.png

那个 shared.bundle.js 就是 lodash-es 这个包抽离出来的,并且生成了一个 LICENSE 文件,这个是使用开源库的版本说明,原因是我开启了 production 模式打包,它会自动生成,如果不需要的话可以进行以下配置:

webpack.prod.js

const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const Terser = require('terser-webpack-plugin') // webpack v5 自动安装

module.exports = merge(baseConfig, {
  mode: 'production',
  optimization: {
    minimizer: [
      new Terser({
        extractComments: false,
      }),
    ],
  },
})

注意:跟优化有关的配置都在 optimization 这个配置中集中配置。

配置 splitChunks

上面那种方式并不常见,而对于拆包分 chunks 使用 splitChunks 尤为常见。我们参照 VueCLI 打包后输出目录进行配置,VueCLI 输出目录会有三大类型的 chunk:

  • 第三方依赖,chunk-vendors。
  • 主入口 chunk。
  • 路由懒加载 chunk。

image.png

当然可能还会有一些公共模块,这个也比较常见,下面我们来对配置详细说明。

const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')

module.exports = merge(baseConfig, {
  optimization: {
    splitChunks: {
      chunks: 'all', // 支持同步/异步导入的模块,有三个值:initial(同步)、async (异步)、all(所有)
      minSize: 20000, // 生成 chunk 最小体积,单位字节
      minChunks: 1, // 这个模块至少被导入一次就分包
      cacheGroups: {
        // 分组一:针对第三方依赖,会继承前面的配置
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10, // 当同时匹配到两个分组时设置的优先级,值越大优先级越大
          filename: 'js/chunk-vendors.[fullhash:8].js',
        },
        // 分组一:针对公共模块,会继承前面的配置
        default: {
          minChunks: 2, // 模块被导入两次进行分包
          priority: -20,
          filename: 'js/[name]-common.[fullhash:8].js',
        },
      }, // 提取 chunk 分组
    },
  },
})

更详细的配置可以查看文档,传送门 -> optimization.splitChunks

import() + webpackChunkName

在 SPA 应用中通常有路由的功能,我们一般都会配置路由懒加载,这样只有跳转到对应路由的时候才会去加载 js 文件,这个功能可以使用 import() 函数实现。

我们把之前 main.ts 中请求数据的代码拆分到 js/fetch.ts 目录下面,然后改成动态导入:

;(async () => {
try {
  await sleep(2)
  const { default: fetchData } = await import('./js/fetch')
  const data = await fetchData('/api/users')
  console.log(data);

} catch (error) {
  console.log(error);
}
})()

我们再次打包,可以看到生成了一个 335.bundle.js 文件,这个名称有点古怪,这个数字 335 我们之前并未配置过,它是怎么生成的?这就要说到一个 chunkIds 属性了,它是决定 chunk 在输出文件名时选择的算法,常见的值有:

  • deterministic:默认值,在不同的编译中不变的短数字 id。有益于长期缓存。
  • natural:生产自然数字,1、2、3...。
  • named:根据 chunk 源文件目录生成有意义的字符串。

比如我们配置 named

webpack.prod.js

module.exports = merge(baseConfig, {
  mode: 'production',
  optimization: {
    chunkIds: 'named',
})

此时会生成一个有意义的名称:

image.png

当然我们也可以配置自定义 chunk 名称,可以通过 output.chunkFilename 配置:

webpack.common.js

module.exports = {
  output: {
    chunkFilename: 'js/chunk-[name].[fullhash:8].js',
  },
}

占位符 name 依然会采取前面提到的算法进行生成。当然如果你觉得这种方式还是不够人性化,可以在 import() 内部通过魔法注释进行自定义设置:

main.ts

;(async () => {
try {
  await sleep(2)
  const { default: fetchData } = await import(/* webpackChunkName: 'fetch' */'./js/fetch')
  const data = await fetchData('/api/users')
  console.log(data);

} catch (error) {
  console.log(error);
}
})()

此时 webpack 会根据 output.chunkFilename 和 魔法注释的名称生成我们需要的 chunkName 了。

image.png

开启 runtimeChunk

runtimeChunk 可以将 webpack 加载模块的代码(webpack为了兼容多个模块化规范实现了自己的模块加载方式)单独抽离出来,可以增强浏览器缓存能力,缺点就是多了一次请求。

webpack.prod.js

module.exports = merge(baseConfig, {
  mode: 'production',
  optimization: {
    runtimeChunk: true,
  }
}

构建优化

提取 css 到单独的文件

前面我们的样式最后是通过 style-loader 创建了 style 标签,将样式内联在了 html 中,这样做好处就是减少了请求,但是一旦文件变大也会对性能造成影响,我们可以借助 mini-css-extract-plugin 来抽离样式到单独的文件,此外我们希望在开发阶段还是使用 style-loader,生成模式下才提取文件,可以利用前文提到的 cross-env 传递的环境变量进行判断。

webpack.common.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const isProd = process.env.NODE_ENV === 'production'

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          isProd ? MiniCssExtractPlugin.loader : 'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
            },
          },
          'postcss-loader',
        ],
      },
      {
        test: /\.less$/,
        use: [
          isProd ? MiniCssExtractPlugin.loader : 'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
            },
          },
          'postcss-loader',
          'less-loader',
        ],
      },
    ],
  },
}

webpack.prod.js

const { merge } = require('webpack-merge')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = merge(baseConfig, {
  mode: 'production',
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[fullhash:6].css',
    }),
  ],
})

此时打包后应该是可以看到多了一个 css 目录(yarn build),但是 css 代码并没有压缩,我们可以借助 css-minimizer-webpack-plugin 这个插件来搞定。

webpack.prod.js

const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const Terser = require('terser-webpack-plugin')
const CssMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin')

module.exports = merge(baseConfig, {
  mode: 'production',
  optimization: {
    runtimeChunk: true,
    // 这个选项专门配置代码压缩
    minimizer: [
      // 压缩 js
      new Terser({
        extractComments: false,
      }),
      // 压缩 css
      new CssMinimizerWebpackPlugin(),
    ],
  },
})

资源预获取/预加载(preload/prefetch)

  • prefetch(预获取):将来某些导航下可能需要的资源,在浏览器空闲时下载,对于用户来说是无感的,推荐使用。
  • preload(预加载):当前导航下可能需要资源,随其他 chunk 并行下载,如果 chunk 很大的话可能会影响性能,不推荐。

前面我们使用 import() 函数来实现动态加载 chunk,我们再看看这段代码:

;(async () => {
try {
  await sleep(2)
  const { default: fetchData } = await import(/* webpackChunkName: 'fetch' */'./js/fetch')
  const data = await fetchData('/api/users')
  console.log(data);

} catch (error) {
  console.log(error);
}
})()

fetch 这个 chunk 会等待 2s 后才会去请求 js 文件,来达到懒加载的目的。而我们可以利用 prefetch 的特性,提前去加载这个资源,因为将来可能会用到这个 chunk,它会在浏览器空闲时加载,不会影响用户体验。

开启 prefetch

;(async () => {
try {
  await sleep(2)
  const { default: fetchData } = await import(
    /* webpackChunkName: 'fetch' */
    /* webpackPrefetch: true */
    './js/fetch')
  const data = await fetchData('/api/users')
  console.log(data);

} catch (error) {
  console.log(error);
}
})()

打包后,会在浏览器添加一个 link 标签,表示该资源将在浏览器空闲时加载:

<link rel="prefetch" as="script" href="http://127.0.0.1:5501/webpack-basic/dist/js/../js/chunk-fetch.56cdf1d3.js">

TreeShaking

webpack 中文文档的翻译解释 TreeShaking :

你可以将应用程序想象成一棵树。绿色表示实际用到的 source code(源码) 和 library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。

它是一种通过 ES Module 静态模块解析的特性来对代码的一种优化手段,最早由 Rollup 这个工具带来的概念。在 webpack v5 版本中已经开始支持 CommonJs Tree Shaking 了,而且在 mode: 'production' 模式下默认启动 TreeShaking 。如果要演示这个功能可以在开发模式下进行配置,这里涉及到两个很重要的配置:

  • usedExports:标记未引用的代码 -> 找到枯萎的树叶;开启代码压缩功能后移除未引用代码 -> 对树使劲踹了一脚,让枯萎的树叶掉下。
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const Terser = require('terser-webpack-plugin')

module.exports = merge(baseConfig, {
  mode: 'development', // 手动体验 treeshaking
  devtool: false, // 去除 eval 包裹的代码,方便查看代码
  optimization: {
    usedExports: true, // 标记未使用成员
    minimize: true, // “摇”掉未使用成员,并使用下面提供的插件压缩代码
    minimizer: [
      new Terser({
        extractComments: false,
      }),
    ],
    },
  },
})
  • sideEffects:标记代码有无副作用,和 usedExports 不冲突,主要用于安全删除代码。它和 usedExports 区别在于,如果有 sideEffects 被标记成有副作用的代码是不会把“枯萎”的树叶“摇”掉的。可以在 package.json 中进行配置:
{
    "sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}

介绍完后我们做个小测试,将之前 main.ts 中的函数全部抽离到 js/utils.ts 文件中:

export async function fetchData(url: string) {
  return (await fetch(url)).json()
}

export function createTitle(content: string) {
  const h2 = document.createElement('h2')
  h2.innerText = content
  return h2
}

export function createImg(src: string) {
  const img = document.createElement('img')
  img.src = src
  return img
}

export function sleep(time = 1) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('done')
    }, time * 1000);
  })
}

然后在 main.ts 只导入 sleep 函数:

import { sleep, fetchData } from './js/utils'

sleep(2)

为了查看 usedExports 效果,我们先把 minimize 设置 false ,然后打包。此时我们能看到一些特别的注释:

image.png

webpack 会给未导出的成员加上特殊的标记:unused harmony exports ... ,将来开启 minimize 后就会被剔除。值得注意的是,我们即使导入了 fetchData ,如果我们不使用还是会被标记成未引用代码。这一点特别重要,大家请着重理解这句话。

而实际情况是,我们虽然不会直接使用这个导入的变量,但是我们需要让这个模块执行一些代码,这些代码可能会改变状态,如定时器里面执行函数、变更全局状态、样式等代码,这种情况下我们必须借助 sideEffects 来表明这个模块是有副作用的,我们不希望把这些代码 掉,这就是 sideEffectsusedExports 的区别,它们相辅相成,来让代码更加精简。

配置 CDN

一句话解释:CDN (内容分发网络)是一种让用户就最近网络节点获取资源的技术,来提升网络传输速率。

我们项目中大部分会使用到 Vue、Vue-Router、Axios 等第三方模块,它们在打包时会一并打包到 chunk-vendors (Vue 中专门放第三方依赖的 chunk) 中,如果使用的生产依赖特别多就会导致初始页面加载很慢,所以我们可以把这些包抽离成 CDN ,不参与打包了。我们以 lodash-es 为例,配置一下 externals 就可以了。

webpack.common.js

module.exports = {
  externals: {
    // key 是 外部依赖名称,value 是你导入的名称
    'lodash-es': '_',
  },
}

打包 Dll 库

Dll 概念最先由微软引入,是一种“动态链接库”。它和 CDN 类似,不过文件一般存放在本地,把一些不经常变动的代码和第三方模块抽离出来,不参与打包,来提升构建速度的一种方式。我们还是以上面的 lodash-es 为例,假如我们不想把 lodash-es 通过 CDN 引入,则可以把它抽离成 Dll 库,在本地直接通过 script 引入不参与打包。

下面是 Dll 使用流程:

  • config 创建 webpack.dll.js
const { resolvePath, WORK_PATH } = require('./utils')
const webpack = require('webpack')

module.exports = {
  mode: 'production',
  entry: {
    lodash: ['lodash-es', 'lodash'],
  },
  output: {
    path: resolvePath('dll'),
    filename: 'dll_[name].js',
    library: 'dll_[name]', // 这里你可以理解为导出的一个全局变量,将来如果在浏览器使用是通过 `dll_lodash.forEach` 去使用的
  },
  plugins: [
    new webpack.DllPlugin({
      name: 'dll_[name]', // 设置成 library 的值
      path: resolvePath('dll/[name].manifest.json'), // 设置 manifest.json 输出目录的绝对路径,这个文件你可以理解为 source-map 中的 .map 文件,用于资源定位查找。
    }),
  ],
}
  • 配置脚本。
  "scripts": {
    "dll": "webpack --config config/webpack.dll.js",
  },
  • 打包。
yarn dll

之后根目录结构会创建 dll ,内容如下:

├── dll
|  ├── dll_lodash.js
|  ├── dll_lodash.js.LICENSE.txt
|  └── lodash.manifest.json

.txt 那个文件可以配置 minimizer 中的 TerserPlugin 属性去除,上文有介绍。

接着我们需要在项目中使用 DllReferencePluginAddAssetHtmlPlugin 来引入 dll 库,前者用于 dll 动态库查找,后者主要是讲文件嵌入到 html 文件中。

config/webpack.common.js

module.exports = {
  plugins: [
    new DllReferencePlugin({
      context: WORK_PATH, // 保证跟 package.json 同级目录,绝对路径
      manifest: resolvePath('dll/lodash.manifest.json'), // 指定 manifest 文件的绝对路径
    }),
    new AddAssetHtmlPlugin({
      outputPath: 'auto', // 将来输出到的文件目录
      filepath: resolvePath('dll/dll_lodash.js'), // dll 文件的绝对路径
    }),
  ],
  // externals: {
  //   'lodash-es': '_',
  // },
}

到这里就算是搞定配置了,可见我们为了一个优化要做这么多事情,其实是很麻烦的(即使使用了 AutoDllPlugin 插件来自动做导入配置也很),而且在 VueCLI 和 CRA 脚手架工具都没使用 dll 了,而是使用 webpack 自身的优化。如果大家对这块有疑问也不必过于深究,了解即可。

控制台优化

我们在开发中一般不需要编译后结果,最重要的就是报错有提示,所以我们可以借助 FriendlyErrorsWebpackPluginstats 选项来优化我们的控制台:

webpack.dev.js

const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const { PUBLIC_PATH } = require('./utils')
const FriendlyErrorsWebpackPlugin = require('@nuxtjs/friendly-errors-webpack-plugin')

// 获取启动端口,默认是 8080
const portArgvIndex = process.argv.indexOf('--port')
let port = portArgvIndex !== -1 ? process.argv[portArgvIndex + 1] : 8080

module.exports = merge(baseConfig, {
  mode: 'development',
  devtool: 'cheap-module-source-map',
  stats: 'errors-only',
  devServer: {
    host: '0.0.0.0',
    port,
    static: PUBLIC_PATH,
    hot: 'only', // 防止 error 导致整个页面刷新
    compress: true, // 开启本地服务器 gzip 压缩
    historyApiFallback: true, // 防止 history 路由刷新后空白
    // 配置接口代理
    proxy: {
      '/api': {
        target: 'https://api.github.com',
        pathRewrite: { '^/api': '' },
        changeOrigin: true,
      },
    },
  },
  plugins: [
    new FriendlyErrorsWebpackPlugin({
      compilationSuccessInfo: {
        // 修改启动后终端显示localhost和network访问地址
        messages: [
          `App runing at: `,
          `Local: http://localhost:${port}`,
          `Network: http://${require('ip').address()}:${port}`,
        ],
      },
    }),
  ],
})

然后再命令行脚本 dev 再配置一个 --progress 参数,这个可以显示编译时的百分比,更加人性化。配置后我们启动本地服务器就可以看到我们很熟悉的控制台了:

image.png

这里推荐一个插件:webpack-dashboard,它提供了更加全的面板,有兴趣大家可以去官方文档查看。

构建后分析

在生产模式下构建后,如果我们想分析打包后文件的体积可以借助 webpack-bundle-analyzer 这个插件,它提供了可视化功能帮我们分析。

  • 安装
yarn add -D webpack-bundle-analyzer
  • 使用

webpack.prod.js

const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = merge(baseConfig, {
  mode: 'production',
  plugins: [
    new BundleAnalyzerPlugin(),
  ],
})

此时我们使用 yarn build 进行打包,可以发现这个插件帮我们启动了一个服务,并且可以看到打包后的可视化页面:

image.png

使用 copy-webpack-plugin

我们开发中对于不需要参与打包的文件如 favicon.ico 、静态资源等可以通过 copy-webpack-plugin 直接拷贝到输出目录,而在开发阶段我们不需要拷贝的原因是,文件 I/O 效率低下,可以直接利用 devServer 静态服务器的能力直接提供资源(前文配置的 devServer.static 属性)。

安装使用方式都很简单,下面我给出 plugins 中的配置:

{
  plugins: [
    new CopyWebpackPlugin({
      patterns: [
        {
          from: 'public',
          globOptions: {
            // 忽略 index.html,该文件由 html-webpack-plugin 拷贝
            ignore: ['**/index.html'],
          },
        },
      ],
    }),
  ],
}

打包 Library

如果我们是库的开发者或者要抽离出一个函数进行单独发布,首先推荐的是 rollup ,因为它提供了很干净的源代码,当然 webpack 也是支持的。下面我们讲 src/js/utils.ts 发布成 lib。

  • 首先在 config 目录下面再整一个 webpack.lib.js
const { resolvePath } = require('./utils')
module.exports = {
  mode: 'production',
  entry: './src/js/utils.ts',
  output: {
    filename: 'utils.js',
    path: resolvePath('lib'),
    libraryTarget: 'umd', // 兼容 AMD、CJS、ESM 等多种模块化
    library: 'utils', // 我们包的名称,即全局变量
    globalObject: 'this', // 使用哪个全局对象,默认是 'self' 即 window 对象,为了兼容 Node 以及其他平台可以设置成 this
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.(js|ts)x?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              cacheDirectory: true,
            },
          },
        ],
      },
    ],
  },
}

打包后,根目录下就多了一个 lib 文件,你可以直接 require 进行使用,也可以在浏览器通过 script 来引入使用,这里就不演示了。

实战

前面我们基本上把 Webpack 核心的一些特性进行了讲解,我们现在都知道了如何使用 loader 来对各种各样的资源进行处理,使用 plugin 来让我们拓展构建系统的能力。下面要介绍的 Vue3 / React 项目其实很简单,就是在前面的基础上,再配上相关的 loaderplugin 就可以工作了。

规范化项目

不管是什么项目,都需要代码质量的管控,所以 ESLint 和 Git 提交都需要被规范化,前面我们只是支持了 TS 语法,如果需要代码提示还需要实时的通过 watch 模式启动前面配置的 check-type 这很麻烦。

ESLint + Prettier 功能集成

安装以下依赖:

  • eslint:使用其语法检测功能。
  • prettier:使用其代码风格检测功能。
  • eslint-webpack-plugin:webpack 集成 eslint 的插件。
  • eslint-plugin-vue:支持 Vue 语法检测功能。
  • eslint-plugin-prettier:集成 prettier 代码风格功能。
  • eslint-config-prettier:覆盖 eslint 中的代码风格检测,或者说解决 eslint 与 prettier 之间的冲突。
  • @typescript-eslint/eslint-plugin:集成 TS 代码检查功能。
  • @typescript-eslint/parser:TS 解析器。
yarn add eslint prettier eslint-webpack-plugin eslint-plugin-vue eslint-plugin-prettier eslint-config-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser -D

根目录下创建 .eslintrc.js

module.exports = {
  parser: 'vue-eslint-parser', // 解析 <template> ...
  env: {
    browser: true,
    node: true,
    es2021: true,
    'vue/setup-compiler-macros': true // Vue 3 编译宏
  },
  extends: [
    'plugin:vue/vue3-strongly-recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended'
  ],
  parserOptions: {
    parser: '@typescript-eslint/parser', // 解析 SFC 中的 script
    ecmaVersion: 12,
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true
    }
  },
  rules: {
    'prettier/prettier': 'error',
    'vue/multi-word-component-names': 'off'
  }
}

创建 .eslintigore 忽略某些文件检测(默认不检测 node_modules,记得项目是在根目录,否则不生效):

*.sh
.vscode
.idea
.husky
.local

*.js
/public
/dist
/config
/dll
/lib

创建 prettier.config.js

module.exports = {
  printWidth: 100,
  tabWidth: 2,
  useTabs: false,
  semi: false,
  vueIndentScriptAndStyle: true,
  singleQuote: true,
  quoteProps: 'as-needed',
  bracketSpacing: true,
  trailingComma: 'none',
  arrowParens: 'always',
  insertPragma: false,
  requirePragma: false,
  proseWrap: 'never',
  htmlWhitespaceSensitivity: 'strict',
  endOfLine: 'lf'
}

相关规则不用记,下面是一些规则配置的文档,按需查找即可:

配置 webpack.dev.js,增强开发实时检测能力:

const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const ESLintPlugin = require('eslint-webpack-plugin')

module.exports = merge(baseConfig, {
  plugins: [
    new ESLintPlugin({
      extensions: ['js', 'ts', 'jsx', 'tsx'],
      emitError: true,
      emitWarning: true,
      failOnError: true
    })
  ]
})

eslint-webpack-plugin 版本要安装成 2.1.0 ,不然没法在控制台找到错误。我是在官方 issue 找到的,传送门在此

搞定配置后,记得重启 VSCode ,然后启动开发服务器,你就可以看到控制台一堆报错了:

image.png

Git 提交约束

上面的配置只是规范的第一道屏障,有的人其实很厌烦,它甚至把 ESLint 检测通过行内注释给关闭了,这可如何是好?

没关系,代码总要上传 Git 的对吧,那我们在他提交代码的时候检测一下不就好了嘛,如果没有通过就不准提交代码。这就要依赖下面的工具了:

  • husky:触发Git Hooks,执行脚本。
  • lint-staged:检测文件,只对暂存区中有改动的文件进行检测,可以在提交前进行 Lint 操作。
  • commitizen:使用规范化的message提交。
  • commitlint:检查 message 是否符合规范。
  • cz-conventional-changelog:适配器。提供conventional-changelog标准(约定式提交标准)。基于不同需求,也可以使用不同适配器(比如: cz-customizable)。

安装:

yarn add husky lint-staged commitizen @commitlint/config-conventional @commitlint/cli  -D

设置适配器:

# yarn
yarn commitizen init cz-conventional-changelog --yarn --dev --exact --force

# npm
npx commitizen init cz-conventional-changelog --save-dev --save-exact --force

使用 --force 参数防止你以前安装过会出现冲突的情况。

它会在本地项目中配置适配器,然后去安装 cz-conventional-changelog 这个包,最后在 package.json 文件中生成下面代码:

 "config": {
    "commitizen": {
      "path": "cz-conventional-changelog"
    }
  }

接下里配置一个脚本,用于以后的 git 提交:

{
  "scripts": {
    "commit": "git cz"
  },
}

然后配置 commitlint ,用于校验 Git 提交消息。

echo "module.exports = {extends: ['@commitlint/config-conventional']};" > commitlint.config.js

有了这个校验工具,怎么才可以触发校验呢,我们希望在提交代码的时候就进行校验,这时候husky就可以出场了,他可以触发Git Hook来执行相应的脚本,而我们只需要把刚刚的校验工具加入脚本就可以了,下面是具体使用方法:

我们需要定义触发 hook 时要执行的 Npm 脚本:

  • 提交前对暂存区的文件进行代码风格语法校验
  • 对提交的信息进行规范化校验
{
  "scripts": {
    "lint-staged": "lint-staged",
    "commitlint": "commitlint --config commitlint.config.js -e -V",
    "lint": "eslint ./src/**/*.{js,jsx,vue,ts,tsx} --fix",
    "prepare": "husky install"
  },
  "lint-staged": {
    "*.{js,jsx,vue,ts,tsx}": [
      "yarn lint",
      "prettier --write"
    ]
  },
}

接下来就是配置 husky 通过触发Git Hook执行脚本:

# 安装钩子,项目开发人员只要拉取代码都会安装
yarn prepare

# 设置`pre-commit`钩子,提交前执行校验
yarn husky add .husky/pre-commit "yarn lint-staged"

# 设置`pre-commit`钩子,提交message执行校验
yarn husky add .husky/commit-msg "yarn commitlint"

注意:你的仓库在此之前必须是一个 git 仓库。

完成配置后,使用 git add . && yarn commit 进行测试吧~。

支持 Vue3 项目

我们知道 Vue 项目主要以 SFC(单文件组件) 为主,要支持识别需要安装以下依赖:

  • vue:生产依赖。

  • vue-router:生产依赖。

  • @vue/compiler-sfc (必须与 vue 同版本):用于编译 SFC 中的 template。

  • vue-loader v16.x:识别并解析 .vue 文件,将 script/style 拆分后交给其他 loader 处理(VueLoaderPlugin)。

  • 安装

yarn add vue@next vue-router@next
yarn add vue-loader@next @vue/compiler-sfc -D
  • 配置 webpack.common.js
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
  entry: {
    app: './src/entry-vue.ts'
  },
  resolve: {
    extensions: ['.js', '.json', '.ts', '.vue']
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: ['vue-loader']
      },
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
}

配置 @babel/preset-typescript 识别 vue 文件中 ts代码:

babel.config.js

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        useBuiltIns: 'usage',
        corejs: 3,
        modules: false
      }
    ],
    [
      '@babel/preset-typescript',
      {
        allExtensions: true // 支持所有文件扩展名
      }
    ]
  ]
}

对标 ts-loader 中的 appendTsSuffixTo 配置。

  • src 目录添加下面结构:
├── entry-vue.ts # vue 入口
├── env.d.ts # ts 声明文件
└── VueApp
   ├── App.vue
   ├── router
   |  └── index.ts
   └── views
      ├── home
      |  └── index.vue
      └── login
         └── index.vue

其中我们看几个核心的文件:

  • entry-vue.ts
import { createApp } from 'vue'
import App from './VueApp/App.vue'
import router from './VueApp/router'

createApp(App).use(router).mount('#app-vue')

VueApp/App.vue

就一个导航跳转功能,很简单:

<template>
  <button @click="$router.push({ path: '/', query: { id: 1 } })">to home</button>
  <button @click="handleClick">to login</button>
  <router-view></router-view>
</template>

<script setup lang="ts">
  import { useRouter } from 'vue-router'

  const router = useRouter()
  const handleClick = () => {
    router.push({ path: '/login', query: { id: 1 } })
  }
</script>

VueApp/router/index.ts 路由配置:

import { createRouter, RouteRecordRaw, createWebHistory } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: () => import('../views/home/index.vue')
  },
  {
    path: '/login',
    component: () => import('../views/login/index.vue')
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

拓展 .vue 模块的识别:

env.d.ts

declare module '*.vue' {
  import { DefineComponent } from 'vue'
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
  const component: DefineComponent<{}, {}, any>
  export default component
}
  • yarn dev 启动项目,大功告成!

image.png

等等,这个警告很烦人,这是个啥?它意思是说我们现在用的是 esm-bundler 版本(vue.runtime.esm-bundler.js)的 Vue,也就是说在如 webpack 这种打包器使用时需要注入环境变量:

  • VUE_OPTIONS_API: 是否支持 Options API
  • VUE_PROD_DEVTOOLS: 是否开启生产环境下 DevTool

我们使用 DefinePlugin 注入一下就好了:

webpack.common.js

{
  plugins: [
    new DefinePlugin({
      BASE_URL: JSON.stringify(''),
      __VUE_OPTIONS_API__: false, // 不支持 options API
      __VUE_PROD_DEVTOOLS__: false // 不支持生产 DevTool
    })
   ],
}

到这为止,一个简单的 Vue 3 项目算是搭建好了。如果是多入口可能会出现 HMR 不起作用的情况,相关 issue,解决方案是:在开发配置里加上 optimization.runtimeChunk: 'single',单入口目前没发现任何问题。

webpack.dev.js

  optimization: {
    runtimeChunk: 'single'
  },

待优化

这一套流程搞下来,我们项目目录变得很庞大且臃肿,其实有些配置文件很少需要变动,而且将来有一个新项目之后可能又要搭建一次,所以我提供的思路就是搞一个类似 VueCLI 的脚手架工具,且符合自己公司业务需求的脚手架,这样就能大大提升效率了,将来我有时间了且能力够的情况下我会写一篇关于 CLI 开发的流程。

总结

本文主要是讲述了 webpack v5 版本中的一些核心特性(当然一些新特性如模块联邦并没有提及,这个以后讲到微前端架构会单独出一篇文章,现在详细讲意义根本不大,了解即可),并且以一个 Vue 3 的实战搭建作为结尾,相信大家看了这篇文章一定会有收获的。笔者花了大概 5 天时间去写这篇文章,比我计划要慢了很久,因为中途写着写着遇到不少坑,大家可以看到很多的 “tips” ,这些都是踩坑记录,不过对于个人而言成长是很大的。

在实际开发中我并不推荐从 0 到 1 直接搭建,因为会占用你和团队大量的时间,使用 VueCLI / create-react-app 是更佳的选择,但是对于自身发展来说,它们终究是一个黑盒,我们只有进入黑盒才能有更大的提升,我们掌握这些技能之后就有了对整个工程的思考维度,所以在时间充裕的情况下自己从 0 到 1 去搭建一个项目收获是很大的。

这几天先缓缓,过阵子开始写 loader/plugin 手写与原理分析,敬请期待!

参考