webpack
案例引入
- 基于 Module Federation 的一些微前端方案
- 基于 webpack-dev-server 的一些 热更新
- 基于 tree-Shaking, code spliting, terser / minify / uglify 的一些压缩方案
- lazy import 的一些懒加载方案
- Js, Json,二进制的解析、生成能力
整体流程
bundle: 由 webpack 打包出来的文件
chunk: 指 webpack 在进行模块依赖分析的时候,代码分割出来的代码块
module: 开发中的单个模块
输入
从文件系统中读取代码
- entry: 用于定义项目的入口,Webpack 会从这些入口文件中找到所有文件 (SFC 单文件组件)
- context: 定义项目的执行上下文
模块递归处理
调用 Loader 转译 Module 内容,并将结果转换为 AST,从中分析出模块依赖关系,进一步递归调用模块处理过程,直到所有依赖文件都处理完毕;
- resolve: 用于配置模块路径的解析规则,可以帮助 webpack 更精确,更高效的找到指定模块;
- module: 配置模块加载规则,loaders
- externals: 声明资源之间的关系。用 externals 声明的外部资源, webpack 会进行忽略。
- chunk: 代码块,一个 chunk 由多个模块组合而成,用于代码合并与分割
后处理
所有模块递归处理完毕后开始执行后处理,包括模块合并、注入运行时、产物优化等,最终输出 chunk 集合
- optimization: 用于控制如何优化产物包体积,scope hoisting、code spliting、代码混淆、代码压缩
- target: 用于配置编译产物的目标环境, web,、node、electron
- mode: dev prod 环境下的声明
- output: 输出,产出物
额外的开发效率工具
- devtools 决定 sourcemap 的生成规则
- DevServer HMR: 内置 node 服务,websocket 通知,再根据新 hash 值请求更新内容
- watch 模式,用于配置持续监听文件变化
- cache webpack 5 以后,缓存编译信息
异步加载
原理
- 请求资源时,先通过 jsonp 的方式去加载 js 模块所对应的文件,会保存在一个全局的 webpackJsonp 中
- 加载回来后在浏览器中执行此 JS 脚本,将请求过来的模块定义合并到 main.js 中的 modules 中去: webpackJsonp.push 的的值,两个参数分别为异步加载的文件中存放的需要安装的模块对应的 id 和异步加载的文件中存放的需要安装的模块列表。
- 合并完后,去加载这个模块
- 拿到该模块导出的内容
优势
- 代码分割(Code Splitting): Webpack 允许将代码拆分为多个块(chunks),并在需要时动态加载这些块。意味着可以将应用程序划分为更小的模块,只在需要时加载,而不是一次性加载整个应用程序。这可以减少初始加载时间,提高性能。
- 动态导入语法: Webpack 提供了动态导入语法,例如使用 import() 函数或 require.ensure() 函数来异步加载模块。这些函数返回一个 Promise,可以使用 then 方法处理加载成功的回调,或使用 catch 方法处理加载失败的回调。
- 按需加载: 通过异步加载模块,可以根据需要加载特定的模块,而不是将所有模块打包到同一个文件中。这样可以减少初始加载时间,并在用户需要时动态加载额外的模块。
- 代码并行加载: Webpack 可以同时加载多个模块,利用浏览器的并行加载能力,从而加快加载速度。这对于大型应用程序和复杂的依赖关系特别有用。
loaders
webpack 某个阶段的解析器,对不同文件进行解析
Webpack 选择了 compose 方式,函数组合是函数式编程中非常重要的思想。在 compose 中是采用 reduceRight,从右往左执行
babel loader
css loaders
原生 webpack 不能识别 css 语法,直接导入 .css 文件会失败,为此, 在 webpack 中,处理 css 文件,通常要使用到
css-loader
将 css 翻译成类似 module.exports =
${css}
的 JS 代码,使 CSS 文件可以和 JS 一样作为资源。同时可以提供 sourcemap、css-in-module
style-loader
在具体的产物中,注入 runtime 代码。让这些代码将 CSS 注入到页面中。
less/sass-loader
通过原本的 less / sass 的解析器解析,最后生成 css.
postcss-loader
CSS 界的 babel.
file loaders
file-loader
经过 file-loader 处理后,原始图片会被重命名并复制到产物文件夹,同时在代码里插入图片 URL
url-loader
对于小于阈值的图像,直接 base 64 编码
raw-loader
都不处理,直接拷贝,一般 svg 会用他。
手写 loader
1 | // less-loader |
plugins
在打包过程中不同周期内加入一些功能
常见的 plugins
- terser-webpack-plugin 压缩 js
- pnp-webpack-plugin Yarn Plug Play 插件
- html-webpack-plugin 自动生成带有入口文件的 index.html 模板注入
- webpack-manifest-plugin 生产资产的显示清单
- mini-css-extract-plugin
- define-plugin
- friendly-errors-webpack-plugin 友好的错误日志。
webpack 运行原理
- 初始化参数: 从配置文件和 Shell 语句中读取与合并参数,得出最终的参数
- 开始编译: 用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法,初始化一个 compilation 对象,执行 compilation 中的 build 方法开始执行编译,触发 compiler 对象的 done 钩子,完成编译
- 确定入口: 根据配置中的 entry 找出所有的入口文件
- 编译模块: 从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
- 完成模块编译: 在经过第 4 步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系
- 输出资源: 根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
- 输出完成: 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
compiler / compilation
compiler
该对象包含了 webpack 的所有配置信息,包括 entry、output、module、plugins 等,compiler 对象会在启动 webpack 时,一次性地初始化创建,它是全局唯一的,可以简单理解为 webpack 的实例
compilation
该对象代表一次资源的构建,通过一系列 API 可以访问/修改本次模块资源、编译生成的资源、变化的文件、以及被跟踪依赖的状态信息等,当我们以开发模式运行 webpack 时,每当检测到一个文件变化,就会创建一个新的 compilation 对象,所以 compilation 对象也是一次性的,只能用于当前的编译
compilation.modules
: 解析后的所有模块compilation.chunks
: 所有的代码分块 chunkcompilation.assets
: 本次打包生成的所有文件compilation.hookscompilation
: 所有的钩子总结:
compiler
代表的是整个 webpack 从启动到关闭的生命周期(终端结束,该生命周期结束),而compilation
只是代表了一次性的编译过程,如果是 watch 模式,每次监听到文件变化,都会产生一个新的 compilation,所以 compilation 代表一次资源的构建,会多次被创建,而 compiler 只会被创建一次
插件实现原理
通过 tapable 链式调用
通过 compiler 对象的 hooks 属性访问到 emit 钩子,再通过 tap 方法注册一个钩子函数,这个方法接收两个参数: 插件名称,callback(compilation: 上下文,其中 assets 属性为即将写入输出目录的资源文件信息, for in 去遍历 assets ,得到键名,即每个文件的名称,然后通过遍历的值对象中的 source 方法获取文件内容,处理过后暴露一个 source 方法用来返回新的内容。另外还需要再暴露一个 size 方法,用来返回内容大小)
1 | const { |
tree-shaking
原理
依赖标记阶段
Make 阶段: 收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
- 将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到 module 对象的 dependencies 集合,转换规则: - 具名导出转换为 HarmonyExportSpecifierDependency 对象 - default 导出转换为 HarmonyExportExpressionDependency 对象
- 所有模块都编译完毕后,触发 compilation.hooks.finishModules 钩⼦,开始执行 FlagDependencyExportsPlugin 插件回调
- FlagDependencyExportsPlugin 插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有 module 对象
- 遍历 module 对象的 dependencies 数组,找到所有 HarmonyExportXXXDependency 类型的依赖对象,将其转换为 ExportInfo 对象,并记录到 ModuleGraph 体系中
Seal 阶段: 遍历 ModuleGraph 标记模块导出变量有没有被使⽤
- 触发 compilation.hooks.optimizeDependencies 钩⼦,开始执⾏ FlagDependencyUsagePlugin 插件逻辑
- 在 FlagDependencyUsagePlugin 插件中,从 entry 开始逐步遍历 ModuleGraph 存储的所有 module 对象
- 遍历 module 对象对应的 exportInfo 数组
- .为每⼀个 exportInfo 对象执⾏ compilation.getDependencyReferencedExports ⽅法,确定其对应的 dependency 对象有否被其它模块使⽤
- 被任意模块使⽤到的导出值,调⽤ exportInfo.setUsedConditionally ⽅法将其标记为已被使⽤
- exportInfo.setUsedConditionally 内部修改 exportInfo._usedInRuntime 属性,记录该导出被如何使⽤
- 结束
产物生成: 若变量没有被其他模块使用时,则删除对应的导出语句
- 打包阶段,调⽤ HarmonyExportXXXDependency.Template.apply ⽅法⽣成代码
- 在 apply ⽅法内,读取 ModuleGraph 中存储的 exportsInfo 信息,判断哪些导出值被使⽤,哪些未被使⽤
- 对已经被使⽤及未被使⽤的导出值,分别创建对应的 HarmonyExportInitFragment 对象,保存到 initFragments 数组
- 遍历 initFragments 数组,⽣成最终结果 (
__webpack_exports__
对象)
标记阶段会将 import & export 标记为 3 类
- 所有 import 标记为
/* harmony import */
- 被使用过的 export 标记为
/* harm export([type])*/
,其中 [type] 和 webpack 内部相关,可能是 binding,immutable 等等- 未被使用过的 import 标记为
/* unused harmony export [FuncName] */
,其中 [FuncName] 为 export 的方法名称
删除阶段
使用 Terser 删除没有用到的导出语句 (dead code)
原理总结
- 在 FlagDependencyExportsPlugin 插件中根据模块的 dependencies 列表收集模块导出值,并记录到 ModuleGraph 体系的 exportsInfo 中
- 在 FlagDependencyUsagePlugin 插件中收集模块的导出值的使用情况,并记录到 exportInfo._usedInRuntime 集合中
- 在 HarmonyExportXXXDependency.Template.apply 方法中根据导出值的使用情况生成不同的导出语句
- 使用 DCE 工具删除 Dead Code,实现完整的树摇效果
启用 tree-shaking
需满足三个条件
- 使用 ESM 规范编写模块,在引入模块时应局部引入,才可以触发 tree shaking 机制
1 | // 导入所有内容(不会触发 tree-shaking) |
配置 optimization.usedExport 为 true,启用标记功能,标记代码无副作用。在 package.json 中配置 sideEffects: false,可以安全删除
启用代码优化功能,有以下途径
- 配置 mode = production
- 配置 optimization.minimize = true
- 提供 optimization.minimizer 数组
1 | module.exports={ |
最佳实践
- 避免无意义的赋值: Tree Shaking 逻辑停留在代码静态分析层面,只是浅显地判断:
- 模块导出变量是否被其它模块引用
- 引用模块的主体代码中有没有出现这个变量
无法从语义上分析模块导出值是不是真的被有效使用,导致出现一些无意义复制被保留的情况
- 使用 #pure 标注纯函数调用
1 | const fun = (str) => { |
- 禁止 Babel 转译模块导入导出语句: 避免 ESM 语句转译成 CommonJS 语句
- 优化导出值的粒度: 尽量明确导出对象最小粒度
- 使用支持 Tree Shaking 的包
source-map
Source Map 是一个信息文件,存储了代码打包转换后的位置信息,实质是一个 json ,维护了打包前后的代码映射关系
生成 source-map
UglifyJS
1 | uglifyjs app.js - o app.min.js--source - map app.min.js.map |
Grunt
1 | grunt.initConfig({ |
Gulp
1 | var gulp = require("gulp"); |
SystemJS
1 | builder.bundle("myModule.js", "outfile.js", { |
Webpack
1 | devtool: "source-map"; |
Webpack 中的 Source Map
配置 devtool 的不同取值使用不同的 source map 策略
- 内联: 构建速度更快
- 外部: 会生成 .map 文件
source-map
: 外部。可以查看错误代码准确信息和源代码的错误位置inline-source-map
: 内联。只生成一个内联 Source Map,可以查看错误代码准确信息和源代码的错误位置hidden-source-map
: 外部。可以查看错误代码准确信息,但不能追踪源代码错误,只能提示到构建后代码的错误位置eval-source-map
: 内联。每一个文件都生成对应的 Source Map,都在 eval 中,可以查看错误代码准确信息和源代码的错误位置nosources-source-map
: 外部。可以查看错误代码错误原因,但不能查看错误代码准确信息,并且没有任何源代码信息cheap-source-map
: 外部。可以查看错误代码准确信息和源代码的错误位置,只能把错误精确到整行,忽略列cheap-module-source-map
: 外部。可以错误代码准确信息和源代码的错误位置,module 会加入 loader 的 Source Map
使用总结
- 开发环境: 需要考虑速度快,调试更友好。综合速度 (eval > inline > cheap) 和调试 (souce-map),有两种方案
- eval-source-map: 完整度高,内联速度快
- eval-cheap-module-souce-map: 错误提示忽略列但是包含其他信息,内联速度快
- 生产环境: 需要考虑源代码要不要隐藏,调试要不要更友好 (内联会让代码体积变大,生产环境不用内联),有两种方案
- nosources-source-map: 全部隐藏 (打包后的代码与源代码)
- hidden-source-map: 只隐藏源代码,会提示构建后代码错误信息
综合选择
- dev:
source-map
(最完整)- prod:
cheap-module-souce-map
(错误提示一整行忽略列)
性能优化
减少打包体积
- 只打包需要的模块: 使用 tree-shaking
- 代码压缩:
- 使用 css-minimizer-webpack-plugin: 压缩和去重 CSS
- terser-webpack-plugin: 压缩和去重 JavaScript,或其他 UglifyJS 插件
- source-map: 在开发模式下生成更准确 (但更大) 的 source-map;在生产模式下生成更小 (但不那么准确) 的 source-map
- webpack-bundle-analyzer: 查看打包后的体积,后续优化
1 | module.exports = { |
使用缓存
- 使用缓存提高打包速度: 避免每次修改代码后重新构建和打包,可使用 cache-loader 、 hard-source-webpack-plugin 、HotModuleReplacementPlugin 等插件缓存打包结果
- 使用浏览器缓存加快页面速度: 避免重复访问服务器。可以通过在 Webpack 中设置 output.chunkFilename 和 output.filename 来控制静态资源的命名规则,并设置 max-age 等缓存策略
1 | module.exports = { |
优化图片加载
- 使用 file-loader 和 url-loader 进行图片处理: file-loader 会将图片打包后生成一个 url,url-loader (或者 webpack5 的 assets-moudle) 会根据图片大小来决定是否将图片转为 base64 编码
1 | { |
- 使用图片压缩工具压缩: tinyPng、Gzip (需后端配合) 等
1 | const CompressionPlugin = require("compression-webpack-plugin"); |
代码分割和加载
- 使用 SplitChunksPlugin 插件进行代码分割: 可以将公共的依赖模块抽取成 chunk,并且将多个 chunk 之间的重复依赖提取成单独的 chunk
1 | module.exports = { |
- 按需加载: 资源动态加载,使用 React.lazy() 函数或者 import() 语法,可以实现组件的按需加载
CDN 加速
CDN: 内容分发网络,通过把资源部署到世界各地,用户在访问时按照就近原则从离用户最近的服务器获取资源,从而加速资源的获取速度。 CDN 其实是通过优化物理链路层传输过程中的网速有限、丢包等问题来提升网速的,其大致原理可以如下: 因为 CDN 都有缓存,所以为了避免 CDN 缓存导致用户加载到老版本的问题,需要遵循以下规则:
- 针对 HTML 文件: 不开启任何缓存,不放入 CDN
- 针对静态 JS 、CSS 、图片等文件: 开启 CDN 和缓存,放入 CDN 服务器,并且给每一个文件名带入 Hash 值,避免文件重名导致访问到同名缓存废弃文件的问题
- 介于浏览器对同一时刻、同一域名的请求个数有限制的状况,请求资源过多的话,可能导致加载文件被阻塞。所以,当同一时间加载资源过多时,我们可以针对不同的文件类型放入不同的 CDN 上
1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); |
优化打包速度
- 使用 HappyPack : 可以将 loader 进行多线程并行处理,加快速度 (thread-loader 也可以启用多线程)
1 | const HappyPack = require("happypack"); |
- 使用 DLLPlugin 进行预编译: 可以将第三方库先进行打包,然后在开发时直接使用已经打包好的 dll 文件,从而减少重复打包的时间
1 | const path = require("path"); |
- 使用 IgnorePlugin 忽略部分模块: 忽略掉不需要的模块,在打包时跳过它们,从而提升打包速度。
也可以使用 exclude 、include 来指定需要特定 loader 编译的文件,并非不编译,而是使用默认的 loader 还是指定 loader 的区别。exclude 的优先级高于 include
Webpack 配置中的 module.noParse 则是在 include/exclude 的基础上,进一步省略了使用默认 js 模块编译器进行编译的时间
1 | const webpack = require('webpack'); |
- 设置 resolve.alias 配置: 可以将一些常用的模块路径映射为绝对路径,从而缩短 Webpack 查找模块的时间
1 | module.exports = { |
- TypeScript 编译优化
- 使用 ts-loader: 默认在编译前进行类型检查,因此编译时间往往比较慢,通过加上配置项 transpileOnly: true,可以在编译时忽略类型检查
1 | rules: [ |
- 使用 babel-loader: 需要单独安装 @babel/preset-typescript 来支持编译 TS,配合 ForkTsCheckerWebpackPlugin 使用类型检查功能
1 | module.exports = { |
其他优化项
结合其他插件: MiniCssExtractPlugin、Webpack Shell Plugin、Autoprefixer
- Scope Hoisting (作用域提升)
普通打包只是将一个模块最终放入一个单独的函数中,如果模块很多,就意味着在输出结果中会有很多的模块函数。concatenateModules 配置的作用,尽可能将所有模块合并到一起输出到一个函数中,既提升了运行效率,又减少了代码的体积
1 | module.exports = { |
- parallel-webpack
并发构建的第二种场景是针对与多配置构建。Webpack 的配置文件可以是一个包含多个子配置对象的数组,在执行这类多配置构建时,默认串行执行
1 | var path = require('path'); |
示例
1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); |