谈谈你对webpack的理解
用于现代JavaScript
应用程序的静态模块打包工具,webpack主要是用来解决模块化
打包问题的。
什么是模块
将某一个复杂的项目,按照某种规则或者规范划分为多个文件,每个文件就是一个模块,模块内部是数据是私有的。
JS模块化实现历程
通过script标签引入js文件
早期模块化的方式中,每个能实现某些功能js文件被设计为一个单独的模块,然后通过script标签
引入
1 | <script src="module-a.js"></script> |
这种方式的缺点很明显:
- 被引入后,模块中的变量都成为全局变量,存在变量污染问题
- 模块中的变量可以被随意修改
- 模块之间依赖关系不明显
命名空间
随后,就出现了命名空间方式,规定每个模块只暴露一个全局对象,然后模块的内容都挂载到这个对象中。
1 | //moduleA.js |
这样在很大程度上解决了全局变量污染的问题,但是也没有解决其他问题。
立即执行函数
后来又选择用立即执行函数为模块添加私有空间
, 解决了内部数据可以被随意修改的问题,还解决了全局变量污染的问题。因为函数作用域中的变量是私有的。
1 | //moduleA.js |
支持传入参数,能在一定程度上解决模块依赖问题,但是必须注意引入模块的先后顺序
,否则就会出现undefined
的问题。
模块化规范
理想的解决方式是,在页面中通过script标签引入一个JS入口文件
,其余用到的模块可以通过代码控制,按需加载进来。
除了模块加载的问题以外,还需要规定模块化的规范,如今流行的则是CommonJS
、ES Modules
,关于二者的详细介绍参考本博客内的
我们上述讨论的模块化的范围只限于js
文件,后来html,css等文件也可以被模块化,这就需要借助webpack
。
模块化的好处
解决了全局变量污染的问题,提高了代码的可维护性与复用性,使得项目中文件的依赖关系明确,支持按需加载。
webpack的构建流程
初始化阶段
简单来说就做了这些事
- 得到options配置对象
- 调用
new Compiler()
,并传入options,创建compiler对象 - 初始化插件(plugins)
合并配置文件
和shell语句
,Shell 语句指的是在命令行界面(CLI)或脚本中使用的指令中的配置参数,得到最终的配置对象options
完成上述步骤之后,创建,并根据options
对象初始化Compiler
对象,该对象掌控者webpack
生命周期(所有生命周期 钩子都在这个对象上),不执行具体的任务,只是进行一些调度工作
。
初始化插件,步骤就是执行 new MyPlugin()
并调用插件的 apply
方法,传入 compiler对对象,注册回调函数
1 | class Compiler extends Tapable { |
1 | class MyPlugin { |
emit
钩子:是一个特殊的钩子,它在 Webpack 准备好要输出所有资源文件到磁盘之前触发,允许插件作者在这个关键时刻介入处理或修改即将输出的内容。
tap
方法:用来注册一个回调函数到特定的 Webpack 钩子上,使得当这个钩子被触发时,能够执行你的自定义逻辑。这个方法有2个参数,第一个参数是字符串,用来标识哪些插件,在这个事件上注册了事件监听,第二个参数则是一个回调函数,用来自定义事件触发后执行的逻辑。
编译阶段
compile 编译
创建并初始化好comiler对象后,就会调用compiler.run
方法,run方法又会触发compiler.compile
,主要是创建一个Compilation
对象
1 | class Compiler extends Tapable { |
compilation
是编译阶段的主要执行者,主要会依次执行下述流程:执行模块创建
、依赖收集
、分块、打包等主要任务。
make 编译模块
当创建了上述的compilation
对象后,就开始从Entry
入口文件开始读取,主要执行_addModuleChain()
函数,如下:
1 | _addModuleChain(context, dependency, onModule, callback) { |
this.buildModule的主要作用,调用配置的loaders
,将我们的模块转成标准的JS
模块(立即执行函数)
模块构建完成后,开始分析模块的依赖关系,对应上述代码中的 this.processModuleDependencies
。
从配置的入口模块开始,分析其 AST
,当遇到require
等导入其它模块的语句时,便将其加入到依赖的模块列表,如果有需要,则递归构建依赖的模块。
webpack是先使用loader
处理入口文件
,构建好模块后,进行依赖分析
,发现其他模块,然后才对其他模块递归使用loader构建模块,再进行依赖分析,以此类推,就能构建完所有模块,并构建好模块依赖图。
打包并输出
打包指的就是seal
,打包打包的是所有模块,打包的结果是一个或者多个chunk,每个chunk最终都会被输出为单独的文件,模块数目无论如何都是大于chunk数目的。
输出指的就是emit
,指的是把chunk输出为真实的文件。
seal
seal
方法主要任务是生成chunks
,对chunks
进行一系列的优化操作
,并生成要输出的代码
根据入口文件和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
(如果只有一个入口文件,一般只有一个chunk),再把每个 Chunk
转换成一个单独的文件。
举例说明:
1 | /*webpack.config.js*/ |
1 | /* main.js */ |
1 | /* con.js */ |
着运行 npm run build
进行打包,最后只会得到一个bundle.js
文件,打开bundle.js
,可以看到con.js
代码被嵌入bundle.js
。
1 | ``` |
在 Compiler
开始生成文件前,钩子 compiler.hooks.emit
会被执行,这是我们修改最终文件的最后一个机会
从而webpack
整个打包过程则结束了
总结
合并shell语句中的参数和webpack配置文件中的配置参数,得到options
对象。根据options对象创建compiler对象,调用compiler
对象的run
方法,开始真正的编译过程。从入口文件出发,使用对应的loader
构建模块,再分析模块的依赖关系,构建模块依赖图。如果有需要,再递归的构建模块,直至所有模块构建完毕,模块依赖图也构建完成。
说说webpack中常见的Loader?解决了什么问题?
是什么
loader
本质是一个函数,用于对文件
的源代码
进行转换,使之变为webpack可用的模块
,在 import
或加载
模块时预处理文件
webpack
做的事情,仅仅是分析出各种模块的依赖关系
,然后形成资源列表,最终打包生成到指定的文件中。如下图所示:

在webpack
内部中,任何文件
都是模块
,不仅仅只是js
文件,这得益于loader扩大了模块化的范围
默认情况下,在遇到import
或者require
加载模块的时候,webpack
只支持对js
和 json
文件打包
像css
、sass
、png
等这些类型的文件的时候,webpack
则无能为力,这时候就需要配置对应的loader
进行文件内容的解析
配置方式
推荐在配置文件中配置,rules
是一个数组,意味着我们可以给多种文件配置loader
,每一类文件对应一个对象。
use
也是一个数组,这意味着我们可以对任意一种文件使用多个loader
,每个loader
是一个对象的格式,loader
是支持链式调用
的,调用的顺序是从右至左
的。
1 | module.exports = { |
常见的loader
css-loader
分析 css
模块之间的关系,并合成⼀个 css
1 | npm install --save-dev css-loader |
如果只通过css-loader
加载文件,这时候页面代码设置的样式并没有生效
原因在于,css-loader
只是负责将.css
文件进行一个解析,而并不会将解析后的css
插入到页面中
如果我们希望再完成插入style
的操作,那么我们还需要另外一个loader
,就是style-loader
style-loader
把 css-loader
生成的内容,用 style
标签挂载到页面的 head
中,简单来说就是把css-loader生成的css代码内联到html文件中。
1 | npm install --save-dev style-loader |
1 | rules: [ |
less-loader
开发中,我们也常常会使用less
、sass
、stylus
预处理器编写css
样式,使开发效率提高,这里需要使用less-loader
1 | npm install less-loader -D |
1 | rules: [ |
编写loader
在编写 loader
前,我们首先需要了解 loader
的本质
其本质为函数,函数中的 this
作为上下文会被 webpack
填充,指向 webpack
提供的对象,能够获取当前 loader
所需要的各种信息,因此我们不能将 loader
设为一个箭头函数
函数接受一个参数source,为 webpack
传递给 loader
的文件源内容
函数中有异步操作或同步操作,异步操作通过 this.callback
返回,返回值要求为 string
或者 Buffer
代码如下所示:
1 | // 导出一个函数,source为webpack传递给loader的文件源内容 |
1 | module.exports = function(source, inputSourceMap) { |
一般在编写loader
的过程中,保持功能单一,避免做多种功能
如less
文件转换成 css
文件也不是一步到位,而是 less-loader
、css-loader
、style-loader
几个 loader
的链式调用才能完成转换
说说webpack中常见的Plugin?解决了什么问题?
是什么
Plugin
(Plug-in)是一种计算机应用程序,它和主应用程序
互相交互,以提供特定的功能
是一种遵循一定规范的应用程序接口编写出来的程序,只能运行在程序规定的系统下,因为其需要调用原纯净系统
提供的函数库
或者数据
webpack
中的plugin
也是如此,plugin
赋予其各种灵活的功能,例如打包优化、资源管理、环境变量注入等,它们会运行在 webpack
的不同阶段(钩子 / 生命周期),贯穿了webpack
整个编译周期
主要用来解决loader
无法解决的其他事情,本质是一个具有apply
方法的js对象
(区别于vue的插件本质是一个具有install
方法的对象),这个方法会被compiler
对象调用。webpack构建过程中会广播
很多事件,plugin可以监听
自己感兴趣的事件,从而改变最后的打包结果。
配置方式
这里讲述文件的配置方式,一般情况,通过配置文件导出对象中plugins
属性传入new
实例对象。如下所示:
1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); // 通过 npm 安装 |
特性
插件本质是一个类,插件实例其本质是一个具有apply
方法javascript
对象
apply方法被调用的时候会传入compiler
对象
1 | const pluginName = 'ConsoleLogOnBuildWebpackPlugin'; |
tap
方法是用来注册一个函数,当某个特定的钩子被触发时,这个函数就会被执行。你可以把它看作是一种订阅模式,你的插件“订阅”了特定事件,并提供了一个回调函数,在该事件发生时执行。
compiler hook
的 tap
方法的第一个参数,应是驼峰式命名的插件名称
关于整个编译生命周期钩子
(hooks),有如下:
- entry-option :初始化 option
- run:会在 Webpack 开始编译之前触发
- compile: 真正开始的编译,在创建 compilation 对象之前
- compilation :生成好了 compilation 对象
- make:从 entry 开始递归分析依赖,准备对每个模块进行 build
- after-compile: 编译 build 过程结束
- emit :在将内存中 assets 内容写到磁盘文件夹之前
- after-emit :在将内存中 assets 内容写到磁盘文件夹之后
- done: 完成所有的编译过程
- failed: 编译失败的时候
常见的plugin
html-webpack-plugin:
在打包结束后,自动生成⼀个
html
文件,并自动引入
打包后的js,css文件(自动注入到head标签中)。1
2
3
4
5
6
7
8
9
10const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
plugins: [
new HtmlWebpackPlugin({
title: "My App",
filename: "app.html",
template: "./src/html/index.html"
})
]
};mini-css-extract-plugin:
提取
CSS
代码到一个单独的文件中,通常用来代替style-loader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
module: {
rules: [
{
test: /\.s[ac]ss$/,
use: [MiniCssExtractPlugin.loader,'css-loader','sass-loader']
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css'//放置提取出的css代码的css文件的文件名
})
]
}
更多的常见plugin可参考:webpack基础 | 三叶的博客
编写plugin
由于webpack
基于发布订阅模式,在整个编译周期
中会广播
出许多事件,插件通过监听感兴趣的事件,并调用webpack提供的api
,就可以在特定的阶段执行自己的插件任务。
在之前也了解过,webpack
编译会创建两个核心对象:
compiler
:包含了 webpack 环境的所有的配置信息,包括 options,loader 和 plugin,和 webpack 整个生命周期相关的钩子compilation
:作为 plugin内置事件回调函数的参数,包含了当前的模块资源、编译生成资源、变化的文件以及被跟踪依赖的状态信息。当检测到一个文件变化,一次新的 Compilation 将被创建
如果自己要实现plugin
,也需要遵循一定的规范:
- 插件必须是一个
函数
或者是一个包含apply
方法的对象,这样才能访问compiler
实例 - 传给每个插件的
compiler
和compilation
对象都是同一个引用,因此不建议修改 - 异步的事件需要在插件处理完任务时,调用回调函数通知
Webpack
进入下一个流程,不然会卡住
实现plugin
的模板如下:
1 | class MyPlugin { |
emit
钩子:是一个特殊的钩子,它在 Webpack 准备好要输出所有资源文件到磁盘之前触发,允许插件作者在这个关键时刻介入处理或修改即将输出的内容。
tap
方法:用来注册一个回调函数到特定的 Webpack 钩子上,使得当这个钩子被触发时,能够执行你的自定义逻辑。
说说Loader和Plugin的区别?
前面两节我们有提到Loader
与Plugin
对应的概念,先来回顾下
loader 是文件加载器,能够加载资源文件,并对这些文件进行一些处理,诸如编译、压缩等,最终一起打包到指定的文件中
plugin 赋予了 webpack 各种灵活的功能,例如打包优化、资源管理、环境变量注入等,目的是解决 loader 无法实现的其他事,
比如提取css代码到一个单独的文件。
从整个运行时机上来看,如下图所示:

可以看到,两者在运行时机
上的区别:
- loader 运行在打包文件之前
- plugins 在
整个编译周期
都起作用
在Webpack
运行的生命周期中会广播出许多事件,Plugin
可以监听这些事件,在合适的时机通过Webpack
提供的 API
改变输出结果
对于loader
,实质是一个转换器,将A文件进行编译形成B文件,操作的是文件,比如将A.scss
或A.less
转变为B.css
,单纯的文件转换过程。
webpack类似的工具还有哪些?区别?
模块化是一种处理复杂系统分解为更好的可管理模块的方式
每个模块完成一个特定的子功能,所有的模块按某种方法组装起来,成为一个整体(bundle
)
在前端领域中,并非只有webpack
这一款优秀的模块打包工具,还有其他类似的工具,例如Rollup
、Parcel
、snowpack
,以及最近风头无两的Vite
这里没有提及gulp
、grunt
是因为它们只是定义为构建工具
,不能类比,关于gulp
的介绍可参考hexo博客搭建的一些思考 | 三叶的博客
Rollup

Rollup
是一款 ES Modules
打包器,从作用上来看,Rollup
与 Webpack
非常类似。不过相比于 Webpack
,Rollup
要小巧的多
现在很多我们熟知的库都都使用它进行打包,比如:Vue
、React
和three.js
等
举个例子:
1 | // ./src/messages.js |
1 | // ./src/logger.js |
1 | // ./src/index.js |
然后通过rollup
进行打包,把index.js
文件和它依赖的模块打包成一个chunk
,结果如下:
1 | const log = msg => { |
可以看到,代码非常简洁,完成不像webpack
那样存在大量引导代码和模块函数,并且error
方法由于没有被使用,输出的结果中并无error
方法,可以看到,rollup
默认使用Tree-shaking
优化输出结果。
因此,可以看到Rollup
的优点:
- 打包后的代码更简洁、打包效率更高
- 默认支持 Tree-shaking
但缺点也十分明显,不能处理其他类型的资源文件
和 CommonJS
模块,又或是编译 ES
新特性,这些额外的需求 ,Rollup
需要使用插件去完成。
综合来看,rollup
并不适合开发应用,因为需要使用第三方模块,而目前第三方模块大多数使用CommonJs
方式导出成员,并且rollup
不支持HMR
,使开发效率降低,所以vite只在生产打包的时候使用rollup
但是在用于打包JavaScript
库时,rollup
比 webpack
更有优势,因为其打包出来的代码更小、速度更快,其存在的缺点可以忽略。
Parcel

Parcel ,是一款完全零配置
的前端打包器,它提供了 “傻瓜式” 的使用体验,只需了解简单的命令,就能构建前端应用程序。
Parcel
跟 Webpack
一样都支持以任意类型文件
作为打包入口,但建议使用HTML
文件作为入口
1 | <!-- ./src/index.html --> |
main.js文件通过ES Moudle
方法导入其他模块成员
1 | // ./src/logger.js |
1 | // ./src/main.js |
运行之后,使用命令打包
1 | npx parcel src/index.html |
执行命令后,Parcel
不仅打包了应用,同时也启动了一个开发服务器
,跟webpack Dev Server
一样
跟webpack
类似,也支持模块热替换(HMR)
,但用法更简单
同时,Parcel
有个十分好用的功能:支持自动安装依赖
,像webpack
开发阶段突然需要安装某个第三方依赖,必然会终止dev server
然后安装再启动。而Parcel
则免了这繁琐的工作流程。
同时,Parcel
能够零配置加载其他类型的资源文件,无须像webpack
那样配置对应的loader
由于打包过程是多进程
同时工作,构建速度会比Webpack
快,输出文件也会被压缩
,并且样式代码也会被单独提取到单个文件
中
可以感受到,Parcel
给开发者一种很大的自由度,只管去实现业务代码,其他事情用Parcel
解决
Snowpack

Snowpack,是一种闪电般快速
的前端构建工具,专为现代Web
设计,较复杂的打包工具(如Webpack
或Parcel
)的替代方案,利用JavaScript
的本机模块系统,避免不必要的工作并保持流畅的开发体验。
开发阶段,每次保存单个文件
时,Webpack
和Parcel
都需要重新构建
和重新打包
应用程序的整个bundle
,这个过程包括:重新解析依赖关系,重新优化和压缩,重新生成资源文件。而Snowpack
为你的应用程序每个文件
构建一次,就可以永久缓存,文件更改时,Snowpack
只会重新构建该单个文件
。
Webpack
相比上述的模块化工具,webpack
大而全,很多常用的功能做到开箱即用。有两大最核心的特点:一切皆模块和按需加载
与其他构建工具相比,有如下优势:
- 智能解析:对 CommonJS 、 AMD 、ES6 的语法做了兼容
- 万物模块:对 js、css、图片等资源文件都支持打包,不过需要通过配置loader来实现
- 开箱即用:HMR、Tree-shaking等功能
- 代码分割:可以将
代码切割
成不同的 chunk,实现按需加载,降低了初始化时间 - 插件系统,具有强大的 Plugin 接口,具有更好的灵活性和扩展性
- 易于调试:支持 SourceUrls 和 SourceMaps
- 快速运行:webpack 使用异步 IO 并具有多级缓存,这使得 webpack 很快且在增量编译上更加快
- 生态环境好:社区更丰富,出现的问题更容易解决
说说webpack的热更新是如何做到的?原理是什么?
是什么
HMR
全称 Hot Module Replacement
,可以理解为模块热替换
,指在应用程序运行过程中,替换、添加、删除模块,而无需重新刷新整个应用。例如,我们在应用运行过程中修改了某个模块,通过自动刷新,会导致整个应用的整体刷新,那页面中的状态信息都会丢失
如果使用的是 HMR
,就可以实现只将修改的模块实时替换至应用中,不必完全刷新整个应用。简单的来说,HMR支持在不刷新页面的情况下更新页面
在webpack
中配置开启热模块也非常的简单,如下代码:
1 | const webpack = require('webpack') |
通过上述这种配置,如果我们修改并保存css
文件,确实能够以不刷新的形式更新到页面中,但是,当我们修改并保存js
文件之后,页面依旧自动刷新了,这里并没有触发热模块,所以,HMR
并不像 Webpack
的其他特性一样可以开箱即用,需要有一些额外的操作
**我们需要去指定哪些模块发生更新时进行HRM
**,如下代码:
1 | if(module.hot){ |
实现原理

第一步,在 Webpack 的 watch 模式下,当文件系统中的某个源文件发生变化时,Webpack 会监听到该变化,并根据配置文件,重新编译受影响的模块,将打包后的结果输出到内存中。
每次 Webpack 构建完成后,都会生成一个唯一的全局 hash 值(对应本次构建的整体状态),还会生成一个json文件,包含本次更新的一些信息,这个 hash 会被发送给客户端。
第二步是 webpack-dev-server 与 Webpack 的集成过程,其中核心组件是中间件
webpack-dev-middleware
。它会接入 Webpack 的编译器(Compiler),监听构建完成事件,从而获取最新的编译结果,并将内存中的构建结果,提供给浏览器访问。
第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。
1
2
3
4
5
6
7module.exports = {
// ...
devServer: {
contentBase: './public', // 静态资源目录
watchContentBase: true, // 开启对这个目录下文件的监听
},
};它的作用是:监听
contentBase
目录下的静态资源(如 HTML、图片、字体等)是否发生变化,如果这些文件变了 → 告诉浏览器刷新页面(Live Reload),不涉及模块打包、不走 Webpack 构建流程(注意,这儿是浏览器刷新,和 HMR 是两个概念)。简单的来说就是,在webpack项目中修改静态文件并保存,会自动刷新页面。小结:Webpack 监听的是源代码文件(如
.js
,.ts
,.vue
)的变化,触发重新编译打包,并将结果写入内存;webpack-dev-server 可以监听静态资源(如 HTML、图片等)的变化,触发浏览器刷新页面(Live Reload)。第四步也是
webpack-dev-server
代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,主要传递的还是将此次更新对应的hash值webpack-dev-server/client
是运行在浏览器中的代码,它负责连接 devServer(WebSocket 或 SockJS),当接收到服务器发来的事件(如 invalid, hash, ok)时,它会把这些事件转发给webpack/hot/dev-server
,它本身不做判断,只是“消息的搬运工”。webpack/hot/dev-server
它主要做以下几件事:接收来自 client 的事件:如 hash(表示新的编译 hash 值)、ok(表示构建完成)
检查是否启用了 HMR,如果
hot: true
并且支持 HMR,则进入热更新流程,否则触发整页刷新(Live Reload)1
2
3
4
5
6
7
8
9
10[1] webpack-dev-server/client 接收构建完成事件
↓
[2] webpack/hot/dev-server 判断是否启用 HMR
↓
[3] 如果启用,调用 module.hot.check()
↓
[4] HotModuleReplacement.runtime 开始工作:
- 请求 update.json(包含变更的 chunk 和模块)
- 对每个变更模块,通过 JSONP 或 script 标签请求新的代码
- 加载并执行热替换
HotModuleReplacement.runtime
是 Webpack 客户端 HMR 的核心逻辑模块。它负责接收新的构建hash
值,向服务器请求更新清单,并通过 JSONP 或其他方式加载新模块代码,最终实现热替换。1
2
3
4
5
6
7
8
9[1] 接收到新 hash
↓
[2] 请求 update manifest(JSON)
↓
[3] 获取需要更新的模块列表
↓
[4] 逐个请求更新模块的代码(jsonp)
↓
[5] 执行热替换(module.hot.apply())- 请求
update manifest
类似于
1
GET /<hash>.hot-update.json
1
2
3
4
5
6
7
8
9
10
11
12
13{
"h": "abcd1234", // 新的构建 hash
"c": {
"main": {
"added": [],
"removed": [],
"updated": [
"./src/App.js",
"./src/components/Header.js"
]
}
}
}- “h”:当前构建的新 hash,用于标识版本
- “c”:chunks 对象,列出每个 chunk 的变更情况
- “main”:chunk 名称(通常就是 entry name)
- “added”:新增的模块列表
- “removed”:被删除的模块列表
- “updated”:需要热更新的模块列表(模块 ID 或路径)
这个 JSON 文件由 Webpack Dev Server 动态生成,包含本次更新涉及的 chunk 及其修改的模块 ID 列表。这一步是由
JsonpMainTemplate.runtime
触发的 Ajax 请求(或 JSONP),用来获取“哪些模块变了”。对每一个需要更新的模块,比如
App.js
,客户端发起单独的JSONP
请求,服务端返回的是一个 JS 脚本,这段代码会注入到页面中执行,HotModuleReplacement.runtime
收集所有更新的模块后,调用module.hot.apply()
,Webpack 会尝试卸载旧模块(如果有dispose
钩子),然后加载新模块,并执行accept
回调。如果某个模块没有注册accept
,则整个页面将触发刷新(Live Reload),这就是为什么你需要在模块里写类似:1
2
3
4
5
6if (module.hot) {
module.hot.accept('./MyComponent', () => {
const NewComponent = require('./MyComponent').default;
render(NewComponent);
});
}
- 请求
总结
- 修改并保存文件,webpack执行增量编译,只重新编译受到影响的模块,然后再打包,并生成一个版本hash值和
hot-update.json
文件,这个文件中包含了需要更新的模块。 - 通过
websocket
将这个hash值交给浏览器,浏览器根据这个hash值,请求hot-update.json
文件, - 对每一个需要更新的模块,比如
App.js
,客户端发起单独的JSONP
请求,并执行返回的js代码 - 浏览器尝试卸载旧模块,加载新模块。
简述vite和webpack的区别
webpack
当我们使用webpack启动项目时,webpack会根据我们配置文件(webpack.config.js
) 中的入口文件(entry
),分析出项目中的所有依赖关系,然后打包成一个文件(bundle.js
),交给浏览器去加载渲染。这样就会带来一个问题,项目越大,需要打包的东西越多,启动时间越长。
HMR
webpack项目中,每次修改文件,都会对整个项目重新进行打包,这对大项目来说,是非常不友好的。虽然webpack现在有了缓存机制,每次修改后保存文件,执行的是增量编译:即只会重新编译被修改的模块和依赖于它的模块,还是无法跳过打包的步骤。
缓存机制
Webpack 提供了 cache: true
的配置项,这个选项是 Webpack 提供的一个构建性能优化手段,它允许 Webpack 缓存 loader 和模块的处理结果,以加快后续构建的速度。
vite
简介
vite
开发环境依赖esbuild
进行预构建。- 生产环境则依赖
rollup
进行打包 - 充分利用了现代浏览器的特性,比如
http2
、ES module
ESM
在讲vite运行原理之前,我们先说一下ES module
。目前,绝大多数现代浏览器都已经支持ES module
了, 我们只需要在<script>
标签中添加type="module"
,内部就能书写ES module
代码了。
在 HTML 中使用:
1 | <script type="module"> |
浏览器遇到 import
导入语句时,会自动发起 HTTP 请求去加载对应的模块文件(如 foo.js
),每个模块可以继续导入其他模块,形成依赖树,所有模块都是 按需异步加载 的,不会阻塞主页面渲染。
这就是浏览器原生支持的“模块系统” —— ES Module(ESM),不需要打包工具也可以运行。Vite 利用的是 原生 ES Module 的加载机制 + 开发服务器的拦截+即时编译。
http2
在之前http1的时候,浏览器对同一个域名的请求,是有并发限制的,一般为6个,如果并发请求6个以上,就会造成阻塞问题,所以在http1的时代,我们要减少打包产物的文件数量,减少并发请求,来提高项目的加载速度。
2015年以后,http2出现了,他可以并发发送多个请求(其实就是多路复用),不会出现http1的并发限制。这时候,将打包产物分成多个小模块,并行去加载,反而会更快。关于http协议的介绍,参考《前端面试网络》
HMR
当你修改了一个文件(比如 utils.ts
):
- Vite 监听到文件变化
- 通过 WebSocket 主动通知浏览器:“这个文件变了”
- 浏览器重新发起对该模块的请求(例如
/utils.ts
) - Vite 编译该模块后返回新内容
- 浏览器执行新的模块代码并更新页面
缓存机制
vite利用浏览器的缓存策略,针对源码模块(我们自己写的代码)做了协商缓存
处理,针对依赖模块(第三方库)做了强缓存
处理,这样我们项目的访问的速度也就更快了。
esbuild
我们知道vite在开发阶段,是只编译浏览器请求的模块,由于有的模块并不是标准的ESM,比如TypeScript,JSX,Vue
单文件组件,CSS imports,CJS模块(如 moment、lodash),这些资源需要经过转换后才能被浏览器识别和执行,需要消耗一定时间。于是 Vite 做了一件很重要的事:在 dev server 启动时,预先将常见的第三方依赖用 esbuild 转换成浏览器可直接加载的 ESM 格式,
特点
Vite的特点如下:
- 开发阶段不打包:不像 Webpack 那样先把整个项目打包成一个 bundle 文件。
- 按需编译:当浏览器请求某个模块时,Vite 服务器拦截这个请求,在服务端动态编译这个模块,编译成标准的esm,然后返回给浏览器。简单的来说,按需编译就是,只有当浏览器请求哪个模块,vite才编译哪个模块。那如果所有模块都被静态 import 了呢?时候看起来像是“一次性全量编译”,但这是你写法的问题,你的代码逻辑要求它们全部被加载,而不是vite的问题,你也可以使用
import()
来实现真正的按需编译 - 首次启动快:因为不需要提前把所有文件都编译一遍。
案例
举个例子说明这个过程:
假设项目结构如下:
1 | my-vue-app/ |
index.html
文件内容如下
1 |
|
main.js
文件内容如下
1 | import { sayHello } from './utils.js'; |
.utils.js
文件内容如下
1 | export function sayHello() { |
HelloWorld.vue
内容如下
1 | <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
文件内容; - 发现它引入了
./utils.js
和./components/HelloWorld.vue
; - 然后 Vite 不会立刻编译这两个文件,而是先返回
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 返回给浏览器。
区别
首次启动速度
使用webpack:
- 启动本地开发服务器(如
webpack-dev-server
) - Webpack 开始进行全量编译打包
- 解析所有入口文件
- 构建依赖图谱
- 打包成 bundle 文件
- 将打包后的资源写入内存(通过
memory-fs
),等待浏览器请求
使用Vite:
- 启动开发服务器
- 浏览器请求某个模块(如
main.js
),Vite 只编译这个模块及其直接依赖 - 不做打包操作(无需构建完整的 chunk/bundle)
- 返回处理后的代码给浏览器
简单的来说,Webpack 首次启动需要全量编译打包;Vite 首次启动,只按需编译浏览器请求的模块,不打包,因此首次启动速度极快。
开发阶段
使用webpack:
- 修改一个文件 → Webpack 检测到变化
- 进行增量编译:重新构建该模块 + 所有受影响的依赖模块
- 生成新的 chunk 文件
- 客户端收到 hash 值 → 请求
.hot-update.json
- 获取变更模块列表 → 逐个请求更新的
.hot-update.js
- 替换模块(如果支持 HMR),否则刷新页面
使用Vite:
- 修改一个文件 → Vite 检测到变化
- 使用websocket通知客户端:“这个模块变了”
- 客户端重新请求该模块(通过 HTTP)
- 用新的模块替换旧模块(不需要打包、不需要 chunk 更新)
Webpack 修改并保存文件,需要重新编译,打包受影响模块;Vite 修改并保存文件,只需重新请求,加载受影响的模块,无需打包,效率更高。
生产阶段
webpack生产阶段还是使用webpack来打包,vite生产阶段使用rollup来打包。rollup打包后的代码非常简洁,完成不像webpack
那样存在大量引导代码和模块函数,所以使用rollup的打包速度更快。
说说如何借助webpack来优化前端性能?
背景
随着前端的项目逐渐扩大,必然会带来的一个问题就是性能
尤其在大型复杂的项目中,前端业务可能因为一个小小的数据依赖,导致整个页面卡顿甚至奔溃
一般项目在完成后,会通过webpack
进行打包,利用webpack
对前端项目性能优化是一个十分重要的环节
如何优化
通过webpack
优化前端的手段有:
- JS,CSS,Html代码压缩
- 文件大小压缩
- 图片压缩
- Tree Shaking
- 代码分离
- 内联 chunk
js代码压缩
terser
是一个JavaScript
的解释、绞肉机、压缩机的工具集,可以帮助我们压缩、丑化我们的代码,让bundle
更小
在production
模式下,webpack
默认就是使用 TerserPlugin
来处理我们的代码的。如果想要自定义配置它,配置方法如下:
1 | const TerserPlugin = require('terser-webpack-plugin') |
压缩css代码
CSS
压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等
CSS的压缩我们可以使用另外一个插件:css-minimizer-webpack-plugin
1 | npm install css-minimizer-webpack-plugin -D |
1 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') |
HTML代码压缩
使用HtmlWebpackPlugin
插件来生成HTML
的模板时候,通过配置属性minify
进行html
优化
关于HtmlWebpackPlugin
插件的详细使用方法,可参考webpack基础 | 三叶的博客。
1 | module.exports = { |
设置了minify
,实际会使用另一个插件html-minifier-terser
文件大小压缩
前面介绍的都是代码压缩
,是指对源代码
进行处理,以减小其体积而不改变其功能。
代码压缩通常涉及以下几种操作:
- 移除空白字符:包括空格、制表符、换行符等。
- 缩短变量名和函数名:将长的
标识符
替换为短的名字,比如从myVariableName
变成a
。 - 移除注释:在生产环境中,注释是没有必要的,所以会被删除。
- 简化语句:例如,合并多个
var
声明或者将一些表达式简化。
文件大小压缩
则是指使用算法
对文件内容进行编码
,从而生成一个更小的表示形式,有时可能会导致文件类型的改变(如压缩成.zip或.rar档案)
常见的文件压缩方法有:
- 无损压缩:如ZIP、Gzip、Brotli等,可以完全还原原始文件的内容。这些压缩方法适用于所有类型的文件,并且特别适合于文本文件,因为文本文件中往往存在很多重复模式,容易被压缩算法利用。
- 有损压缩:如JPEG图片压缩,视频编码等,通过去除一些人类视觉或听觉不易察觉的信息来减小文件大小,但不能完全恢复原始文件。
在网络传输中,服务器常常会在发送响应之前使用Gzip
或Brotli
等压缩算法对整个响应体(包含HTML、JS、CSS等)进行压缩,以减少传输的数据量。当客户端接收到这个压缩后的数据后,会自动解压并处理
。
1 | npm install compression-webpack-plugin -D |
1 | const CompressionPlugin = require('compression-webpack-plugin'); |
图片压缩
一般来说在打包之后,一些图片文件的大小,是远远要比 js
或者 css
文件要来的大,所以图片压缩较为重要
image-webpack-loader
,这是一个专门用来压缩图片的加载器。它不会影响文件的存储位置或名称,而是专注于减少图像文件的大小。
TreeShaking
Tree Shaking
是一个术语,在计算机中表示消除死代码(一般指的是js代码),基于ES Module
的静态语法分析(不执行任何的代码,可以明确知道模块的依赖关系)
在 Webpack5 中,Tree Shaking 在生产环境下默认启动
,这就意味着不需要配置usedExports
,同时还会自动启用代码压缩
。
如果想在开发环境
启动 Tree Shaking,需要配置 optimization.usedExports
为 true,启动标记功能;
1 | module.exports = { |
usedExports
用于在 Webpack 编译过程中启动标记功能
,使用之后,没被用上的变量/函数(包括未导入的函数/变量和导入后未使用的函数/变量),在webpack
打包中,会被加上unused harmony export
注释,当生成产物时,被标记的变量/函数对应的导出语句
会被删除。
当然,仅仅删除未被使用的变量/函数
的导出语句
显然是不够看的,若 Webpack 配置启用了代码压缩工具,如 Terser
插件,那么在打包的最后它还会删除所有引用被标记内容
的代码语句,这些语句一般称作 Dead Code
。可以说,真正执行 Tree Shaking 操作的是 Terser 插件。
如下面sum
函数没被用到,webpack
打包会添加注释,terser
在优化时,则将该函数连同引用该函数的代码删除掉。
要注意的是,上述注释只有在开发打包下,开启usedExports,不开启代码压缩,才能看到。

但是,并不是所有 Dead Code 都会被 Terser 删除。
1 | // src/math.js |
在上述代码中,由于square
函数没有被导入,自然也就不会在index.js
中被使用,所以这个函数会被标记,然后由于console.log(square(10))
引用了这个函数,所以它会被标记为Dead Code
,最终在压缩js代码的时候会被删除。
然而实际上,并非如此,打包后的math.js
模块中:square
函数的痕迹被完全清除,但是打印语句仍然被保留,这是因为,这条语句存在副作用。
副作用(side effect) 的定义是,在导入时
会执行特殊行为的代码(不是export,而是比如调用函数之类的代码)。
显然,以上示例的 console.log()
语句存在副作用。Terser 在执行 Tree Shaking 时,会保留它认为存在副作用
的代码,而不是将其删除,即便这个代码是Dead code。
作为开发者,如果你非常清楚某条语句会被判别为有副作用
,但其实是无害的(删除后无影响),应该被删除,可以使用 /*#__PURE__*/
注释,来向 terser 传递信息,表明这条语句是pure的,没有副作用,terser 可以放心将它删除:
1 | // src/math.js |
然后在打包结果中就不会有console.log
语句
sideEffects
sideEffects
用于告知webpack compiler
哪些模块是有副作用(区别于pure注释
的代码层面),
"sideEffects"
是 package.json
的一个字段,默认值为 true
,即认为所有模块都可能是有副作用的。如果你非常清楚你的 package 是纯粹的,不包含副作用,那么可以简单地将该属性标记为 false
,来告知 webpack 整个包都是没有副作用的,可以安全地删除所有未被使用的代码
(Dead Code),执行比较激进的tree-shaking
;如果你的 package 中有些模块确实有一些副作用,可以改为提供一个数组:
1 | "sideEffects":[ |
更多内容参考:Webpack 5 实践:你不知道的 Tree Shaking本篇文章从 什么是 Tree Shaking、如何使用 T - 掘金
在本博客内的《前端面试-vue》一文中也有对tree-shaking的部分介绍。
总结一下关键点:
tree-shaking的作用是用来删除未使用的js代码,减小最终代码的体积
tree-shaking基于esm的静态导入,即在编译的时候,就能确定哪些模块的具体哪些功能被使用
生产模式打包,会默认开启tree-shaking,开发模式打包,需要手动开启tree-shaking,即设置usedExports属性为true。
默认情况,tree-shaking会标记模块中未被导入的变量和函数,以及导入了但是没有被使用的变量和函数,然后使用terser压缩js代码的时候,不仅仅会删除这些被标记的的变量和函数,还会删除引用了这些被标记的变量和函数的代码,这些代码被叫做dead code
但是如果某条dead code被认为是有副作用的,则不会被tree-shaking删除,比如
console.log
语句
代码分离
将代码分离到不同的bundle
中,之后我们可以按需加载
,或者并行加载这些文件
默认情况下,所有的JavaScript
代码(业务代码、第三方依赖、暂时没有用到的模块)在首页全部都加载,就会影响首页的加载速度
代码分离可以分出更小的bundle
,以及控制资源加载优先级,提供代码的加载性能
这里通过splitChunksPlugin
来实现,该插件webpack
已经默认安装和集成,只需要配置即可
默认配置中,chunks仅仅针对于异步(async)请求,我们可以设置为initial
或者all
1 | module.exports = { |
splitChunks
主要属性有如下:
- Chunks:对同步代码还是异步代码进行处理
- minSize: 拆分包的大小, 至少为minSize,如果包的大小不超过minSize,这个包不会拆分
- maxSize: 将大于maxSize的包,拆分为不小于minSize的包
- minChunks:被引入的次数,默认是1
内联chunk
可以通过InlineChunkHtmlPlugin
插件将一些chunk
的模块内联到html
,如runtime
的代码(对模块进行解析、加载、模块信息相关的代码),代码量并不大,但是必须加载的。
1 | const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin') |
如何提高webpack的构建速度?
背景
随着我们的项目涉及到页面越来越多,功能和业务代码也会随着越多,相应的 webpack
的构建时间也会越来越久
构建时间与我们日常开发效率密切相关,当我们本地开发启动 devServer
或者 build
的时候,如果时间过长,会大大降低我们的工作效率
所以,优化webpack
构建速度是十分重要的环节
如何优化
常见的提升构建速度的手段有如下:
- 优化 loader 配置
- 合理使用 resolve.extensions
- 优化 resolve.modules
- 优化 resolve.alias
- 使用 DLLPlugin 插件
- 使用 cache-loader
- terser 启动多线程
- 合理使用 sourceMap
优化loader配置
在使用loader
时,可以通过配置include
、exclude
、test
属性来匹配文件
如采用 ES6 的项目为例,在配置 babel-loader
时,可以这样:
1 | module.exports = { |
说白了就是使用loader的时候,尽可能精确的匹配文件。
合理使用 resolve.extensions
在开发中我们会有各种各样的模块依赖,这些模块可能来自于自己编写的代码,也可能来自第三方库, resolve
可以帮助webpack
从每个 require/import
语句中,找到需要引入的,合适的模块代码
解析到未加扩展名的文件时,通过resolve.extensions
,自动给文件添加拓展名,默认情况如下:
1 | module.exports = { |
当我们引入文件的时候,若没有文件后缀名
,则会根据数组内的值依次查找
当我们配置的时候,则不要随便把所有后缀都写在里面,这会调用多次文件的查找,这样就会减慢打包速度
简单的来说就是,后缀自动填充数组的长度不要太长了。
优化 resolve.modules
resolve.modules
用于配置 webpack
去哪些目录下寻找第三方模块
。默认值为['node_modules']
,所以默认会从node_modules
中查找文件。当安装的第三方模块都放在项目根目录下的 ./node_modules
目录下时,所以可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:
1 | module.exports = { |
优化 resolve.alias
alias
给一些常用路径
起一个别名
,特别当我们的项目目录结构比较深的时候,一个文件的路径可能是./../../
的形式
通过配置alias
以减少查找过程,在vue的脚手架中,这是自动配置好的。
1 | module.exports = { |
使用 cache-loader
在一些性能开销较大的 loader
之前添加 cache-loader
,以将结果缓存到磁盘里,显著提升二次构建
速度
保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader
使用此loader
1 | module.exports = { |
terser 启动多线程
使用多进程并行运行来提高构建速度,其实默认就是使用多线程
1 | module.exports = { |
更多优化方式参考:面试官:如何提高webpack的构建速度?
说说webpack proxy工作原理?为什么能解决跨域?
是什么
webpack proxy
,即webpack
提供的代理服务
基本行为就是接收客户端发送的请求后,转发给其他服务器
其目的是为了便于开发者在开发模式下解决跨域问题(浏览器安全策略限制)
想要实现代理首先需要一个中间服务器(代理服务器),webpack
中提供服务器的工具为webpack-dev-server
webpack-dev-server
webpack-dev-server
是 webpack
官方推出的一款开发工具,将自动编译
和自动刷新浏览器
等一系列对开发友好的功能,全部集成在了一起,目的是为了提高开发者日常的开发效率,只适用在开发阶段
关于配置方面,在webpack
配置对象属性中,通过devServer
属性提供,如下:
1 | // ./webpack.config.js |
proxy属性的名称是需要被代理的请求路径前缀
,一般为了辨别都会设置前缀为/api
,值为对应的代理匹配规则,对应如下:
- target:表示的是代理到的目标地址
- pathRewrite:默认情况下,我们的
/api
也会被写入到URL中,如果希望删除,可以使用pathRewrite - secure:默认情况下,不接收转发到https的服务器上,如果希望支持,可以设置为false
- changeOrigin:它表示是否更新
代理请求
的headers
中host
字段,如果这个值为true,则修改代理请求(代理服务器发送给目标服务器的请求)的host从localhost:8080
为api.github.com
工作原理
参考前端面试—vue部分一文中的跨域解决部分。