注:本文使用的vite版本为2.9.14
在上一篇文章中,我们了解到vite在业务上的能力,也就是说,我们已经可以在一般业务上使用vite了,但是vite真的仅仅只能做到这样吗?或者说,vite为什么能做到这样,因此,上一篇并非我们的终结,我们需要学习的东西依然有很多。
模块联邦
一般来说,如果仅仅只有一两款细分应用,那么我们完全不用考虑他们之间的关系,但是如果应用变得庞大起来,那么必然会进行依赖抽取,模块共享的问题,那么如果我们这里有多款应用,每个应用由不同团队开发,每个应用都需要依赖一些相同的代码、相同的第三方库、相同的组件,我们有什么优雅的手段呢?
1、npm包
这是一个很常见的做法,可以把共用代码封装为一个npm包,让这些细分应用依赖这个npm包,这样的话,我们就做到了抽离所有应用的共用组件。
但这个有显而易见的缺点:
虽然降低了共用组件的维护成本,但增加了依赖的维护成本,共用组件更新的时候,每次都需要更新依赖。
风险问题,抽离的共用组件需要联调所有的细分应用,若公共组件出现问题,那么增加联调成本。
构建问题,意味着我们很难进行按需引入共用组件,所以将共用组件都要打包进最后产物,会让产物增大。
所以这个方案具有一定的可行性,但并非最终方案。
2、Git Submodule
Git Submodule可以让我们把代码封装成一个公共Git仓库,复用到不同应用里面,然后通过git submodule来更新。
这个方案与npm类似,依然存在npm的缺点。
3、external + CDN
在打包过程中,我们可以用external排除不需要依赖构建的包,然后使用cdn进行引入,通过cdn引入的代码就会自动注入到全局环境下,那么我们就可以在需要的地方从全局环境拿取需要的代码,从而达到复用的效果。
但这个也有缺点:
兼容问题,如果是第三方依赖,很难保证他是UMD格式的产物,所以如果共用组件是第三方依赖的话,可能存在兼容问题。
依赖顺序问题,如果提取的共用组件有依赖,那么我们需要对这些依赖也进行external,但如果这些依赖是庞大的,那么针对每个细分应用都需要一套定制的顺序依赖。
产物体积无法通过构建来控制,因为存在cdn,因此产物体积处于不可控状态。
4、Monorepo
Monorepo是一个全新的项目管理方式,很好的解决模块复用的问题,在Monorepo架构下,多个项目放在一个仓库,各个子项目通过软链方式进行调试,因此代码复用非常方便,如果依赖代码有变动,那么用到的这个项目也会立刻感知到。
Monorepo很优秀,但却带来了一些挑战:
所有代码放到同一个仓库中,如果是旧项目改造,那么调整项目所花的改造成本就会相对较高。
如果细分软件较多,依赖安装、整体构建的时间会变长,因此需要投入额外的精力去维护Monorepo。
同时Monorepo也有一些问题:
项目构建问题,所有的共用组件都会进入构建流程。
大部分无法对每个项目进行权限上的访问控制,
需要更多的储存空间,与2类似,所有的代码都会在一个仓库中,因此占用了更多的存储空间。
5、模块联邦
模块联邦,原文是Module Federation,最早是webpack提出来的新特性,可以让跨应用的模块共享模块,达到热插拔式的便捷。也就是说,模块联邦可以将一个应用的包、组件用在另一个应用,同时具备整体应用一起打包的抽取公共依赖能力。
模块联邦主要分为本地模块、远程模块。
本地模块指的是普通模块,是当前项目的构建流程的一部分,本地模块在运行中导入,同时可以跟远程模块共享依赖,而远程模块不属于当前的构建流程。
需要注意,在模块联邦,模块又可以是本地模块,同时也能导入其他的远程模块,然后自己作为远程模块被其他模块导入。
模块联邦具有以下优势:
任意模块的共享:任意模块,这个模块可以是第三方依赖,也可以是一个utils,也可以是一个js文件,甚至也可以是一整个前端应用。
优化构建体积,由于可以是运行时拉取,因此不用参与本地构建,减少构建体积。
运行按需加载,由于可以自定义远程模块导出,因此,我们可以自己实现按需导入,从而实现按需加载。
与CDN类似,但由于可以使用依赖共享,因此可以规避CDN的依赖加载问题。
vite中的模块联邦
那么,我们如何在vite中使用模块联邦呢?社区中发布了@originjs/vite-plugin-federation插件, 获得了广泛关注。
我们把当前已经迁移到vite的webapp当做本地项目,然后新起一个vite-vue3的项目,当做远程模块,然后安装@originjs/vite-plugin-federation。
在新的vite-vue3中,我们把自带的logo.png和App.vue作为远程模块进行导出。
在这个远程模块,我们将他的name设置为common,这个在本地模块用来引入当做包名。
filename是引入远程模块的入口文件,exposes是需要导出的列表,key值是模块路径,而value是真实路径。
我们在本地项目中,同样需要安装@originjs/vite-plugin-federation。 同时在配置中,设置remotes字段,此字段为远程模块的入口地址,字段地址是网络地址。
也就是说,远程模块是需要部署在服务器上的,而本地模块就没有这个要求。
同时这里需要注意的是,这个功能需要build.target为esnext,来支持插件中的新语法,不过我们依然可以使用上一篇提到的语法降级来兼容低版本浏览器。
我们启动webapp,可以在任意地方,使用
import logo from 'common/logo'
import App from 'common/App'
来引入远程模块,然后在控制台打印出结果。
可以看到,我们在基于vue2的webapp中,引入了另一个项目的图片文件,以及vue3的App.vue。
并且我们可以看到这个是通过commonIndex按需引入,并不占用产物体积。
如果在federation中传入参数shared,比如
那么就形成了共享依赖声明,在本地模块中,配置所有使用到的远端模块的依赖,而远程模块需要配置对外提供的组件的依赖。
我们来总结一下实现所需要的字段:
remotes:可以用来获取远程模块
exposes:在远程模块中细化需要导出的文件
shared:用来确定本地模块和远程模块中进行共享的第三方依赖,当此项目作为远程模块的时候,不会把此依赖打包进共享模块中,当此项目作为本地模块的时候,将此依赖将从本地依赖提供给远程模块。
原理
那么,模块联邦的原理是什么样的呢,为什么我们可以通过定义一个入口文件,就可以拿到我们想要的其他项目的模块呢?
我们虽然可以直接进入打包产物,看看引入App和log的代码被编译成什么样子,但是经过混淆后的代码,看着十分伤眼睛,因此我们可以启动本地服务器,来观察结果。
我们看到,引入的语句被编译成了顶层await,由__federation_method_getRemote函数进行代理引入,而__federation_method_getRemote是在顶层进行注入的,那么我们进入他所在的文件,观察定义逻辑。
代码很简单,包含工具函数,单例运行容器ensure,还有就是getRemote的函数定义。
而getRemote的原理,是从本地运行容器中取远程模块的入口文件,然后从入口文件,也就是commonIndex中取想要的模块。
那我们来看远程模块的入口文件:commonIndex是怎么样的。
入口文件也定义了一个容器,当入口文件被引入的时候,会暴露get方法,用于获取对应的模块,而get方法在getRemote中使用,从而获取对应模块数据,在getRemote中的factory就是commonIndex中的(module) => () => module。
我们理解了普通模块的交互逻辑,那么接下来我们看一下共享依赖的交互逻辑。
当本地模块设置了shared:['lodash']的情况之后,如果执行远程模块,遇到需要引入lodash的情况,会优先使用本地的lodash。
让我们回到ensure中,看一下本地模块的容器是如何运行的。
虽然看着很多,但是是处理两种情况的代码:从标签引入依赖和用import引入依赖,通篇下来,除了判断各种边际情况以外,主要的功能是将本地的依赖传递给远程模块的init函数。
那么init是怎么处理传进来的依赖的呢?我们再回到commonIndex中,看一下init是什么逻辑。
代码看着很绕,但实际功能并不需要纠结太多,其实就是实现了把依赖挂载到globalThis,也就是window对象上。
我们可以通过控制台,看到挂载的对象
那么,既然挂载到window对象上,远程模块怎么使用依赖呢,当远程模块使用shared中设置的依赖的时候,并非直接引入,而是经过了一层包装。
那么,我们来看一下importShared做了什么事情
由于远程模块运行时,容器初始化时(init方法)已经在window上挂载了共享依赖的信息,同时由于shared配置的存在,远程模块内部可以很方便的感知到当前的依赖是不是共享依赖,如果是共享依赖则通过window使用本地模块的依赖代码,否则使用远程模块自身的依赖产物代码。
总结
到目前为止,我们已经很明了vite的远程模块的原理和逻辑了,来捋一下整个交互流程。
本地模块引入远程模块的入口文件
执行远程模块的入口文件的init,传递给远程模块共享依赖,并挂载到window上
根据模块名,通过入口文件暴露的get,获取远程模块对应的地址,然后引入
引入远程模块后,如果有对应依赖,先去window上进行查找,如果没有就拿远程模块自己的依赖
ESM
好了,我们又回到了ESM,在我们前几篇的印象中,ESM就是ECMAScript官方提出的模块化规范,可以用import进行导入,同时也可以用export进行导出。其实ESM并没有我们想的那么简单,他依然有很多用法。
引入ESM,主要有三种方式:
绝对路径引入,比如 https://cdn.skypack.dev/vue@2.6.14
相对路径引入,比如./m.js
包名引入,比如import vue from 'vue'
import map
我们经常在项目中使用包名引入,但我们有没有想过,我们也可以在浏览器中使用,只要我们使用了importmap。
我们在html中增加一个新script标签,并增加type="importmap",浏览器就会记录下里面第三方包的映射表,如果遇到引入包的情况,会直接到对应地址拉取依赖代码,在这里我使用了vue作为示例。
可以看到,我们在控制台打印出来了。
但是,俗话说得好,好功能兼容性都不太好,在canIuse上,兼容值达到了71.98%,并没有type="module"的95%高,好在社区提供Polyfill的解决方案:es-module-shims
Nodejs 包
nodeJs也对ESM进行了支持,如果我们需要使用ESM,存在以下两个方式:
文件以.mjs扩展名结尾
package.json 中声明type: "module"
如果导出一个包,我们可以在package.json的exports和main中选择一个来进行设置,其中,exports的优先级高于main。
main字段使用较为简单,接受一个字符串,设置为包的入口文件块就可以了、
但exports就复杂多了,他包括默认导出、子路径导出、条件导出。
其中条件导出中,可以设置为node,并进行嵌套条件导出。
import: 指import进行引入的情况。
require: 用于require方式导入的情况。
default: 兜底方案
如果导出一个包,那我们需要用到package.json的imports字段。
import { cloneDeep } from "#dep";
这个字段类似于alias别名,但依然有区别,imports是全量匹配,无法像别名一样进行路径拼接。
modulepreload
在veu-cli中,我们会注意到打包产物的html,会被注入以下标签
<link rel="preload" href="main.js" as="script">
这个是preload方式进行预加载,也就是说在资源使用之前进行加载,而不是在使用的时候才进行记载,为的就是让资源更早地到达浏览器而不阻塞页面。
在这个标签中,我们会生命href和as属性,分别标识资源地址和和资源类型。
同时,针对ESM,也提供了modulepreload来进行预加载,但是浏览器兼容问题依然存在。
只有73.9%的浏览器会进行兼容。
但vite中可以使用build.polyfillModulePreload:true,来注入基于preload的polyfill,让这个属性可以达到preload的兼容支持度。
依赖预构建
我们一直提到,开发环境是esbuild,生产环境用Rollup,虽然在前也介绍了esbuild并不局限开发环境,但预构建的确是使用的ESbuild,那么esbuild究竟是怎么进行预构建的呢?
目前预构建代码都在当前文件中,可以进行对照查看。
_metadata.json
如果我们经历过一次预构建,我们可以找到./vite/deps/_metadata.json这个文件,这个文件记录了预构建的关键信息,形成了缓存,让我们可以在第二次启动的时候,通过文件中的hash值,来判断是否还需要进行预构建。
需要注意的是,这个文件中虽然依赖也存在fileHash,但进行预构建的依据是最外层的hash,而非单个依赖的hash,而这个hash是怎么得出来的呢,通过源码我们可以看到影响预构建hash的因素有很多。
新依赖的安装、配置项的改变都会影响预构建的hash值,如果的出来的hash值与_metadata.json中的hash值不同,那么就意味着没有命中缓存,所以不会使用缓存内容,而是直接开始进行预构建流程。
如果serve.force为true,那么就不会对比hash,也会直接开始进行预构建流程。
依赖扫描
在预构建流程开始之前,vite会先进行依赖扫描,收集依赖列表,然后,将收集起来的依赖通过ESbuild进行打包,这样可以节省很多时间。
这里需要注意的是,这里的依赖扫描,指的是业务代码的依赖,而对于,第三方依赖的依赖,是不会进行扫描的。
那么,esbuild是怎么进行依赖扫描的呢?
我们注意到,在源码中,这个函数返回了依赖关系.
const { deps, missing } = await scanImports(config)
而这个函数内部是怎么实现的呢?
可以看到,这个函数的确调用了ESbuild, 但并没有产出任何信息,而是自己实现了一个ESbuild的插件,然后通过修改入参的方式,将插件中扫描到的依赖和缺失的依赖暴露出来,然后在上文中返回。
换句话说,在这里ESbuild并非一个打包工具,而是借用了ESbuild的快速的特点,通过插件把依赖和缺失依赖获取到了。
那么,解析路径的插件就是这里核心中的核心,这个插件通过获取入口文件,扫描记录了遇到的所有依赖,
那这里,我们按照寻找的线索,进入esbuildScanPlugin来看看内部的逻辑,我们马上点亮所有的逻辑了。
不过在这之前,我们先思考一个问题,ESbuild如何开始扫描依赖?或者说,入口文件是哪个?
在vite中,因为有双引擎,因此入口文件可能存在多个配置中,比如optimizeDeps.entries和build.rollupOptions.input,甚至还需要考虑多入口的情况,甚至用户没有进行配置,进行了开箱即用,因此需要一个逻辑去探查入口文件的位置。
我们把目光移到上图,发现入口已经被找到了,也就是entries,也就是说,确定入口是在scanImports开始就进行的。
好了,入口解决了,我们来通过esbuildScanPlugin进入入口模块,看看依赖扫描最后一片拼图是怎么进行工作的。
esbuildScanPlugin是一个巨长的函数,但不要被他的长度吓到,大部分是处理HTML以及vue、Svelte等等情况,毕竟他们中的依赖也需要使用正则来匹配出来,同时为了更方便的匹配,还会为了不同情况产生不同的处理。
比如html中,带有type=module的script标签,对带有src的会改写为import,如果标签内有具体内容的,还会拼接到之前的内容,成为一段js代码。或者对import.meta.glob进行处理转换重写。处理一些情况,使vite应对各种情况都万无一失。
如果把各种情况都处理了,那么整个项目的依赖在逻辑上就是一段js代码,所有的依赖都被前面的情况处理成import了。
既然已经是import,那么我们就可以用build的onResolve来识别了,既然能识别,那么我们就能记录了。
这个就是核心逻辑中的核心逻辑,如果当前依赖没有被排除,那么就去解析地址,如果成功了,判断是否是第三方依赖,并且是否为js或者ts文件,如果是的话,就扩增depImports,也就是({ deps, missing } = await scanImports(config))中的deps,如果没有解析成功地址,就扩增missing。
顺带一提,这里的解析地址,并非仅仅是vite自己实现,而是靠着各个插件的resolveId方法进行路径解析,都没有解析到,才会被归到missing。
就此,依赖扫描结束,得到了依赖列表deps和无法解析的依赖列表missing。
上文我们提到了依赖扫描是业务代码的依赖,而实现地方就在此核心逻辑中,externalUnlessEntry({ path: id })返回一个对象,里面包含了external字段,而externalUnlessEntry的逻辑就是,如果path不是业务代码的入口文件的绝对地址,就返回true进行排除。既然被排除,ESbuild就不会进行深入依赖扫描,因此就实现了只扫描业务代码的第三方依赖的逻辑。
而业务代码中,vue文件会被当做html来识别,并打上namespace: 'html'的标志,并且在onLoad打包成js来读取,因此vue组件相互引用,会在其他的onResolve进行解析、提取script成为虚拟模块,然后解析虚拟模块并打上namespace: 'script',并在对应地方返回path地址。
然后由于组件(现在已经被处理成虚拟模块)中有引用第三方依赖的行为,会被此核心逻辑匹配到,然后进行依赖鉴别,判断是否放到deps或者missing中。
依赖打包
既然获取到了依赖列表,那么把通过ESbuild他们打包成esm并非难事
通过esbuild,输出文件夹并没有选择/.vite/deps/,而是到临时目录processingCacheDir,然后到最后得出metafile。
最后,会删除/.vite/deps,然后把processingCacheDir重命名为deps。(processingCacheDir默认地址为/.vite/processing)
而metafile中有些数据是对我们有用的,因为我们现在构建了依赖,但是还没生成_metadata.json,那么下次缓存机制就失效了。
同时,我们注意到,入口变成了flatIdDeps,而非之前的deps,其实在打包之前,vite对deps中的依赖列表进行了扁平化,这一导致了在开发环境和打包的情况下, 获取第三方依赖的名称不一致的问题。不过扁平化解决了深层嵌套导致的路径解析麻烦和不可控的问题。
同时,vite遍历了deps,进行了拆解,原来的value作为src的值,同时增加了file(进行扁平化的地址,此地址并没有对应产物)和browserHash,id(等同当前key值,当前key不变),而flatIdDeps就是扁平化id和实际地址对应起来的键值对数组。
接下来,我们来生成_metadata.json。
在预构建一开始,我们就依靠depsInfo生成了大部分的信息,然后根据打包结果,将剩下的依赖也写入对象,并转成JSON,写入磁盘,那么新的_metadata.json就生成了。
不过,问题并没有结束,还记得前面的扁平化吗,我们提到,扁平化是为了解决深层次嵌套目录,但仅仅改个名字,就能扁平化了吗?
实际上并不是这样的,为了达到扁平化,vite引入了一个新的逻辑:代理模块。
代理模块
代理模块代替了真实的模块作为打包入口,那么我们看一下代理模块是怎么工作的,以及为什么要使用代理模块。
代理模块是进行预构建打包的是时候实现的,或者说是由打包添加的esbuildDepPlugin插件实现的,这个插件接受三个参数,分别是flatIdDeps, flatIdToExports, config,config是配置项,flatIdDeps指之前提到过一次,flatIdToExports又是什么呢?
我们看向他们的生成逻辑。
es-module-lexer是一个解析ES导入导出的库,这个库在vite经常被使用。flatIdDeps作为入口文件传入打包器中,同时也传入这个插件中,他的结构是扁平化的文件id作为key,实际的绝对地址作为value。而flatIdToExports是扁平化的文件id作为key,而导出信息是value,其中第一项是引用数组,第二项是导出数组,值得注意的是, export * from 导出语法会被记录在 import 信息中,所以vite这里进行正则匹配,并增加了hasReExports标识。
进入esbuildDepPlugin函数,我们发现他的逻辑和依赖扫描类似,但最重要的是这些代码。
这部分代码,把我们扫描出来的依赖,通通进行了包装,新建了一个虚拟的js,通过相对路径,引入扫描出来的依赖,而打包器打包的对象,从扫描出来的依赖变成了这些虚拟js,或者说,变成了代理模块。
同时我们进行了导入和导出的语法解析,保证了代理模块也可以导出真实模块相同的内容。
之后,就交给打包器进行打包了。
那么为什么要使用代理模块呢?
这里有段注释,大致讲述了为什么使用代理模块。
// For entry files, we'll read it ourselves and construct a proxy module
// to retain the entry's raw id instead of file path so that esbuild
// outputs desired output file structure.
// It is necessary to do the re-exporting to separate the virtual proxy
// module from the actual module since the actual module may get
// referenced via relative imports - if we don't separate the proxy and
// the actual module, esbuild will create duplicated copies of the same
// module!
大意就是,我们保留原始的id,方便esbuild输出对应的文件结构,同时,需要奋力虚拟模块和真是模块,因为真是模块可以通过相对地址来引入,如果不分离,那么esbuild会产生,创建相同的副本。
可能不好理解,但是如果加上之前的一个条件,就可以帮助我们来理解了:
扫描依赖,并非扫描整个node_modules包中的东西,而是扫描业务代码的依赖。进行预构建的也是这些依赖。但是,如果这些依赖之中,又依赖了已经参与预构建的依赖呢?那么我们就会得到两份几乎一样的模块。一份参与预构建的某个依赖,另一个是参与预构建的某个依赖的依赖。
举个例子,我在项目中同时引用了rxjs和@rishiqing/sdk/rxjs。sdk中的rxjs其实就是require了rxjs然后进行重导出。
但是我在进行预构建的时候,build.onLoad的返回值中,contents是需要我返回文件内容,当我返回rxjs的文件内容的时候,会形成一个rxjs预构建包,当预构建@rishiqing/sdk/rxjs的时候,这次并不会如同依赖扫描一样跳过,而是会进行深层次依赖扫描,因此会根据require获取到rxjs的文件内容,再次对rxjs打包。
由此,我这里就有了两份rxjs预构建包。
那么代理模块是怎么处理这种问题的呢?代理模块返回的文件内容是重导出拼接的,当预构建rxjs的时候,代理模块里面是export * from 进行包装的相对node_modules的真实地址。而到rishiqing/sdk/rxjs的时候,require(’rxjs‘)也会指向rxjs的真实地址,因此esbuild会将两个rxjs识别为同一个模块,从而只打包一份rxjs。
总结:产生的原因是vite追求快速,从而只扫描业务代码的依赖,从而在预构建这些依赖的时候,如果出现依赖的依赖是预构建依赖的时候,同时由于入口模块是为了方便esbuild输出对应的文件结构,而使用目前还不存在的、扁平化的地址,需要在onLoad返回文件内容,导致会打包出两份相同的模块。为了解决这个问题,使用代理模块来重新建立依赖链。
虽然在依赖扫描阶段进行全依赖扫描可以避免这个问题,但这大大降低预构建的速度。
preAlias
到目前为止,我们已经结束了预构建,.vite/deps中已经存在了构建好的ESM,但是,我们的业务代码的依赖依然指向node_modules,因此我们需要做一些小处理,让他们指向预构建出来的ESM.
还记得我们上一篇提到,vite的插件加载顺序,alias插件是排在第一位的,即使是使用了enforce:pre的插件,优先级依然不会比他高。
这里的alias插件,指的是vite:pre-alias和rollup/plugin-alias,前者就是指向了预构建的ESM,后者我们在上一篇也提到过,就是使用了插件上下文来规避resolveId这个钩子只会捕获一次的问题。
那么,preAlias是怎么让依赖进行重定向的呢。
通过追踪代码,我们注意到,preAlias是从server._optimizedDeps这个在私有变量,获取到metadata信息,然后通过id提取里面的file,进行拼接、返回,进行重定向的。
而server._optimizeDepsMetadata是怎么来的呢?
我们可以在servee/index.ts文件里面,找到server._optimizedDeps是在vite启动本地服务器后,执行了buildStart钩子后,进行挂载的,这里需要注意的,挂载并没有使用await,也就是说没有阻塞后续代码的执行。
以下是挂载的方法。
这里省略很多代码,以及优化和边界情况,我们把注意力接种到返回值optimizedDeps,因为这个返回值最终会被挂载到server._optimizedDeps上, 其中discoverProjectDependencies就是我们一开始提到的扫描依赖的方法,他最终会返回依赖预构建的metadata信息。
整个预构建的流程如下:
本地服务器启动 -> 预构建 -> 如果没有关闭预构建,就创建预构建信息 -> 如果server.force不为true 获取_metadata.json -> 如果有结果就往server._optimizedDeps挂载metadata -> 没有结果就启动依赖扫描 -> 依赖打包 -> 往server._optimizedDeps挂载metadata -> 加载preAlias插件 - >resolveId钩子通过server._optimizedDeps 获取包名进行路径重定向
HMR
在上一篇,我们提到了vite相关的热更新的钩子,那么这次我们把目光聚焦到vite的热更新策略上。
vite和vue-cli的热更新是有区别的,在vue-cli中,如果我们修改了vue.config.js,是不会进行热更新的,需要我们重启项目,但vite是可以进行热更新的。
因为vite是基于HMR边界的更新模式,也就是说,当一个模块发送变动,vite会自动寻找边界,然后更新边界模块。接下来,然后我们来梳理一下,vite的HMR的实现逻辑,以此来实现更加深入的认识。依赖图可以在这个文件里面查看。
模块依赖图
vite为了管理各个模块的依赖关系,会在本地服务器创建依赖图,HMR的边界判定,实际上就基于这个依赖图来进行实现的。
创建依赖图主要分为三个步骤:
初始化依赖图实例
创建依赖图节点
绑定各个模块节点的依赖关系
1、依赖图在本地服务器启动的时候,就会进行实例化,内部存放了依赖关系,这些依赖关系是以Map数据结构存放的节点。这些节点记录了很多有关模块的信息,而最需要关注的是importers 和importedModules,这两条信息分别代表了当前模块被哪些模块引用以及它依赖了哪些模块,是构建整个模块依赖图的根基所在。
2、创建依赖图节点是在本地服务器移动的中间件中进行创建的,检测到一个模块被引入的时候,如果存在缓存,那么就使用缓存,否则就调用vite各个插件的resolveId和load方法对模块进行加载,加载后,判断有没有节点缓存,没有就创建并记录到 urlToModuleMap、idToModuleMap、fileToModulesMap 这三张表中。
3、目前已经绑创建了各个节点,但实际上他们只见的相互关系并没有绑定,那么在哪进行绑定的呢,vite在插件vite:import-analysis中处理这些逻辑,在这个插件的transform钩子中,会对代码的import语句进行分析,会得到以下信息
importedUrls: 当前模块的依赖模块 url 集合。
acceptedUrls: 当前模块中通过 import.meta.hot.accept 声明的依赖模块 url 集合。
isSelfAccepting: 分析 import.meta.hot.accept 的用法,标记是否为接受自身更新的类型。
得出这些逻辑之后,会进入绑定环节,这里会循环依赖模块url,然后往他们的依赖图节点添加当前模块,同时往当前模块的依赖图节点添加依赖模块。acceptedUrls也会进行循环,并绑定在当前模块对应位置。
随着越来越多模块经过transform钩子处理,整个依赖图会被慢慢补充完整。
收集更新
模块依赖图完成了,接下来,vite需要根据这个图来收集更新模块,来进行边界判定。
首先vite会再启动本地服务器的时候,通过chokidar新建文件监听器。并且通过修改文件、新增文件、删除文件三个逻辑,来进行对应的热更新操作。
修改文件
当监控到某个文件被修改了,那么首先会执行清除缓存操作,将此模块的缓存信息去除。然后进入收集更新阶段。
可以看到,vite这里针对文件类型不同,热更新的策略不同。
如果是配置文件和环境变量改动,vite会重启服务器,这个与vue-cli是不同的
如果是客户端注入文件的修改或者html修改,vite会刷新页面
如果是普通模块修改,vite会获取需要热更新的模块,然后对这些模块一次查找热更新边界,然后将更新信息川哥客户端。
可以注意到,最后一行代码的updateModules,这个是核心逻辑,用于普通文件的热更新编辑查找逻辑的实现。
需要注意的是,propagateUpdate函数,也是通过修改入参:boundaries的方式,来向外传递需要热更新的边界的。当收集结束后,updateModules通过boundaries遍历出需要的信息,然后把这些信息推送给客户端,从而完成局部模块更新。
新增与删除
在vite中,虽然新增和删除的逻辑是分开的,但调用的方法并没有区别,而是在删除的时候,增加了一个标识。同时,方法内部逻辑较为简单,获取删除、新增的模块,然后把模块交给updateModules来处理,然后推送到客户端,处理的逻辑我们在上文说过了,因此省略。
客户端
我们在服务端收集更新,然后推送到客户端,那么,客户端收到了推送信息,如何进行热更新呢?
我们之前注意到,vite会在开发阶段,往HTML注入一段js,而这段js创建了WebSocket 客户端,并与 Vite Dev Server 中的 WebSocket 服务端建立双向连接。
那么,服务端的更新信息,就会从这里被接收到,进入handleMessage函数进行处理,在这里面维护了心跳检测、full-reload等事件类型,这里面,我们把目光集中到js-update,js的更新逻辑,也就是queueUpdate(fetchUpdate(update))上。
因此,可以看出,我们在客户端获取了这些信息:
边界模块所棘手的模块
触发更新后的回调
但是,这里产生了一个问题,hotModulesMap是怎么来的?派发热更新首先是从hotModulesMap获取路径,那么hotModulesMap是怎么来的呢,其实我们打开项目中任意一个文件,会发现在顶部注入了一些工具代码。
vitecreateHotContext接受了此文件的路径,那么这个函数的内容是什么呢?
看到这里,我们可以得出结论,vite给每个可以热更新的模块注入的工具代码有两个作用
将当前模块 accept 过的模块和更新回调函数记录到 hotModulesMap 表中
而前面所说的 fetchUpdate 函数则是通过 hotModuleMap 来获取边界模块的相关信息,在 accept 的模块发生变动后,通过动态 import 拉取最新的模块内容,然后返回更新回调,让queueUpdate这个调度函数执行更新回调,从而完成派发更新的过程。至此,HMR 的过程就结束了。
我们来回顾下整个热更新的流程。
首先,Vite 创建了模块依赖图的数据结构,在 HMR 过程中,服务端会根据这张图来寻找 HMR 边界模块。
其次,HMR 更新由客户端和服务端配合完成,两者通过 WebSocket 进行数据传输。在服务端,Vite 通过查找模块依赖图确定热更新的边界,并将局部更新的信息传递给客户端,而客户端接收到热更信息后,会通过动态 import 请求并加载最新模块的内容,并执行派发更新的回调,即 import.meta.hot.accept 中定义的回调函数,从而完成完整的热更新过程。
3.0
到目前为止,我们已经基本了解到了vite的基本核心概念,但本系列使用是2.9.14版本,在前一阵子,vite更新了3.0,由此,也带来了一些较为重要的更新。
CLI
vite启动后,界面会跟之前有所不同 ,同时,端口为了防止冲突,默认端口从3000变到5173。
WebSocket
vite2有个问题,就是存在代理的情况,需要手动配置WebSocket 使 HMR 生效。目前 3.0 内置了一套更加完善的 WebSocket 连接策略,自动满足更多场景的 HMR 需求。
服务冷启动性能提升
虽然vite的冷启动非常快,但依然有一些问题,在2.0 - 2.9之间,vite会在服务启动之前进行依赖预构建,但这样子会造成两个问题:
依赖与构建会阻塞本地服务器的的启动,但本是是可以并行运行的
如果插件注入了依赖,会导致vite进行二次预构建,因为这个属于运行时依赖,冷启动无法扫描到,但结果上是启动时就出发了二次预构建。
二次预构建是一个需要避免的问题,因为如果依赖gengxin ,页面需要reload,同时预构建需要将所有依赖全量预构建,会导致开发服务器性能下降。
在2.9版本,解决了预构建阻塞问题,也就是我们前文提到在buildStart钩子执行后就进行了依赖扫描,如果某些依赖是运行时发现的,那么vite会尽可能复用已有的构建产物。
虽然解决了大部分的二次预构建问题,但这个问题依然存在,就是当启动时发现了一个第三方依赖包,这个包此时与一个已经进行预构建的包,有着共同的依赖,那么这个共同的依赖会被抽离成为一个公共chunk,因此也导致之前的预构建包发生了变化,又触发了一次预构建。
正常来说运行时发现依赖,进行预构建是一个合理的行为,但二次预构建会导致所有依赖再次打包,然后刷新页面。
vite3.0根本上解决了二次预构建问题,核心思路是延迟处理,也就是预构建行为到页面加载的最后阶段进行,此时vite已经编译完所有文件,可以准确记录下所有需要预构建的依赖,包括在插件阶段添加的依赖,然后统一预构建。
import.meta.glob 语法更新
vite 3.0对import.meta.glob进行了重写,增加更加灵活的匹配,同时,废弃掉原来的import.meta.globEager,改为import.meta.glob("*.js", { eager: true });
更细粒度的 base 配置
在某些场景下,我们需要将不同的资源部署到不同的 CDN 上,比如将图片部署到单独的 CDN,和 JS/CSS 的部署服务区分开来。但 2.x 的版本仅支持统一的部署域名,即base 配置。在 3.0 中,可以通过 renderBuiltUrl 进行更细粒度的配置
Esbuild 预构建用于生产环境
我们之前反复将,ESbuild用于开发环境,Rollup用于生产环境,但在3.0 ESbuild也可以在生产环境使用了,在vite2中,生产环境使用Rollup打包,然后用@rollupjs/plugin-commonjs 来处理 cjs 的依赖,这样做会导致依赖处理的不一致问题,造成一些生产构建中的 bug。
但在3.0中,可使用optimizeDeps.disabled: false,可以让ESbuild的预构建在生产环境也启用。
这里需要注意的是2.9中,optimizeDeps.disabled的的作用为是否进行预构建,而3.0改变了他的作用,变成了指定环境不进行预构建,默认值为build。
4.0
Rollup 将在接下来的几个月发布 v3 的大版本,要知道,Rollup 2.0 发布至今已经过去 2 年多的时间了,无论是 Rollup 还是 Vite 来讲,这都是一次非常重大的变更。由于 Vite 的架构非常依赖 Rollup,在 Rollup 发布 v3 之后,Vite 也将跟随着发布 Vite 的第 4 个 major 版本。所以,Vite 4.0 也很快到来。
小结
到现在我们理解了vite的生态,以及ESM的使用方法,然后我们大致了解了esbuild的预构建功能,以及vite的HMR功能,到最后也了解到vite3.0的变化。
目前来说,我们学到的东西足够我们使用vite进行业务开发了,也比上一篇更加进一步了解底层的实现逻辑。了解底层不光有助于自己了解vite,还有助自己之后的编程思维的成长。知其然,也知其所以然。
到这里,vite的学习就告一段落了,虽然可以自行手写一个简易版vite来进行实践,达到深入理解的程度,但大片代码的观感确实不会很好。
不过,这依然不是学习的终点,我们需要学习的东西还有很多,我们来到了一个新的起点。