谈谈你对webpack的理解

首先我们需要知道,我们为什么需要webpack这个打包工具。我们前端开发的时候是需要模块化的,不使用模块化开发的项目,依赖管理困难,项目难以维护,代码复用性不高,还可能出现变量冲突的问题。

但是浏览器是不支持模块化的,所以我们需要将模块化的代码打包成能在浏览器上直接运行的代码。

另一方面,我们会在项目开发过程中,用一些新语法和框架的特殊写法(es,ts,vue),浏览器只认识js,而且有些浏览器对es6的支持不全面,所以我们需要打包工具来进行代码转换,语法降级

模块化打包是webpack最基本的功能,其他功能比如语法降级(将es6转化成es5),将ts,vue的代码编译成浏览器上能执行的代码,都是通过loader和插件实现的。还有其他功能比如文件合并,拆分,压缩,资源处理,也是通过其他loader和插件实现的,

快速开始

下载

1
npm i webpack webpack-cli -D //下载webpack和调用webpack的命令

不下载到全局的原因:

  • 不同项目可能需要使用不同版本的webpack

  • 下载到全局无法被项目中的package.json文件记录,分享项目给其他人使用的时候需要额外下载webpack,对于没有前端基础的人可能甚至不知道需要下载webpack。

webpack打包命令

如果全局安装:

1
webpack

如果安装到本地

1
npx webpack #npx会自动在node_modules/.bins目录下查找可执行文件,效果和运行package.json中的脚本一样

或者

1
npx webpack --watch #实时监测文件的变化,变化后保存文件自动打包

webpack.config.js

webpack配置文件,在此自定义webpack配置,就不用每次都在命令行中指定配置参数。

这个文件的执行环境是node.js,必须使用cjs语法,使用module.exports导出配置对象

常见配置属性

entry

是webpack中的必备配置项,用来指定入口文件,入口文件是依赖分析的起点,一个入口文件对应一个打包后的文件

指定一个入口文件:

1
entry:'./src/main.js'

使用对象指定多个入口文件:

1
2
3
4
5
6
entry:{
app:'./src/main.js',
app2:'./src/main2.js' //app, app2指定的是打包后的模块名name
}
//或者
//entry: ['./src/index.js', './src/another-entry.js']

更详细的配置:

1
2
3
4
5
6
7
entry:{
app:{
import:'./src/main.js',//指定入口文件
dependOn:'lodash',//指定依赖的模块
filename:'page/[name].bundle.js',//指定打包后的js文件名称
} //app是模块名,用来替代[name]的位置
}

output

是webpack中的必备配置项,用来指定打包后的产物(图片,css,js,html文件)的存储位置和名称(只能指定js文件的名称)

1
2
3
4
5
6
{
filename: '[name].bundle.js',//打包后额度js文件名, 其中的name指的是模块名,可以用来防止多入口情况下的命名冲突问题
path: path.resolve('dist')
clean:true
publicPath:''//给所有资源都加上公共路径前缀
}
  • filename:打包后输出的js文件名
  • path:打包后**所有文件(包括js文件,css文件和图片文件)**的存放位置,必须是绝对路径,所以使用path.resolve()来拼接路径,如果最终拼接的不是绝对路径,还会和当前工作目录拼接,确保结果是一个绝对路径。
  • clean:值为布尔值,为true表示每次打包清除之前的打包文件
  • publicPath:指定所有文件的公共路径

哈希值

在使用 Vue CLI 构建项目时,可以通过在文件名中配置哈希值来优化缓存。具体的来说,如果没添加哈希值,即便文件内容被修改了,打包后的文件名也不会改变,然后在浏览器中重新加载的时候,就会因为文件名相同使用先前的缓存,导致更新失效。在文件名中使用哈希值,能确保当文件内容发生变化时,浏览器能够识别并下载最新版本的资源,而不是使用旧的缓存

Vue CLI 基于 Webpack 实现了这一功能,因此可以利用 Webpack 的相关配置,来控制哈希值的生成方式。

以下是几种常见的哈希计算方式

[hash]:基于某次构建过程中的编译结果,生成唯一一个哈希值,一次打包后的所有文件共用同一个hash值。这意味着,如果构建过程中,有任何文件发生了变化,所有输出文件的哈希值都会改变,这就导致某些可用的缓存失效。从这个意义来说,这个类型的hash值可以看作版本号

1
2
3
output: {
filename: '[name].[hash].js',
}

**[chunkhash]**:基于整个 chunk 的内容生成哈希,一个 chunk 可能包含多个文件(如 JS + CSS),只要 chunk 内任一文件内容变化,整个 chunk 的 chunkhash 就变

1
2
3
output: {
filename: '[name].[chunkhash].js',
}

当某个入口点下的文件发生更改时,仅该入口点相关的文件哈希值会更新,其他入口点的文件哈希值保持不变。

**[contenthash]**:这是 Vue CLI 推荐的方式,它根据文件内容生成哈希值。对于 CSS 文件,Vue CLI 使用 extract-text-webpack-pluginmini-css-extract-plugin 插件提取样式到单独的文件,并为这些文件生成基于内容的哈希值。

1
2
3
output: {
filename: '[name].[contenthash].js',
}

这种方法更加精细,只会在文件的实际内容发生变化时才更新其哈希值,从而最大限度地利用浏览器缓存

mode

定义打包模式(必填)

1
mode:developmemt||production

开发模式和打包模式的区别

开发环境:

  • 不需要使用文件缓存,所以不需要给文件名额外添加[contenthash]
  • 保留devServer
  • 删除压缩css,js文件配置

