阅读 263

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

导读

上一篇文章(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' } 复制代码

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

  • Vue Rules

  • Prettier Rules

  • ESLint Rules

  • TS Rules

配置 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 手写与原理分析,敬请期待!


作者:不烧油的小火柴
链接:https://juejin.cn/post/7030709712920248357


文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