文章目录
前提
首先我们得了解
Compiler 和 Compilation
compiler
对象可以理解为一个和 webpack 环境整体绑定的一个对象,它包含了所有的环境配置,包括 options,loader 和 plugin,当 webpack 启动时,这个对象会被实例化,并且他是全局唯一的,上面我们说到的 apply 方法传入的参数就是它。
compilation
在每次构建资源的过程中都会被创建出来,一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。它同样也提供了很多的 hook 。
生命周期函数
- beforeRun在 Webpack 开始读取配置之前,该钩子将被调用。
- run在 Webpack 开始编译时,该钩子将被调用。
- watchRun在使用 webpack-dev-server 进行开发时,该钩子将被调用。
- beforeCompile在 Webpack 开始编译之前,该钩子将被调用。
- compile在 Webpack 开始编译时,该钩子将被调用。
- thisCompilation在创建新的 Compilation 对象时,该钩子将被调用。
- compilation在编译时,每当 Webpack 生成一个新的 Compilation 对象时,该钩子将被调用。
- emit在生成资源之前,该钩子将被调用。
- afterEmit在生成资源之后,该钩子将被调用。
- done在 Webpack 编译完成时,该钩子将被调用。
- assetEmitted 生命周期钩子是在所有资源(如 JavaScript、CSS、图片等)都已经生成到输出目录中后,即 webpack 打包完毕后触发的。
打包流程
1、我们打包的时候,会先合并 Webpack config
文件和命令行参数,合并为 options
。
2、将 options
传入Compiler
构造方法,生成 compiler
实例,并实例化了 Compiler
上的 Hooks
。
3、compiler
对象执行run
方法,并自动触发 beforeRun
、run
、beforeCompile
、compile
等关键 Hooks。
4、调用 Compilation
构造方法创建 compilation
对象,compilation
负责管理所有模块和对应的依赖,创建完成后触发 make Hook
。
5、执行 compilation.addEntry()
方法,addEntry
用于分析所有入口文件,逐级递归解析,调用 NormalModuleFactory
方法,为每个依赖生成一个 Module
实例,并在执行过程中触发 beforeResolve
、resolver
、afterResolve
、module
等关键 Hooks
。
6、将第 5 步中生成的 Module
实例作为入参,执行 Compilation.addModule()
和 Compilation.buildModule()
方法递归创建模块对象和依赖模块对象。
7、调用seal
方法生成代码,整理输出主文件和 chunk
,并最终输出。
优化
通过speed-measure-webpack-plugin测试打包速度,通过webpack-bundle-analyzer 分析打包后的文件,判断哪些包还可以拆分和优化
主要从两个方面分析
- webpack构建时间太久,影响开发效率
- webpack打包的结果体积过大,影响网站的性能
构建优化
- 解析loader开启多进程,使用thread-loader
将thread-loader放在比较费时间的loader之前,比如babel-loader - 缓存资源,提高二次构建速度
将cache-loader放在比较费时间的loader之前,比如babel-loader - 开启热更新
给开发环境下配置plugins
plugins: [
newwebpack.HotModuleReplacementPlugin()
]
- exclude & include
exclude:不需要处理的文件
include:需要处理的文件
//使用include来指定编译文件夹
include: path.resolve(__dirname, '../src'),
//使用exclude排除指定文件夹
exclude: /node_modules/,
- 构建区分环境
开发环境:去除代码压缩、gzip、体积分析等优化的配置,大大提高构建速度
生产环境:需要代码压缩、gzip、体积分析等优化的配置,大大降低最终项目打包体积 - 并行构建HappyPack
由于有大量文件需要解析和处理,所以构建是文件读写和计算密集型的操作, 特别是当章优化文件数量变多后, Webpack 构建慢的问题会显得更为严重。运行在 node. 之上的 Webpack 是单线程模型的,也就是说 Webpack 需要一个一个地处理任务,不能同时处理多个任务。
happy能够让Webpack做到这一点,它将任务分解给多个子进程去并发执行,子进程处理后将结果返回给主进程。
打包体积优化
- 代码分离
- 它主要的目的是将代码分离到不同的bundle中,之后我们可以按需加载,或者并行加载这些文件;
- 比如默认情况下,所有的JavaScript代码(业务代码、第三方依赖、暂时没有用到的模块)在首页全部都加载,就会影响首页 的加载速度;
- 代码分离可以分出更小的bundle,以及控制资源加载优先级,提供代码的加载性能;
Webpack中常用的代码分离有三种:
- 入口起点:使用entry配置手动分离代码;正则匹配规则写的更精准;除去无需打包的文件;配置module.noParse,例如JQuery或者lodash…已经是可以直接运行在浏览器的文件,就不必再搜索解析
- 防止重复:使用Entry Dependencies或者SplitChunksPlugin去重和分离代码;
- 动态导入:通过模块的内联函数调用来分离代码;import()语法
- CDN
- 在打包的时候我们不再需要对类似于lodash或者axios这些库进行打包;
- 在html模块中,我们需要自己加入对应的CDN服务器地址;
-
提取css文件
MiniCssExtractPlugin可以帮助我们将css提取到一个独立的css文件中,然后link标签引入css。 -
JS-CSS代码压缩
js: terser-webpack-plugin
css: css-minimizer-webpack-plugin -
Tree Shaking
tree-shaking简单说作用就是:只打包用到的代码,没用到的代码不打包,而webpack5默认开启tree-shaking,当打包的mode为production时,自动开启tree-shaking进行优化
mode: 'production'
- 文件压缩
通过CompressionPlugin插件进行文件压缩 - source-map类型
// 开发环境
mode: 'development',
devtool: 'eval-cheap-module-source-map'
// 生产环境
mode: 'production',
devtool: 'nosources-source-map'
- 优化resolve.alias配置
起别名来配置模块的入口地址,跳过递归解析文件的操作,比如react有两套代码,一个是已经打包好的react.min.js,另一个是common.js下所有的模块文件,以react.js为入口文件,在我们使用时,可以直接将文件入口配置到react.min.js,省去递归的过程。
用户体验优化
- 模块懒加载
使用模块懒加载之后,大js文件会分成多个小js文件,网页加载时会按需加载,大大提升首屏加载速度
// 路由懒加载
{
path: '/home',
name: 'home',
// 懒加载
component: () => import('../views/home/home.vue'),
},
- 合理配置hash
改过的文件需要更新hash值,而没改过的文件依然保持原本的hash值,上线后对没有改变的文件依然使用缓存
output: {
path: path.resolve(__dirname, '../dist'),
// 给js文件加上 contenthash
filename: 'js/chunk-[contenthash].js',
clean: true,
},
- Gzip
const CompressionPlugin = require('compression-webpack-plugin')
plugins: [
// gzip
new CompressionPlugin({
algorithm: 'gzip',
threshold: 10240,
minRatio: 0.8
})
]
原理
运行机制
下图是内部的运行机制
模块解释:
Compiler:
包含了所有webpack
的所有配置信息,包含option
|loaders
|plugins
,该对象有一个方法run
,执行该方法开始编译,全局只有一个
Compilation:
只要文件被修改,便有compilation
被创建,包含当前模块的资源、编译生成资源、变化文件
流程:
为什么经webpack打包后的文件可以直接在浏览器中运行?
原来一个独立的模块文件被合并到了一个个单独的bundle.js
文件中,浏览器不能像node
快速加载本地文件,所以就将所有的模块放在一个数组中,执行一次网络请求。被加载过的文件不会执行第二次,执行结果会缓存在内存中。
还需要了解_webpack_require_
函数,定义在浏览器中执行的加载函数(模拟node.js
的require
)
为什么运行在node(遵循CommonJs)的环境,但webpack引入模块可以使用es6的import xxx from 'xxx'
呢?
webpack内部的babel可以将es6的语法转换为CommonJs的语法。
具体webpack
的配置,戳这里
webpack的异步模块加载的实现
对于异步方法(import(‘xxModule’)),webpack是怎么处理的呢?
单独打成一个包,采用动态加载的方式,具体过程:当用户触发其加载的动作时,会动态的在head标签中创建一个script
标签,然后发送一个http请求,加载模块,模块加载完成以后自动执行其中的代码,主要的工作有两个,更改缓存中模块的状态,另一个就是执行模块代码
import()导入模块后是一个Promise对象,我们可以通过import().then()的方式来处理后续异步的工作
import moduleName from 'xxModule’和import(‘xxModule’)经过webpack编译打包后最终变成了什么?在浏览器中是怎么运行的?
- import经过webpack打包以后变成一些Map对象,key为模块路径,value为模块的可执行函数;
- 代码加载到浏览器以后从入口模块开始执行,其中执行的过程中,最重要的就是webpack定义的
__webpack_require__
函数,负责实际的模块加载并执行这些模块内容,返回执行结果,其实就是读取Map对象,然后执行相应的函数; - 当然其中的异步方法(import(‘xxModule’))比较特殊一些,它会单独打成一个包,采用动态加载的方式,具体过程:当用户触发其加载的动作时,会动态的在head标签中创建一个script标签,然后发送一个http请求,加载模块,模块加载完成以后自动执行其中的代码,主要的工作有两个,更改缓存中模块的状态,另一个就是执行模块代码。
以下代码webpack打包出来2个包,a.js因为是异步加载的,所以单独为一个包。
在b.html中访问了b.js,且b.js中异步加载了a.js,浏览器会先返回模块b-chunk.js,然后返回a-chunk.js
// b.html
<html>
<script src="https://xxx.com/b.js"/>
</html>
// a.js
var a = 'a'
modules.exports = {
name: 'a'
}
// b.js
var b = 'b'
import('./a.js').then(res => {
console.log(res, '1s')
})
module.exports = {
name: 'b'
}
webpack打包后产物分析
- 总体的文件就是一个 IIFE——立即执行函数
- webpack 会对加载过的文件进行缓存,从而优化性能
- 主要是通过
__webpack_require__
来模拟 import 一个模块,并在最后返回模块 export 的变量 - 动态加载 import() 的实现主要是使用 JSONP 动态加载模块,并通过 webpackJsonpCallback 判断加载的结果
- 对于模块的处理方式是,所有的js依赖,打包到一个文件中,然后自己实现一套require和module.exports,让浏览器可以执行源代码
(function(modules) { // webpackBootstrap
// The module cache
var installedModules = {};
function __webpack_require__(moduleId) {
// ...省略细节
}
// 入口文件
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
({
"./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {}),
"./src/sayHello.js": (function(module, __webpack_exports__, __webpack_require__) {})
});
- 对于commonJs打包产物分析
__webpack_modules__
:保存文件路径和文件内容的映射关系__webpack_modules_cache__
:缓存已加载过的文件,key为文件路径,value为导出内容__webpack_require__
:加载文件,将导出内容添加到module.exports和exports对象中
- ES modules
__webpack_require__.d
将传入的对象属性和值遍历到 exports 对象上__webpack_require__.o
判断某属性是否存在于某对象中__webpack_require__.r
给使用 ES Module 实现模块化的文件中 exports 对象里增加 __esModule 属性
- commonJs和ES modules混合使用
__webpack_require__.n
定义遍历赋值为函数,并在该变量上添加a属性,值为函数
热更新原理
- 通过webpack-dev-server创建两个服务器:提供静态资源的服务(express)和Socket服务
express server 负责直接提供静态资源的服务(打包后的资源直接被浏览器请求和解析) - socket server 是一个 websocket 的长连接,双方可以通信
- 当 socket server 监听到对应的模块发生变化时,会生成两个文件.json(manifest文件)和.js文件(update chunk)
- 通过长连接,socket server 可以直接将这两个文件主动发送给客户端(浏览器)
- 浏览器拿到两个新的文件后,通过HMR runtime机制,加载这两个文件,并且针对修改的模块进行更新
- Webpack Compile:将 JS 源代码编译成 bundle.js
- HMR Server:用来将热更新的文件输出给 HMR Runtime
- Bundle Server:静态资源文件服务器,提供文件访问路径
- HMR Runtime:socket服务器,会被注入到浏览器,更新文件的变化
- bundle.js:构建输出的文件
- 在HMR Runtime 和 HMR Server之间建立 websocket,即图上4号线,用于实时更新文件变化
工具包的功能:
hot-module-replacement-plugin
:提供 HMR 的 runtime,并且将 runtime 注入到 bundle.js 代码里面去。一旦磁盘里面的文件修改,那么 HMR server 会将有修改的 js module 信息发送给 HMR runtime,然后 HMR runtime 去局部更新页面的代码。因此这种方式可以不用刷新浏览器webpack-dev-middleware
:本地文件的编译和输出以及监听。- 首先对本地文件代码进行编译打包
- 编译结束后,开启对本地文件的监听,调用compiler.watch()
- 当文件发生变化,重新编译,编译完成之后继续监听(通过文件的生成时间是否有变化)
- 执行setFs方法,这个方法主要目的就是将编译后的文件打包到内存(用的是memory-fs这个包)
tree shaking
Tree-Shaking 是一种基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,确定 ESM 模块中哪些导出值未曾其它模块使用,并将其删除,以此实现打包产物的优化。
- 如何开启
-
使用 ESM 规范编写模块代码
-
配置 optimization.usedExports 为 true,启动标记功能
-
启动代码优化功能,可以通过如下方式实现:
- 配置 mode = production
- 配置 optimization.minimize = true
- 提供 optimization.minimizer 数组
为什么require(‘./utils/index’)的方式引入就不会被删除掉呢?
Webpack 不支持使用 commonjs、AMD、CMD模块来完成 tree-shaking。仅支持ESM,原因是,旧的模块系统中,导入导出都是动态的,难以预测,但是ESM导入导出都在顶层,且模块名必须为字符串常量,模块之间的依赖关系是确定的,与运行状态无关,只需要对ESM做静态分析,就可以推断出来哪些模块未被使用,可以被优化掉。
Webpack的tree-shaking是代码在运行前完成的,而require(‘./utils/index’)的引入是代码运行的时候才会执行的,所以tree-shaking没有办法对它进行分析删除。
而es6的引入方式是静态的,在运行之前进行了引入,所以打包出来以后就是tree-shaking处理过的。
- 实现原理
先标记模块导出中未被使用的值,使用 Terser 删掉这些没被用到的导出语句
未被使用到的变量foo通过标记,不会被导出,但该变量foo对应的代码还是存在,通过Terser插件,分析永远不会被执行的代码就会被删除。
懒加载实现与原理,如何配置懒加载
通过动态加载的方式
async function handlerUploadClick() {
// 动态加载 upload 模块,该模块的 default 属性就是 upload 方法
const { default: upload } = await import("./upload");
// 调用 upload 方法
upload();
}
webpack 的懒加载实现在打包时会将懒加载的代码切割出去单独打包,然后在主包中进行按需加载,最后执行调用。
webpack5
- 持久性缓存:更快的构建速度和优化
通过使用文件内容的哈希来生成缓存文件名,Webpack 5能够在文件内容未发生变化时重用之前构建的结果。
module.exports = {
// ...其他配置项
cache: {
type: 'filesystem', // 使用文件系统缓存
},
};
- 模块联邦:拆分应用,共享模块
使用模块联邦,每个应用块都应该是一个独立的构建,这些构建都将编译成容器,容器可以被其他应用或容器使用,引用模块的引用者成为host,一个被引用的容器成为remote。
// app1的webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ...其他配置项
plugins: [
new ModuleFederationPlugin({
name: 'app1',
remotes: {
app2: 'app2@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
};
- Tree Shaking优化
确保在Webpack配置中启用了mode选项,通常选择production模式
"sideEffects": false
:告诉Webpack该包没有副作用,可以安全地删除未引用的代码 - 改进的代码生成:提高性能和加载速度
Webpack 5通过生成更紧凑、高效的代码,提升了应用的性能和加载速度。这是Webpack的一个重要优化,不需要额外的配置即可生效。 - nodeJs的polyfill脚本被移除
不再为 Node.js 模块自动引用 Polyfills
借鉴:webpack优化文章