生产环境:

  • 需要使用缓存,保留文件额外名[contenthash]
  • 删除devServer
  • 保留压缩css,js文件配置
  • 使用tree-shaking

module

非必须,配置loader的地方

plugins

非必须,配置插件的地方

optimization

非必须,优化相关

resolve

非必须,提供一些简化功能,比如:

路径别名:允许你为常用路径设置别名,避免写长相对路径

1
2
3
4
5
6
7
8
9
// webpack.config.js
const path = require('path');
module.exports = {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
};
1
2
3
4
5
// 以前
import Button from '../../components/Button.vue';

// 现在
import Button from '@/components/Button.vue';

自动补全扩展名:

1
2
3
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue', '.json'], //从前往后开始匹配
}
1
2
3
import api from '@/utils/api.js';
// 可以写成
import api from '@/utils/api'; // 自动查找 api.js / api.ts / api.vue

处理JS

babel-loader

webpack本身只能对js代码打包, 压缩,不能进行ES6到ES5的转换,需要借助babel-loader将新的语法转换成低版本的语法,实现语法降级,提高代码的兼容性,比如对esm语法的转化。

安装:

1
npm i babel-loader @babel/core @babel/preset-env --save-dev

babel-loader 是一个 Webpack 加载器,用于在 Webpack 构建过程中使用 Babel 转译 JavaScript 代码。

@babel/core 是 Babel 的核心库,负责执行实际的代码转译工作

@babel/preset-env 是一个智能预设,可以根据目标环境自动选择需要的 Babel 插件,以生成兼容的代码。

配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
}

balel-loader的详细配置,也就是presets部分,有时候内容会很多,我们还可以将这部分内容移动到.babelrc文件中,以json的格式书写

1
2
3
{
"presets": "['@babel/preset-env']"
}

eslint插件

eslint在webpack中是作为一个插件存在的,使用前需要先安装:

1
2
3
4
5
# ESLint 核心
npm install eslint --save-dev

# Webpack 的 ESLint 插件(让 Webpack 调用 ESLint)
npm install eslint-webpack-plugin --save-dev

然后在配置文件中配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack.config.js
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
// ... 其他配置
plugins: [
new ESLintPlugin({
// 检查的文件路径
context: 'src', // 检查 src 目录
files: ['**/*.js', '**/*.vue'], // 检查 .js 和 .vue 文件
extensions: ['js', 'vue'], // 检查的文件扩展名
exclude: ['node_modules', 'dist'], // 排除目录
failOnError: false, // 是否在 ESLint 错误时停止构建(开发环境设为 false)
failOnWarning: false,
emitWarning: true, // 输出警告
emitError: true, // 输出错误
}),
],
};

eslint的代码规范是人为配置的,这部分的代码通常比较多,我们可以放在.eslintrc.js文件中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// .eslintrc.js
module.exports = {
// 1. 环境设置
// 告诉 ESLint 哪些全局变量是合法的,避免误报
env: {
//启用浏览器全局变量,如 window, document, localStorage 等,避免 ESLint 报 undefined 错误
browser: true,
//启用 ES2021 语法支持,如 Promise.any, String.prototype.replaceAll 等
es2021: true,
//启用 Node.js 全局变量,如 process, __dirname, require 等
node: true,
},
// 2. 解析器配置
parserOptions: {
ecmaVersion: 'latest',// 使用最新的 ECMAScript 语法
sourceType: 'module',// 启用 ES6 模块语法(import/export),而不是 script
ecmaFeatures: {
jsx: true, // 用 JSX 语法支持(用于 React 项目)
},
},
// 3. 扩展规则
// 继承预设的规则集
extends: [
'eslint:recommended', // 启用 ESLint 官方推荐的基础规则
'plugin:vue/vue3-recommended', // 启用 Vue 3 的推荐规则(来自 eslint-plugin-vue)
'standard', // 启用 JavaScript Standard Style 规范
],
// 4. 使用的插件
plugins: [
'vue', // 加载 eslint-plugin-vue 插件,用于检查 .vue 文件中的 <template> 和 <script>
],
// 5. 自定义规则
rules: {
'no-console': 'warn', // 禁止使用 console,但只警告(不中断构建)
'no-debugger': 'error', // 禁止使用 debugger,错误级别(会报错)
'vue/multi-word-component-names': 'off', // 关闭“Vue 组件名必须是多词”的限制
'space-before-function-paren': ['error', 'never'], // 函数名后不能有空格
'semi': ['error', 'always'], // 必须使用分号结尾
'quotes': ['error', 'single'], // 必须使用单引号
}
};

其中最关键的配置项就是rules,是直接指定代码规范的地方,如果我们不想自己编写规范,也可以使用标准的规范,这就需要我们下载eslint-config-standard,然后配置在extend中。

处理CSS

如果我们在wepack中直接引入css文件,然后打包,一定会报错,因为webpack本身无法处理css文件。我们需要下载css-loader,以及style-loader或者mini-css-extract-plugin

style-loader: 把css写入js,代码执行后,把解析后的css样式,放到打包后的html文件的style标签中。

mini-css-extract-plugin:把css提取为单独的文件多个css文件会合并为一个单独的css文件,在html-webpack-plugin插件的作用下,还会自动在html文件中引入(在head标签中)这个css文件。

1
2
3
4
5
6
7
8
9
10
module:{
rules:[
{
test:/\.css$/,//匹配css文件
use:[ MiniCssExtractPlugin.loader,'css-loader' ]//如果想要配置多个loader必须使用use
//或者
// use:['style-loader','css-loader']
},
]
}

