vite特性
vite是新型的前端构建工具,The State of JavaScript Survey 2022的js社区调查结果中显示,Vite 在全球开发者中的满意度超过 98%,为什么vite会这么受欢迎,vite本身又有哪些特性,我们下面一起来看下:
vite主要特点
快速冷启动:Vite 利用了浏览器的原生 ES Import 特性,避免了打包其他服务,实现no-bundle,从而提高了冷启动速度。
按需编译:Vite 按需编译当前页面需要的组件,而不需要打包整个 APP 的组件,这样的提升对比其他构建工具如 Vue CLI 更加明显,特别是在项目越大速度差距越大。
快速的热模块替换 (HMR) :Vite 的 HMR 更新速度不会和模块数量牵扯,Vite 会让 HMR 一如既往的保持快速。
内置 TypeScript 支持:Vite 内置了 TypeScript 的支持,开箱即用,无需额外配置。
基于 Rollup 的生产环境构建:Vite 在生产环境下基于 Rollup 打包,这使得 Vite 的构建速度在与基于 webpack 实现的 vue-cli 相比,差距不大。
插件化架构:Vite 提供了插件化的架构,可以方便地扩展和定制 Vite 的功能。
开箱即用:Vite 提供了一些开箱即用的功能,如 CSS 预处理器支持、JSON 模块支持、环境变量注入等。
以上是vite的特点及其优势,那么vite是怎么做到的呢,下面一起来揭秘:
快速冷启动
原因
vite的的快速冷启动主要是取决于本身是no-bundle的构建工具,Vite 基于浏览器原生 ESM 的支持实现模块加载,无论是开发环境还是生产环境,都可以将其他格式的产物(如 CommonJS)转换为 ESM。
同时注意代码分为两部分,一部分是源代码也就是业务代码,另一部分是node_modules中的代码,业务代码是no-bundle的,对于三方依赖(node_modules)的代码还是需要bundle的,并且使用esbuild来进行打包,速度极快。对三方依赖打包有两个主要原因,第一个原因是模块兼容性 ,因为很多三方依赖包并没有生成ESM的的产物,CMJ格式的代码无法在浏览器运行,所以需要预构建将其转化成ESM的产物;第二个原因是优化性能,因为很多三方依赖本身的依赖层级是非常深的,模块数量非常多,它在加载时会发出非常多的网络请求导致页面卡顿,所以预构建也将三方库的文件合并到一起,减少http请求,优化加载性能;
提问
1.归根结底vite是基于ESM实现no-bundle,而将非esm的三方依赖的转换是如何实现的呢?
2.ESM的模块指定符号必须是完整的URL
或者是以/
、./
或../
开头的相对URL
,vite对裸模块是怎么做的路径处理呢?
3.怎么将用户在配置文件中定义的esbuild配置项合并应用到预构建的过程中呢?
"裸模块"(Bare Module Specifiers)是指在 ES6 模块导入语句中直接使用包名而非相对路径或绝对路径的形式。
可以跟着下面的讲解来一起解答这些问题;
原理
上面的部分概念介绍完了,下面我们来看看这部分的原理实现;(不要分心哦😁)
归根结底vite是基于ESM实现no-bundle,而将非esm的三方依赖的转换是如何实现的呢?
模块的转换是由esbuild来实现的,那具体是怎么实现的呢,可以来看一下依赖预构建对于三方模块的处理。
依赖预构建
预构建的概念
所谓依赖预构建指的是在 DevServer
(开发服务器) 启动之前,Vite 会扫描使用到的三方依赖从而进行构建,之后在代码中每次导入(import
)时会动态地加载构建过的依赖这一过程,也就是说只有在开发环境才存在依赖预构建。
对于这些预构建的文件,默认情况下vite 会统一放在 node_modules/.vite/deps/ 文件夹下。
预构建流程
预构建核心功能流程图
以上是预构建做的主要事情,先对上面的核心流程做进一步介绍,首先是在启动开发服务器时会执行依赖预构建的逻辑,这里会先进行依赖分析,这一步的目的是得到需要打包的三方依赖列表;主要做的事情是先拿到依赖分析的入口文件,这个入口文件可以再vite的配置文件中指定,默认情况下是会去把根目录下的html文件当成依赖分析的入口文件,拿到入口文件会用正则匹配到其中引入的script脚本文件,分析脚本文件中引入的模块信息,如果是三方模块则会记录到需要打包的列表中,如果是普通模块则递归的分析下去,直到拿到完整的依赖列表。最后将其给到打包器进行依赖打包。打包出来的文件和信息记录默认情况下vite 会统一放在 node_modules/.vite/deps/ 文件夹里。(当然依赖打包的过程中会有对模块路径的查找和处理,对模块的缓存优化等操作,下文我们会详细介绍)
这里我们还可以看到,对于引入的vue,cube-ui三方依赖包的路径都会被改写为预构建出来的资源所在地;这块资源路径重写的逻辑在插件中实现,下面也会有分析;
上面我们介绍了vite预构建的核心流程,那么vite是如何实现这些功能的呢,我们来跟着源码来看下预构建功能的具体实现逻辑。
预构建源码分析
Vite 源码结构为
monorepo
结构,vite相关的代码都在vite文件夹中;
首页我们找到开发服务器的入口,预构建的代码实现就在这个方法里;
下面这段代码在_createServe方法中,上面我们知道这个方法是用来创建开发服务器的,其中initDepsOptimizer方法会进行依赖预构建;
而container是vite在开发环境模拟rollup实现的一套插件容器,buildStart是插件的钩子,在开始build的时候调用;(在下文会对这个插件架构做进一步介绍;)
依赖扫描
在initDepsOptimizer函数内部,这里就正式开始预构建的逻辑了,根据上面流程图的步骤,我们知道它会先进行依赖分析,收集依赖列表,接下来我们就来看这这部分功能的代码实现:
discoverProjectDependencies
依赖收集的核心方法是discoverProjectDependencies,这个方法内部会调用scanImports方法拿到收集的三方依赖列表;
scanImports
而在scanImports方法中,主要做两件事情:
1.拿到依赖扫描的入口文件,可以在配置中指定入口文件,没有对应配置的话默认会从根目录开始找html文件作为入口文件;
2.调用prepareEsbuildScanner方法,传入入口文件作为参数,将其作为结果返回;
prepareEsbuildScanner
这个方法会利用esbuild扫描得到依赖数据:
1.创建vite插件容器,vite插件容器后续会详细介绍,本质是在开发环境实现一套rollup一样的插件机制;
2.定义esbuildScanPlugin插件,这里有处理依赖的核心逻辑,下面会详细介绍;
3.利用esbuild进行依赖扫描,传入对应的插件,并且指定write为false,表示不会写入磁盘,提升扫描速度;
esbuildScanPlugin
是传入esbuild在依赖扫描阶段定义的插件;
这个插件内部主要是调用build.onResolve和build.onLoad两个钩子;
build是esbuild中的打包阶段API:
1)
build.onResolve
:这个方法用于解析模块路径。当 esbuild 在构建过程中遇到一个模块导入时,它会首先调用onResolve
方法来解析模块的路径。可以在onResolve
方法中对模块路径进行自定义处理,将某些特殊的模块路径映射到其他的路径。
cssbuild.onResolve({ filter: /^env$/ }, args => ({ path: args.path, namespace: 'env-ns', }))
2)
build.onLoad
:这个方法用于加载模块内容。当 esbuild 在构建过程中需要加载一个模块的内容时,它会调用onLoad
方法。可以在onLoad
方法中对模块内容进行自定义处理,将某些特殊的模块内容替换为其他的内容。
cssbuild.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({ contents: JSON.stringify(process.env), loader: 'json', }))
下面是对于这个插件具体逻辑的分析:
1.首先将一些资源路径的模块external,不进入后续的依赖扫描流程,不分析其依赖关系;(例如http(s),data:开头的模块,这部分不具体分析,感兴趣也可以去看下源码~)
2.对于入口html文件,会将内部script,type属性为module的择出,有src属性 的拿到路径地址,没有src属性会转化成虚拟模块处理,虚拟模块的build.onload阶段会返回js content,这些引入的js脚本内部的模块依赖也会被扫描到;(入口文件还可以指定为vue|svelte|astro|imba等类型,这里也暂不分析这些场景)
虚拟模块处理逻辑:
3.前面两步都是对非三方依赖的处理,第三步我们分析对三方依赖的处理,也就是针对需要被构建模块的处理;
这里会匹配出裸模块进行处理,根据代码逻辑主要有以下几步的判断处理:
a.如果模块在配置文件中被exclude了,就直接external,不对该模块进行依赖扫描和记录;
b.如果已经被添加到预构建的列表中,也external;
c.解析出的路径无效或者是虚拟模块,也external;
d.解析出的路径包含node_modules或者为配置文件中被include的模块,符合预构建条件,添加到预构建列表中;
裸模块指的是在模块导入语句中直接使用包名而不是相对路径或绝对路径。
这里的resolve函数用于解析模块路径,本质也是调用vite插件容器的resolveId钩子;
流程总结:
到这里依赖扫描对应的源码逻辑就介绍完了,本质是利用esbuild去拿到需要预构建的三方依赖,下面我们再一起来看下依赖打包的逻辑~
依赖打包
在依赖扫描之后就可以拿到需要预构建的依赖模块,之后进行依赖打包的流程,也就是runOptimizeDeps方法,也同样是在createDepsOptimizer方法里,如下图:
runOptimizeDeps
在runOptimizeDeps函数里,我们拿到预构建的缓存目录,并且会创建一个临时目录存放缓存的依赖;
给缓存目录创建package.json文件, type设置为module让所有缓存文件都被识别为ES模块;
给metadata缓存文件定义browserHash值等;
之后会执行prepareEsbuildOptimizerRun 方法,这个方法内部利用 EsBuild 对于前一步扫描生成的依赖进行预构建打包;
等到 esbuild 进行打包完毕,生成最终的 metadata 文件,写入到缓存目录 .vite 中。
prepareEsbuildOptimizerRun
这个方法主要做的事情有:
1.flatIdDeps记录模块的绝对路径,idToExports记录利用es-module-lexer解析出文件import和export信息(也就是依赖信息);
2.合并用户自定义的esbuild插件配置项和esbuild的打包配置项;
3.还有就是给esbuild传入esbuildDepPlugin这个插件方法(下面介绍)
4.最后利用esbuild构建打包模块,flatIdDeps的keys值作为打包入口
这里介绍主要功能,细节部分可以看截图中的注释或源码~
esbuildDepPlugin
1.针对入口文件也就是flatIdDeps(可以理解为依赖扫描搜集的模块),兼容入口文件别名的场景,直接返回路径交给esbuild打包处理;
2.非入口模块的处理,根据模块种类(esm&cmj)返回不同的路径查找函数(最终都是调用vite插件的resolvedId处理);
3.针对入口文件自身的依赖模块的一些场景的处理(browser-external和optional-peer-dep);
这个插件里还有针对#8459 issues的bug修复逻辑:
问题描述 :就是预构建的三方依赖在构建之后的产物里对静态资源的引入的require语法没有进行转换;
修复逻辑 : require
转换为 import
演示效果如下(演示地址):
esbuildDepPlugin插件的内容就是这些了。
流程总结:
到这里依赖预构建的逻辑也整体结束了。最后再附上打包的缓存目录:
加餐
其实除了上面的逻辑之外,在initOptimizer 内部(也就是刚开始预构建的时候)还会判断依赖缓存的逻辑,判断是第一次构建的话则是会初始化metadata构建信息,根据lock文件和配置项生成hash信息,然后再进入依赖分析和构建的流程;如果是第二次构建已经存在metadata构建信息且hash值未发生改变则会直接复用;
初始化的metadata结构如下图:
最后生成的保存预构建信息文件_metadata.json中的字段结构也对应上了;
插件化架构
我们都知道vite在开发环境用的是esbuild,生产环境用的是rollup作为bundle工具,但是vite其实在开发环境实现了一套类似于rollup的插进容器(pluginContainer),该插件容器的写法完全兼容rollup,因此在开发环境中有很多rollup插件也还是能正常使用的。(一些rollup插件不能使用,是因为还有一部分机制vite是没有实现的;)
rollup
下面可以看rollup官网经典的两张图初步了解其插件机制:
钩子类型特点:
async:异步钩子
sync:同步钩子
Parallel:并行钩子
Sequential:串行钩子
First:异步优先钩子
build阶段Hook:
build阶段主要是针对module的处理(解析 加载 转换等);
output阶段Hook:
output阶段主要是针对chunk的操作,也就是针对打包出来的产物的处理;
vite
Vite 调用的钩子:
服务器启动阶段 : options
、buildStart
。
请求响应阶段 : 当浏览器发起请求时,Vite 内部依次调用resolveId
、load
和transform
钩子。
服务器关闭阶段 : Vite 会依次执行buildEnd
和closeBundle
钩子。
生产环境下,Vite 使用 Rollup, 自然所有的rollup插件也都能正常使用。
vite独有钩子(vite自定义钩子):
1.enforce
: 这个钩子的值可以是 pre
或 post
,pre
会较于 post
先执行。
2.apply
: 这个钩子的值可以是 build
或 serve
,也可以是一个函数,用来指明它们仅在 build
或 serve
模式时调用。
3.config(config, env)
: 这个钩子可以在 Vite 被解析之前修改 Vite 的相关配置。钩子接收原始用户配置 config
和一个描述配置环境的变量 env
。
4.configResolved(resolvedConfig)
: 这个钩子在解析 Vite 配置后被调用。使用这个钩子读取和存储最终解析的配置。当插件需要根据运行的命令做一些不同的事情时,它很有用。
5.configureServer(server)
: 这个钩子主要用来配置开发服务器,为 dev-server (connect 应用程序) 添加自定义的中间件。
6.transformIndexHtml(html)
: 这个钩子用于转换 index.html
。钩子接收当前的 HTML 字符串和转换上下文。
7.handleHotUpdate(ctx)
: 这个钩子用于执行自定义 HMR 更新模块。