配置mini-css-extract-plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
plugins:[
new MiniCssExtractPlugin({
//存放css代码的文件的名称,如此命名还会将css文件最终打包到dist/styles/main.css中
//也不用担心这么做html找不到对应的css文件,因为也是根据配置的filename来引入的
filename:'styles/main.css'
})
]
modules:{
rules:[ {test:/\.css$/, use:[MiniCssExtractPlugin.loader,'css-loader']} ]
}
}//注册

loader是支持链式调用的,顺序从右往左,就拿上面的例子来说,先对css文件使用css-loader,再使用style-loader。

注意:单纯打包css文件不会修改原来的css代码,也不会压缩代码,只是把原来的css代码放到一个文件中。压缩,修改css代码需要借助其他插件。也就是说,单纯借助上述的3个工具,只能做到提取css代码,无法对css代码进行优化

想要压缩css代码,还需要下载并配置css-minimizer-webpack-plugin

1
npm i css-minimizer-webpack-plugin -D
1
2
3
4
5
6
7
const CssMinimizeWebpackPlugin = require('css-minimizer-webpack-plugin')
module.exports = {
mode: 'production',
optimization: {
minimizer:[new CssMinimizerPlugin()]
}
}//注册

处理其他资源文件

在webpack5中,不需要再使用loader来处理各类资源文件(比如图片,字体),因为自带了对各类资源文件的处理功能(存在资源模块)。当然也可以使用原来的File-loader或者url-loader。

asset/resource

无论资源的大小如何,返回资源打包后的路径,打包后的文件中会包含源文件

1
2
3
4
5
6
7
8
9
module:{
rules:[{
test:/\.jpg$/,
type:'asset/resource',
generator:{
filename: 'images/[contenthash][ext]'//打包后的图片名称,存储位置由output决定
}
}]
}

[contenthash]代替文件名,这意味着会根据文件的内容来确定文件名,如果文件内容改变,这个值也会改变

[ext]来代替后缀,表示源文件是什么类型的后缀,打包后的文件也是什么类型的后缀。

图片可以通过import或者src的方式被引入

1
2
3
4
5
6
7
//通常会得到该图片打包后的路径 或 Base64 编码字符串(取决于你的构建配置)
import logoImage from './logo.png'
function App() {
return (
<img src={logoImage} alt="Logo" />
);
}

asset/inline

无论资源的大小如何,返回源文件的data:url,也就是base64格式字符串,打包后的结果就不会包括源文件了

设你有一张 PNG 格式的图片,通过 data URL 和 Base64 编码的方式内联到 HTML 文件中,它可能看起来像这样:

1
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..." alt="Embedded Image">

data:image/png表示数据的MIME类型,base64表示数据是否经过了 Base64 编码,如果数据未进行 Base64 编码,则应省略此部分。

这里的 "iVBORw0KGgoAAAANSUhEUgAAAAUA..." 就是该图片经过 Base64 编码后的字符串。

asset

webpack将按照默认条件,自动地在resourceinline 之间进行选择,小于4kb的文件,将会视为inline模块类型,否则会被视为resource模块类型。也可以修改这个配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module:{
rules:[{
test:/\.jpg$/,
type:'asset/resource',
generator:{
filename:'images/[contenthash][ext]'#打包后的图片名称
}
parser: {
dataUrlCondition:{
maxSize: 4 * 1024 *1024
}//4Mb
}
}]
}

asset/source

导出资源的源码,这种方式非常适合需要将小文件的内容,直接包含到代码中的场景,比如模板、小型脚本或样式表等。

1
2
3
4
5
6
7
8
9
10
module.exports = {
module: {
rules: [
{
test: /\.(txt|xml|json)$/, // 匹配你想要作为 source 导入的文件类型
type: 'asset/source',
},
],
},
};

假设有一个名为 example.txt 的文本文件,其内容为 "Hello, Webpack!",你可以通过以下方式将其导入到 JavaScript 文件中:

1
2
import content from './example.txt';
console.log(content); // 输出: Hello, Webpack!

在这种情况下,content 变量将会包含 example.txt 文件的所有文本内容。

注意事项

  • 仅限文本文件:asset/source 最适合用于文本文件。如果你尝试用它来处理二进制文件(如图片、字体),可能会导致不可预料的结果,因为这些文件的二进制数据会被当作字符串处理。
  • 文件大小:虽然可以将任何大小的文件作为源代码导入,但通常建议只对较小的文件这样做,以避免增加最终打包文件的体积。

loader

loader 本质是一个函数,用于对文件的源代码进行转换,使之变为webpack可用的模块,在 import 或加载文件时,使用loader预处理文件。

比如我们可以在我们的项目根目录下,编写一个mycss-loader,大致项目结构如下:

1
2
3
4
-----
|--mycss-loader
|--index.js
|--webpack.config.js

mycss-loader/index.js中编写如下代码:

1
2
3
4
// 导出一个函数,source为webpack传递给loader的文件源内容
module.exports = function(source) {
return source.replace('0','10px')
}

然后在webpack.config.js中配置:

1
2
3
4
5
6
7
8
module:{
rules:[
{
test:/\.css$/,//匹配css文件
use:['style-loader','css-loader', './mycss-loader']
},
]
}

然后打包后的css文件中,0都会变成10px。

webpack做的事情,仅仅是分析出各种模块的依赖关系,然后形成资源列表,最终打包生成到指定的文件中。如下图所示:

默认情况下,在遇到import或者require加载模块的时候,webpack只支持对jsjson 文件打包,像csssasspng等这些类型的文件的时候,webpack则无能为力,这时候就需要配置对应的loader进行文件内容的解析

得益于loader,在webpack内部中,不仅仅只是js文件,任何文件都是模块。

loader在webpack.config.js文件中使用的时候通常不需要导入,直接使用即可, Webpack 会根据配置中的名称自动查找并使用相应的加载器,而对于插件,需要导入然后手动创建实例。

处理html

我们需要html做些什么?

  • 提供一个html模板复用固定的内容,返回一个html
  • 打包的时候自动引入css,js

要实现上述功能,我们就要借助html-webpack-plugin这个插件,它会生成一个自动引用打包后的文件的html文件,包括mini-css-extract-plugin生成的css文件,会自动注入打包后的html文件中。

安装:

1
npm i html-webpack-plugin -D

webpack.config.js中配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
const HtmlWebpackPlugin = require(' html-webpack-plugin') //引入
module.exports = {
plugins:[
new HtmlWebpackPlugin({
template, // html模板路径
filename, // 输出的html文件名
title, // 打包后html文件的title
inject, //指定打包后的js文件的的注入位置,可取值为'body'或者'header'
chunks:[] //指定引入哪些打包后的js文件(入口文件),默认全引入,
minify, // html-webpack-plugin默认会压缩打包后的html文件,可以在这个属性中进行更具体的配置
})
]
}//注册

当我们指定多个入口文件的时候,意在构建多页面应用程序,希望不同的入口文件打包后,能嵌入到不同的html文件中,但是如果我们在配置中只创建了一个HtmlWebpackPlugin实例,那么这些入口文件打包后都会被插入同一个html文件中,为了避免这个效果,**我们可以在配置中创建多个HtmlWebpackPlugin实例,并在chunks属性中指定:”这个html文件要引入哪些入口文件”**。

自定义打包后的html的标题:

  • 在配置对象中添加titile属性并赋值
  • html模板中的title标签内容替换为<%= htmlWebpackPlugin.options.title %>

代码分割

由于我们是通常是单入口的项目,所有js文件通常会打包成一个js文件,css文件也是如此,这就导致我们页面初次加载的时候,需要花很长时间来加载js和css文件,尽管其中的许多代码并不是立马就会用到(非首屏需要)。

动态导入

1
import('./math.js')//返回一个promise对象
1
2
3
4
5
export const add = (x,y)=>{
console.log(x+y)
}//lazy.js代码

import('./lazy.js').then( ({add})=>{ add(1,1)} )

动态导入的文件打包的时候会被自动抽离为一个单独的模块,最终输出为一个单独的文件,使用的时候再被导入(会引发一个额外的请求),即便没有被多次使用,因为是动态导入的,所以不参与模块的静态依赖分析。

魔法注释

1
2
import(/* webpackChunkName:'math' */'./lazy.js') //指定动态导入的文件,打包后的模块名(也许不是最后文件名)
import(/* webpackPrefetch:true */'./lazy.js') // 首页内容都加载完毕,等待网络空闲的时候再加载这个文件

模块抽离

多入口的项目中,不同的入口文件中可能会引入相同的模块,如果我们不做任何处理,打包后的2个入口文件将包含相同的模块代码,然后浏览器就会重复请求这部分相同的代码。更好的做法是,在得知哪个模块被重复引用的前提下,抽离出该模块,将其单独打包为一个文件。

1
2
3
4
5
6
7
8
9
10
11
entry:{
index:{
import:'./src/main.js',
dependOn:'shared'//表示依赖哪个模块
},
another:{
import:'./src/another.js',
dependOn:'shared'
},
shared:'lodash'
} //index,another,shared是模块名

除了用上述配置方法,更好的方式是使用插件split-chunks-plugin自动抽离重复引用的模块,无需下载,webpack内置。

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
mode: 'production'
optimization: {
splitChunks:{
chunks:'all',
minChunks:2, //重复引用2次的模块,才进行分割
minSize: 1000, //重复引用的模块只有体积大于1000字节时,才进行分割。
name:'chunk' //指定分割出去的模块名
},
}
}//注册

要注意但是,所有重复引用的模块,默认情况下都会打包到同一个文件中去。有些时候,我们需要把某个单独的文件分割出来,更多情况下,无论是单入口还是多入口,我们会把第三方库单独打包成vendor,以及单独打包webpack用来组织模块运行的runtime代码,对此我们需要使用cacheGroup选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
module.exports = {
mode: 'production'
optimization: {
splitChunks:{
chunks:'all',
cacheGroup:{
// 用来提取第三方库代码
vendor:{
test: /[/\\]node_modules[/\\]/, // [/\\] 的一意思是匹配一个正斜杠或者反斜杠
//由于指定的是filename而不是name,打包后的文件名就叫做vendor.js,不受output中的配置影响
filename:'vendor.js',
chunks: 'all',
minChunks:1 //至少被重复引用2次,才提取为一个单独的文件
},
// 用来提取多入口项目中,在多个入口文件重复引入的业务代码
common:{
filename:'common.js',
chunks: 'all',
minChunks:2
minSize: 100
}
}
},
}
}//注册

单独打包webpack用来组织模块运行的runtime代码,则需要使用optimization中的另一个配置项:runtimeChunks。

1
2
3
4
5
6
7
{
optimization:{
runtimeChunks:{
name:'runtime.js'
}
}
}

开发模式devServer

webpack-dev-server是一个由webpack团队维护的,webpack高度支持的独立的工具,用于在开发过程中提供一个开发服务器, 使用不需要导入,但是需要额外下载

1
npm i webpack-dev-server -D
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
devServer:{
static:'./dist',
port:8080,//配置服务器端口号,
hot:true,
compress: true, //保证传输的是压缩文件从而提高我们的传输效率
//headers:配置响应头
proxy:[
{
path:'/api',//使用的是前缀匹配
changeOrigin:true,
target:'http://localhost:8081',
pathRewrite: { '^/api': '' }
}
]//配置代理,解决跨域问题,因为搭建的本地服务器部署的网页也可能发请求获取其他源下的资源。
}
}

工作原理

使用webpack-dev-server开启一个wepack服务器,底层会使用Express开启一个node服务器,然后调用webpack方法进行打包,再将打包后的结果返回给wepack服务器。当项目中的文件变更了,又会通知wepack服务器重新调用webpack方法进行打包。简单实现如下:

1
2
3
4
5
6
7
8
const express =require("express");
const webpackDevMiddleWare = require("webpack-dev-middleware");
const webpack = require("webpack");
const config =require("./webpack.config.js")
const dist = webpack(config)
const app = express();
app.use(webpackDevMiddleWare(dist));
app.listen(2000);

开启服务器

1
npx webpack-dev-server

运行这个命令不仅会启动Webpack的打包过程(打包到内存,不输出实际文件),还会启动一个开发服务器,部署的是打包到内存中的文件。这个服务器会监听源文件(src目录下的js文件)的变化,源文件修改并保存会自动重新编译和刷新浏览器。

值得注意的是,如果修改了wepack配置文件,就需要重启wepack服务器。

热更新

配置hot:true,即开启热更新,我们要将热更新和强制更新区分开。热更新指的是在不刷新浏览器的前提下更新页面,而强制更新是通过自动刷新页面来更新页面,二者都不需要我们手动刷新页面。区别在于前者能保持当前页面的状态,后者不能。

通常来说更改了除js之外的代码(比如css代码)使用的是热更新,修改js代码使用的是强制更新(无论是否开启热更新)。

proxy

在webpack中开启proxy,就是我们的webpack服务器帮我们发送请求,然后返回响应,简单的来说就是本地的webpack服务器帮助我们代理请求,目的是解决跨域问题

sourceMap

1
2
3
4
5
// webpack.config.js
module.exports = {
mode: 'development',
devtool: 'source-map', // 或 'eval-source-map', 'cheap-module-source-map'
}

配置了sourceMap,开发过程中遇到了错误就能定位到源码的位置,而不是定位到打包后的代码的位置。

static

指定开发环境中的一个静态资源(比如图片,字体,视频)目录的路径,在vue和react中,这个静态资源目录都是public目录,存放不需要经过构建处理的静态资源,构建时直接复制到输出目录(如 dist)。

plugin

是什么

webpack构建过程中会广播很多事件,plugin可以监听自己感兴趣的事件,在合适的时机通过Webpack提供的 API改变最后的打包结果。

插件本质是一个,插件实例其本质是一个具有apply方法javascript对象,apply方法被调用的时候会传入compiler对象

1
2
3
4
5
6
7
8
9
10
11
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
//tap方法是用来注册一个函数,当某个特定的钩子被触发时,这个函数就会被执行
compiler.hooks.run.tap(pluginName, (compilation) => {
console.log('webpack 构建过程开始!');
});
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;

配置插件,就是在webpack构建过程中的某个事件上注册回调函数。

和loader的区别:loader主要负责文件转换,提高webpack的模块化能力,而插件主要负责解决loader功能以外的问题;loader 运行在打包文件之前(因为webpack只能处理js文件,所以需要在打包前处理其他类型的文件),plugins 在整个打包周期都起作用。

webpack-bundle-analyzer

可以帮助开发者可视化和分析 Webpack 打包后的文件大小和内容,它生成一个交互式的报告,显示每个模块的大小及其在最终打包文件中的占比,从而帮助识别和优化代码。

安装:

1
npm i webpack-bundle-analyzer -D

配置:

1
2
3
4
const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer')
module.exports = {
plugins:[new BundleAnalyzerPlugin()]
}//注册

terse-webpack-plugin

用来压缩js代码,但是Webpack 5 内置了对 Terser 的支持,在生产模式下会自动使用 Terser 进行js代码压缩,这一功能已经是webpack核心功能的一部分。但是如果需要自定义 Terser 的选项,仍然需要安装 terser-webpack-plugin 并进行相应的配置。这时,terser-webpack-plugin 是作为一个第三方插件来使用的。

webpack的构建流程

初始化阶段

合并指令中的配置参数和配置文件中的配置参数,得到最终的配置对象options

根据options对象初始化Compiler对象:该对象掌控者webpack生命周期(所有生命周期钩子都在这个对象上)。

初始化插件,注册事件监听:步骤就是执行 new MyPlugin() 并调用插件的 apply 方法,传入 compiler对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Compiler extends Tapable {
constructor(context, options) {
super();
this.hooks = {
beforeCompile: new AsyncSeriesHook(["params"]),
compile: new SyncHook(["params"]),
afterCompile: new AsyncSeriesHook(["compilation"]),
make: new AsyncParallelHook(["compilation"]),
entryOption: new SyncBailHook(["context", "entry"])
//后续还定义了很多不同类型的钩子
};
this.options = options;
this.context = context;
}
}

function webpack(options) {
var compiler = new Compiler(context, options);
// 检查options, 若watch字段为true, 则开启watch线程
// 后续的代码就是根据options来配置compiler
return compiler;
}
...
1
2
3
4
5
6
7
8
9
10
11
12
class MyPlugin {
// Webpack 会调用 MyPlugin 实例的 apply 方法给插件实例传入 compiler 对象
// 所以初始化插件的时候,
apply (compiler) {
// 找到合适的事件钩子,并监听,实现自己的插件功能
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation: 当前打包构建流程的上下文
console.log(compilation);
// do something...
})
}
}

emit 钩子:是一个特殊的钩子,它在 Webpack 准备好要输出所有资源文件到磁盘之前触发,允许插件作者在这个关键时刻介入处理或修改即将输出的内容。

tap 方法:用来注册一个回调函数到特定的 Webpack 钩子上,使得当这个钩子被触发时,能够执行你的自定义逻辑。

这个方法有2个参数,第一个参数是字符串,用来标识哪些插件,在这个事件上注册了事件监听,第二个参数则是一个回调函数,用来自定义事件触发后执行的逻辑。

编译阶段

创建并初始化好compiler对象后,就会调用compiler.run方法,创建一个Compilation对象

compilation是编译阶段的主要执行者,主要会依次执行下述流程:执行模块创建、依赖收集、分块、打包等主要任务。

当创建了上述的compilation对象后,就开始从Entry入口文件开始读取,调用配置的loader构建模块,构建好模块后,进行依赖分析,发现其他模块,然后才对其他模块递归使用loader构建模块,再进行依赖分析,以此类推,就能构建完所有模块,并构建好模块依赖图,然后进行打包输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
_addModuleChain(context, dependency, onModule, callback) {
//省略了许多代码
const afterBuild = () => {
//构建完模块之后,再分析模块的依赖
this.processModuleDependencies(module, (err) => {
if (err) return callback(err);
callback(null, module);
});
};
// 构建模块的过程中,Webpack 会读取模块内容并应用指定的 loaders。
// 调用配置的`loaders`,将我们的模块转成标准的`JS`模块(立即执行函数)
this.buildModule(module, false, null, null, (err) => {
if (err) return callback(err);
//就是上面定义的箭头函数
afterBuild();
});
}

打包并输出

打包指的就是seal,打包打包的是所有模块,打包的结果是一个或者多个chunk,每个chunk最终都会被输出为单独的文件,模块数目无论如何都是大于chunk数目的。

chunk

  • webpack 中的 chunk ,可以理解为配置在 entry(入口文件) 中的模块,或者是动态引入的模块。

  • 每个 chunk 可以包含一个或多个模块,并且可以每个chunk可以被单独加载

  • 每个入口点都会创建一个初始 chunk。

  • import() 动态加载模块时,会创建异步 chunks,它们是按需加载的。

  • 开发者可以通过配置,让 Webpack 根据某些规则自动分割代码到不同的 chunks 中。

输出指的就是emit,在确定好输出内容后,根据配置的输出的路径和文件名,把chunk输出为真实的文件。

使用webpack如何前端性能

通过webpack优化前端的手段有:

  • JS,CSS,Html代码压缩
  • 文件大小压缩(区别于代码压缩,会改变文件的后缀)
  • 图片压缩
  • Tree Shaking
  • 代码分离

TreeShaking

Tree Shaking 是一个术语,在计算机中表示消除死代码(一般指的是js代码),基于ES Module的静态语法分析(不执行任何的代码,可以明确知道模块的依赖关系)

在 Webpack5 中,Tree Shaking 在生产环境下默认启动,这就意味着不需要配置usedExports,同时还会自动启用代码压缩。

usedExports 用于在 Webpack 编译过程中启动标记功能,使用之后,没被用上的变量/函数(包括未导入的函数/变量和导入后未使用的函数/变量),在webpack打包中,会被加上unused harmony export注释,当生成产物时,被标记的变量/函数对应的导出语句会被删除。当然,仅仅删除未被使用的变量/函数导出语句显然是不够看的,若 Webpack 配置启用了代码压缩工具,如 Terser 插件,那么在打包的最后它还会删除所有引用被标记内容的代码语句,这些语句一般称作 Dead Code。可以说,真正执行 Tree Shaking 操作的是 Terser 插件。

如下面sum函数没被用到,webpack打包会添加注释,terser在优化时,则将该函数连同引用该函数的代码删除掉。

要注意的是,上述注释只有在开发打包下,开启usedExports,不开启代码压缩,才能看到。

但是,并不是所有 Dead Code 都会被 Terser 删除

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/math.js

export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
console.log(square(10));

// src/index.js
import { cube } from './math.js';
console.log(cube(5));

在上述代码中,由于square函数没有被导入,自然也就不会在index.js中被使用,所以这个函数会被标记,然后由于console.log(square(10))引用了这个函数,所以它会被标记为Dead Code,最终在压缩js代码的时候会被删除。

然而实际上,并非如此,打包后的math.js 模块中:square 函数的痕迹被完全清除,但是打印语句仍然被保留,这是因为,这条语句存在副作用

副作用(side effect) 的定义是,在导入时会执行特殊行为的代码(不是export,而是比如调用函数之类的代码)。

显然,以上示例的 console.log() 语句存在副作用。Terser 在执行 Tree Shaking 时,会保留它认为存在副作用的代码,而不是将其删除,即便这个代码是Dead code。

PURE注释

作为开发者,如果你非常清楚某条语句会被判别为有副作用,但其实是无害的(删除后无影响),应该被删除,可以使用 /*#__PURE__*/ 注释,来向 terser 传递信息,表明这条语句是pure的,没有副作用,terser 可以放心将它删除:

1
2
3
4
5
6
7
8
// src/math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
/*#__PURE__*/ console.log(square(10));

然后在打包结果中就不会有console.log语句

sideEffects

sideEffects用于告知webpack compiler哪些模块是有副作用,区别于pure注释的代码层面,

"sideEffects"package.json 的一个字段,默认值为 true,即认为所有模块都可能是有副作用的。如果你非常清楚你的 package 是纯粹的,不包含副作用,那么可以简单地将该属性标记为 false,来告知 webpack 整个包都是没有副作用的,可以安全地删除所有未被使用的代码(Dead Code),执行比较激进的tree-shaking;如果你的 package 中有些模块确实有一些副作用,可以改为提供一个数组:

1
2
3
4
"sideEffects":[
"./src/util/format.js",
"*.css" // 所有的css文件
]

更多内容参考:Webpack 5 实践:你不知道的 Tree Shaking本篇文章从 什么是 Tree Shaking、如何使用 T - 掘金

总结一下关键点

tree-shaking的作用是用来删除未使用的js代码,减小最终代码的体积

tree-shaking基于esm的静态导入,即在编译的时候,就能确定哪些模块的具体哪些功能被使用

生产模式打包,会默认开启tree-shaking,开发模式打包,需要手动开启tree-shaking,即设置usedExports属性为true。

默认情况,tree-shaking会标记模块中未被导入的变量和函数,以及导入了但是没有被使用的变量和函数,然后使用terser压缩js代码的时候,不仅仅会删除这些被标记的的变量和函数,还会删除引用了这些被标记的变量和函数的代码,这些代码被叫做dead code

但是如果某条dead code被认为是有副作用的,则不会被tree-shaking删除,比如console.log语句

使用webpack搭建vue脚手架

npm包下载

webpack开发必备:

1
npm i webpack webpack-cli html-webpack-plugin webpack-dev-server -D

vue相关:

1
npm i vue-template-compiler vue-loader -D

vue-template-compiler用来解析vue模板

vue-loader用来解析.vue文件

配置vue-loader:

1
2
3
4
5
6
7
8
9
const {VueLoaderPlugin} = require('vue-loader') //vue-loader还包含一个plugin
module.exports = {
plugins:[
new VueLoaderPlugin() //作用是让其他文件的(js,scss文件)的解析规则复用到解析vue文件
],
module:{
rules:[ {test:/\.vue$/,use:'vue-loader'} ]
}
}

css相关:

1
npm i css-loader mini-css-extract-plugin -D

sass相关:

1
npm i sass sass-loader -D

babel相关:

1
npm i babel-loader @babel/core @babel/preset-env -D

生产环境相关

1
npm i vue axios vue-router

注意vue下载的是vue2

模拟vue-cli打包

1
2
3
4
5
6
{
"scripts": {
"serve": "webpack-dev-server --mode development",
"build": "webpack --mode production"
}
}

完整配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const {VueLoaderPlugin} = require('vue-loader')
module.exports = {
entry:'./src/main.js',
output:{
filename:'main.js',
path:path.resolve(__dirname,'dist'),
clean:true
},
plugins:[
new HtmlWebpackPlugin({
template:'./src/index.html',
filename:'index.html',
inject:'body'
}),
new MiniCssExtractPlugin({
filename:'styles/main.css'
}),
new VueLoaderPlugin()//作用是让其他文件的(js,scss文件)的解析规则复用到解析vue文件
],
module:{
rules:[
{
test:/\.s[ca]ss$/,
use:[ MiniCssExtractPlugin.loader,'css-loader','sass-loader'] //先使用sass-loader再使用css-loader
},
{
test:/\.vue$/,
use:'vue-loader'
},
{
test:/\.js$/,
exclude:/node_modules/,
use:{
loader:'babel-loader',
options:{
presets:['@babel/preset-env']
}
}
}
]
},
mode:'development',
devServer:{
static:'./dist'
}
}

RollUp

打包后的代码更简洁,就像源代码一样,不像webpack那样存在大量引导代码和模块函数,打包效率更高。但是rollup的缺点也很明显,本身只支持打包js,实现其他功能都需要借助插件,而且插件生态也不好。

Vite

特点

特点是开箱即用,0配置:天生支持CSS和各种预处理语言,支持TS,能处理各种资源(比如图片),不需要做任何配置,直接引入即可

常见配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
server: {
port: 3000, // 端口, 默认 5173,可自定义
open: true, // 启动时自动打开浏览器
cors: true, // 启用 CORS
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api/v1')
}
},
hmr: {
overlay: true // 错误时显示全屏遮罩
}
},
build: {
outDir: 'dist', // 输出目录
assetsDir: 'static', // 静态资源目录
sourcemap: false, // 是否生成 source map,生产环境通常关闭,防止源码泄露
minify: 'terser', // 使用 terser 压缩(支持更多优化)
chunkSizeWarningLimit: 500, // 分块警告阈值(KB),某个打包后的文件超过 500KB 给出警告
rollupOptions: {
output: {
manualChunks(id) {
// 匹配所有 node_modules 中的模块
if (id.includes('node_modules')) {
// 匹配 vue, vue-router, pinia,element-plus → vendor
// 将这些第三方的包都打包到一个js文件中去
if (
id.includes('vue') || id.includes('vue-router') || id.includes('pinia')
|| id.includes('element-plus')
) {
return 'vendor'
}
}
},
// 显式设置 chunk 文件名,便于查看
chunkFileNames: 'assets/js/[name]-[hash].js', //入口文件名
entryFileNames: 'assets/js/[name]-[hash].js' //其他拆分出来的模块名
}
}
},
plugins: [vue()], //配置插件@vitejs/plugin-vue,只有这样才能处理vue文件。
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
}
}
})

其中,server 控制 Vite 开发服务器 的行为,build 控制 Vite 构建打包 的行为, rollupOptions 是 Vite 提供给用户的入口,让你可以在 Vite 的构建流程中,直接控制底层 Rollup 的行为。

与webpack的区别

loader

在vite中没有loader,对于vite无法处理的文件类型,使用插件来解决。具体的来说,使用插件来拦截对模块的请求,然后动态转换为浏览器可识别的格式。

1
2
3
4
// 插件机制示意
resolveId(id) // 解析模块 ID
load(id) // 加载模块内容
transform(code, id) // 转换代码(相当于 loader)

启动速度

vite

我们先介绍一下浏览器的原生模块系统。

目前,绝大多数现代浏览器都已经支持ES module了, 我们只需要在<script>标签中添加type="module",内部就能书写ES module代码了。

1
2
3
<script type="module">
import { foo } from './foo.js';
</script>

浏览器遇到 import 导入语句时,会自动发起 HTTP 请求,去加载对应的模块文件(如 foo.js),每个模块可以继续导入其他模块,形成依赖树,所有模块都是 按需异步加载 的,不会阻塞主页面渲染。这就是浏览器原生支持的“模块系统”,不需要打包工具也可以运行。

Vite 利用的是 原生 ES Module 的加载机制 + 请求拦截 + 即时编译,启动开发服务器时不需要打包,请求哪个模块就编译哪个模块,编译成标准的esm。

请求路径服务器如何响应
/src/main.js直接返回文件内容
/src/App.vue拦截,编译 .vue 文件 → 返回 JS
/src/style.css拦截,编译 CSS → 返回 CSS

举个例子说明这个过程,假设项目结构如下:

1
2
3
4
5
6
my-vue-app/
├── index.html
├── main.js
├── utils.js
└── components/
└── HelloWorld.vue

index.html文件内容如下

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.js"></script>
</body>
</html>

main.js文件内容如下

1
2
3
4
import { sayHello } from './utils.js';
import HelloWorld from './components/HelloWorld.vue';

sayHello();

.utils.js 文件内容如下

1
2
3
export function sayHello() {
console.log('Hello from utils!');
}

HelloWorld.vue 内容如下

1
2
3
<template>
<div>Hello Vue!</div>
</template>

当运行 npm run dev 启动 Vite 时发生了什么?

第一步:启动开发服务器:Vite 启动了一个本地开发服务器,默认监听 localhost:5173。它并没有像 Webpack 那样一次性打包所有文件。

第二步:浏览器访问 index.html,浏览器开始解析 HTML,遇到:

1
<script type="module" src="/main.js"></script>

于是发起 HTTP 请求获取 /main.js请求的是本地开发服务器,请求的内容是一个模块

第三步:Vite搭建的服务器拦截请求并处理,发现main.js是一个标准的esm,所以直接返回 main.js 的内容

第四步:浏览器执行 main.js,由于main.js中导入了其他模块,所以触发更多 import,浏览器执行到:

1
import { sayHello } from './utils.js';

这时浏览器会自动发出新的 HTTP 请求:GET /utils.js,Vite 拦截这个请求,找到 utils.js 文件,直接返回即可(因为它已经是标准 JS,无需编译)。

第五步:加载 .vue 文件:当浏览器执行到:

1
import HelloWorld from './components/HelloWorld.vue';

浏览器会发起请求:GET /components/HelloWorld.vue,这时问题来了,.vue 文件不是原生 ESM 模块,浏览器无法识别,于是Vite使用插件(比如 @vitejs/plugin-vue将 .vue 文件编译成标准的 ESM 模块,然后把编译后的 JS 返回给浏览器

思考

那如果所有模块都被静态 import 了呢?当模块多的时候会有很多请求,但现代浏览器和 HTTP/2 能很好地处理大量小请求,所以这个缺点可接受。

缓存机制:vite利用浏览器的缓存策略,针对源码模块(我们自己写的代码)做了协商缓存处理,针对依赖模块(第三方库)做了强缓存处理,这样我们项目的访问的速度也就更快了。

webpack

而webpack首次启动开发服务器,在分析好模块的依赖关系后,进行一次全量的打包,存储到内存中等待服务器请求,所以webpack开发服务器的启动速度慢

热更新速度

vite

  • 在Vite中某个模块被修改,会被Vite 监听到,然后通过 WebSocket主动通知浏览器:“这个文件变了”
  • 浏览器重新发起对该模块的请求(例如 /utils.ts),Vite 编译该模块后返回新内容
  • 浏览器执行新的模块代码并更新页面

整个过程不需要重新打包整个应用,所以vite的热更新极快。

webpack

在webpack中,后续某个模块被修改,重新编译被修改的模块和依赖于它的模块(增量编译,其他未修改的模块使用缓存),然后再进行一次打包。因为需要打包,所以webpack的热更新速度较慢。

打包速度

vite生产阶段使用rollup来打包。rollup打包后的代码非常简洁,完成不像webpack那样存在大量引导代码和模块函数,所以使用rollup的打包速度更快。

引导代码 = Webpack 自带的“运行时系统”,用于管理模块加载、缓存、依赖解析等。比如__webpack_require__表示核心模块加载函数,__webpack_modules__用来存放所有模块的代码,这些代码是 Webpack 能运行的根本,但它们不是你写的业务代码。

模块函数 = 每个模块被包装成的一个函数,由__webpack_require__调用。

1
2
3
4
5
__webpack_modules__["./src/utils.js"] = function(module, exports, __webpack_require__) {
// 你的源码被包裹在这里
const utils = () => console.log('utils');
exports.utils = utils;
};

其他

vite的入口文件是index.html而大部分的打包工具比如webpack的入口文件是一个js文件。

参考文章:终于搞明白,vite为什么比webpack快了!运行原理、构建方式等方面到底有什么不同! - 知乎