背景
在我们以往的日常项目开发中,大多使用webpack/rollup或者在此基础上进行的二次封装改良的bundle-based 一类的构建工具,随着项目的体积规模的增大,我们是否存在这样的痛点,开启服务需要静等几分钟甚至十几分钟,改一行代码甚至也需要十几秒、几十秒钟看效果?随着浏览器的原生ES模块支持,大家掀起了一股Bundle Free的新热潮,对于以往的静态打包方式,这或许还有很多不成熟的地方,但对于他的理念这或许将是一场新的变革。
1、Vite是什么?
我们先来看一下尤大对于vite的定义
一个开发服务器,它基于原生 ES 模块提供了丰富的内建功能,如速度快到惊人的 模块热更新(HMR)。
一套构建指令,它使用Rollup打包你的代码,并且它是预配置的,可输出用于生产环境的高度优化过的静态资源。
Vite 意在提供开箱即用的配置,同时它的插件 API和JavaScript API带来了高度的可扩展性,并有完整的类型支持。
下面我们本地启动一个简单的react项目看看vite服务有多快?从下图可以看出本地服务的开启基本上是秒开。
图1.1.2 vite服务启动
vite为什么这么快离不开以下几个原因:
浏览器内置 ES Module 的支持,浏览器直接向 dev server 请求需要的模块,不需要提前把所有文件打包,从而实现的真正的按需加载。
esbuild的助力,利用esbuild超快的编译速度把第三方库进行预构建,一方面将零散的文件打到一起,减少网络请求,另一方面全面转换为 ESM 模块语法,以适配浏览器内置的 ESM 支持,下图中esbuild要比排名第二的快百倍。
图1.1.2 构建工具排名
图1.2.1 Bundle based dev server
图1.2.2 Native ESM based dev server
和常用的webpack打包工具相比,如上两图:
webpack首先会从入口开始遍历整个项目,进行打包处理,然后启动服务和浏览器交互,对于热更新,webpack的HMR速度也会随着应用规模的增长而变得缓慢。
vite在开发阶段开启服务之后,不用将业务代码打包(依赖库还是要打包的),让浏览器自身去解析和处理模块,遇到import语句可以自动发起http请求去加载对应的模块,实现真正的按需加载,当有文件的变化时,也会精准的链接模块本身,使得HMR的速度不会受项目规模变大而受影响。
webpack的打包是静态的,不管代码有没有执行都会打包到bundle里,当然也可以通过动态引入异步加载模块或者使用tree shaking等方法减小打包体积,总之看起来不如vite优雅。如果在项目逐渐庞大的时候,vite可以极大提升构建效率和开发体验。
2、Vite插件机制
vite之所以能够构建不同的框架、提供各种开箱即用的功能,依赖于它的插件机制,使用插件来扩展vite能力。vite插件基于出色的Rollup插件接口扩展了自己的hook,保留了rollup 7个钩子函数同时又提供了vite独有的5个钩子函数来处理一些在打包中不存在的需求,比如自定义的对入口index.html的处理、热更新的处理等,钩子函数返回值和参数完全参照rollup,很多rollup插件也可以跟vite直接兼容。在不同的时机对外暴露一些钩子,让用户可以自己去做一些配置,以介入到整个服务中。感兴趣的同学可以去官方git看一下,包括 兼容Rollup 的插件以及Vite 的专属插件。
以下钩子在服务器启动时被调用:(标红部分是vite独有的hook)
config 解析 Vite 配置前调用,接受用户原始配置包括命令函、配置文件的配置参数
configResolved 在解析 Vite 配置后调用,读取和存储最终解析的配置
options rollup通用钩子,返回plugin options
configureServer 配置开发服务器的钩子
buildStart 服务器开启服务之前调用
以下钩子会在每个传入模块请求时被调用:
resolveId 返回模块id
load 加载资源并返回ast
transform 转义code返回转义结果
以下钩子在服务器关闭时被调用:
buildEnd
closeBundle
transformIndexHtml是转换 index.html 的专用钩子devHtmlHook.
handleHotUpdate自定义HMR更新时调用的专用钩子。
vite devServer启动过程中,会创建一个pluginContainer插件容器来管理插件,在接收到客户端的资源请求之后,会通过pluginContainer插件容器对一系列plugins集合进行处理,执行钩子函数,vite就是通过这些钩子函数在vite devServer启动和提供服务的不同阶段,在各个时机提供不同的能力。
3、运行流程
vite是一套构建指令,从cli文件中可以看到主要有四个命令:dev、build、optimize、preview,源码位置:https://github.com/vitejs/vite/blob/main/packages/vite/src/node/cli.ts
在这里我们主要来看一下本地开启服务dev命令,图3.1.1是开启dev命令执行的核心代码,执行createServer方法并返回server,然后端口监听开启服务。
图3.1.1 dev命令执行
在展开具体流程之前我们先来了解一下vite的几个核心模块:
httpServer:node http 服务器实例,并根据http/https/http2作了不同情况的处理。
middlewares:一个 connect 应用实例,可以用于将自定义中间件附加到开发服务器,还可以用作自定义http服务器的处理函数。
webSocketServer:创建webSocket服务,HMR热更新流程中发送消息、监听连接。
FSWatcher:这是热更新的基础,使用chokidar库监听文件的变化。
PluginContainer:插件容器,主要用于对插件统一处理,执行钩子函数
ModuleGraph:模块图谱,跟踪导入关系和hmr状态。
createServer方法的源码位置:https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/index.ts
下面我们具体来看一下细节实现:
1.配置参数整合:通过resolveHttpsConfig方法对config参数进行处理,对于命令行vite --config xxx.ts指定配置文件,或者寻找根目录的vite.config.js| vite.config.mjs | vite.config.ts默认配置文件,合并参数;定义一些日志方法、根目录、默认别名、缓存路径、公共资源路径等;取配置中的插件进行排序整合,执行config和resolvePlugins钩子函数,最后执行configResolved钩子,到此config配置基本不会再发生改变了。
2.创建httpServer,主要是启动本地服务,使用connect框架创建实例并注册一系列的中间件依次执行,用于处理客户端的各种请求。
const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)
// Internal middlewares...
middlewares.use(servePublicMiddleware(config.publicDir))
middlewares.use(transformMiddleware(server))
middlewares.use(serveRawFsMiddleware(server))
// ....
middlewares.use(indexHtmlMiddleware(server))
// ...14个内部中间件
middlewares注册了14个中间件,其中servePublicMiddleware中间件,处理public静态文件目录,使得这些文件就像没有变换一样;
核心转换器transfromMiddleware,用于处理js/css/vue等请求,维护模块图谱moduleGraph、缓存处理等,对资源解析、加载、转换,返回客户端代码;
indexHtmlMiddleware中间件转换入口文件index.html,注入/@vite/client.js和/@react-refresh热更新部分代码。
3.创建webSocketServer,主要用于开发阶段的热更新,发送信息,监听连接。使用chokidar这个跨平台文件监听库库,监听文件的改动add、unlink、change,同时会更新模块依赖图谱moduleGraph,同步热更新。
const ws = createWebSocketServer(httpServer, config, httpsOptions)
const watcher = chokidar.watch(path.resolve(root), {
ignored: [
'**/node_modules/**',
'**/.git/**',
...(Array.isArray(ignored) ? ignored : [ignored])
],
ignoreInitial: true,
ignorePermissionErrors: true,
disableGlobbing: true,
...watchOptions
}) as FSWatcher
// watcher监听到变化后进行相应的热更新
watcher.on('change', async (file) => {
file = normalizePath(file)
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
if (serverConfig.hmr !== false) {
try {
await handleHMRUpdate(file, server)
} catch (err) {
ws.send({
type: 'error',
err: prepareError(err)
})
}}})
4.创建插件容器pluginContainer,主要是通过插件容器来对所有插件统一处理,执行插件的相应钩子,使得代码按照vite定义的规则做转化。
5.创建模块依赖图谱moduleGraph,跟踪导入关系的依赖图谱,它是由一系列map组成,记录了url到文件的映射关系以及热更新状态。
6.改写httpServer.listen方法,以便在listen执行之前可以做预构建处理。在开启服务之前,执行插件容器的buildStart方法遍历插件的对应钩子,然后执行runOptimize开启启动之前的优化工作即依赖预构建,把裸模块也就是import npm依赖模块和用户指定的需要预构建的模块进行打包缓存。
基本原理就是启动一个httpServer服务器,可以拦截浏览器请求,中间件和插件各司其职有序执行,中间件会调用插件的钩子函数,在不同的流程节点中对请求进行处理(图3.3.1中的插件钩子部分的虚线部分主要是在httpServer启动阶段执行的,主要是一些参数的配置),包括一些请求路径、文件等的处理改写,通过路径查找目录下的对应文件做一定的处理,最终以esm格式返回给客户端。vite的热更新主要是FSwatcher实例监听,有文件改变之后通知webSocketServer发送消息,客户端监听socket消息进行更新。
图3.3.1 整体流程图
4、依赖预构建
预构建简单来说就是对依赖的第三方库在本地服务开启之前对其进行预打包,在分析模块中遇到import该依赖,则动态使用预打包的文件。
vite为什么要做依赖的预构建?
vite主要依赖于浏览器原生的ESModule支持,vite server会把所有的请求模块都当做是es模块,所以对于一些CommonJS/umd需要转化为esm格式。
将依赖包的内置模块构建打包成单一的可缓存的文件,减少网络请求,提升页面加载性能,这一点和webpack的作用是一样的,只不过vite使用了esbuild,速度提升了几十倍。
当我们在项目中使用lodash-es库时,它有超过600个内置模块,如果我们在vite.config.js中配置optimizeDeps: {exclude: ['lodash-es'] },强制使lodash-es依赖不做预构建,从下图中可以看出有六百多个请求。
当我们使用默认的lodash-es提前预构建的优化处理,loash-es成为一个模块,只有一个请求,整体请求数量变成19个。如果没有预构建优化,浏览器将会有大量的请求造成网络堵塞,影响页面的加载速度
下面我们来看一下这个传说中的依赖预构建,也就是上面提到的runOptimize函数
其中cacheDir默认是'node_modules/.vite',optimizeDeps方法会根据package.json 的 dependencies 的参数进行编译,然后将编译出来的依赖通过esbuild打包成单文件,当浏览器器请求时就可以保证只请求一次接口了。
_registerMisingImport是一个运行时优化,当运行过程中发现没有被预构建的依赖,则会执行这个方法,并重启vite server。该函数内部也做了节流优化,会每隔100ms批量处理一批。
预构建核心optimizeDeps方法主要做了一下几件事:
定义缓存的json文件路径和元数据结构
判断新旧hash是否相等,判断是否重新预构建
核心scanInports函数,解析需要预构建的deps和missing
更新browerHash值,合入用户配置的依赖项
扁平化嵌套的源码依赖,利用esbuild对所有依赖预构建,并存入默认缓存地址/node_modules/.vite
此次构建信息写入json文件,预构建结束
下面我们来具体看看实现细节,源码位置:
https://github.com/vitejs/vite/blob/main/packages/vite/src/node/optimizer/index.ts:
1.定义json文件的保存位置,默认路径为node_modules/.vite/_metadata.json,文件中定义了依赖的元数据信息,其中hash是根据文件内容生成的hash值,browserHash是用于在浏览器获取npm依赖时在请求路径后添加的编码,例如?v=20a12891,以此作为文件是否更新的依据。optimized是所有依赖包的键值对,保存了依赖包的路径和构建之后的文件路径;needsInterop标识是在预构建过冲中是否有模式转化。
2.可以通过命令行配置force参数强制重新构建,否则如果_metadata.json文件存在并且新旧hash值相等,表示已经构建过了且没有新的变化则不再重新构建,返回上一次构建信息prevData。
3.核心scanImports函数,使用esbuild解析整个项目
找到入口文
使用esbuild打包一次,通过esbuildScanPlugin插件,找到需要预构建的deps和missing。
默认情况下,vite会抓取入口文件index.html来检测需要预构建的依赖,如果指定了build.rollOptions.input,则将会去抓取这些入口。
4.加入用户自定义的配置config.optimizeDeps?.include里的依赖(默认情况下,不在 node_modules 中的,链接的包不会被预构建。用户使用这个配置强制预构建链接的包),获取本次全部需要预构建的依赖deps和解析出问题的依赖missing。deps记录的是依赖名到其在系统中的node_moduls中的文件的路径映射关系,如果有missing值,则控制台打印提示安装信息。
5.进一步处理deps,扁平化嵌套的依赖目录结构。使用esbuild对deps中的所有依赖打包构建成esm模式,输出到node_module/.vite下。
6.把此次的构建信息写入json文件,写入node_modules/.vite/_metadata.json中。
当前端获取依赖的时候,则替换为缓存目录,加载预构建打包的文件,之后每当有新的依赖加入的时候,都会重新进行预构建重复上述步骤。
5、本地开启vite流程
本地开启一个vite react项目
npm init vite@latest vite-react --template react
在下图中可以看到所有的请求:
html文件:localhost请求返回,和项目中的html文件相比会多了一些热更新的代码
热更新相关:client、@react-refresh请求是在html文件中注入而发起的请求,env.mjs是client里引用的,是支持热更新的部分
依赖包相关:react.js、react-dom.js依赖会引入经过转化之后的chunk文件,构建前如果是commonJs的形式,会对它进行编译改写,下图中可以看到main.js中对裸模块react引用重写,引入了转化之后的react文件。文件名后的?v=7f3dd877就是缓存文件_metadata.json中的值,值不变的时候可以直接使用缓存文件
业务相关:业务项目中的main.js、App.jsx
样式相关:index.css、App.css
从项目请求中可以看到,在请求页面的时候不是向以往的webapck去请求整个bundle文件,而是按照模块去加载。
下面我们具体来看一下启动的vite react项目在处理请求时都经历了哪些过程,服务起完访问页面http://localhost:3000,之后httpServer中的app便开始调用一些列的中间件。
1. / 请求:spaFallbackMiddleware中间件会重定向到/index.html,之后进入indexHtmlMiddleware中间件,这个中间件会读取html文件内容,对html文件改写,注入热更新的客户端和react更新的支持代码。
indexHtmlMiddleware中间件调用了server上定义的createDevHtmlTransformFn,会调用插件的钩子函数,内置hook devHtmlHook方法中执行了traverseHtml方法,其中使用了@vue/compiler-dom的parse方法将模板代码转化为ast,transform方法优化ast,返回需要注入的html和tags,tags里包括了要注入的客户端信息。
执行完devHtmlHook钩子会执行插件的钩子函数transformIndexHtml,调用插件vite:react-refresh的钩子函数,也返回了新的标签。
最后applyHtmlTransforms执行完全部钩子函数之后会把返回的tags注入到html文件中。到此返回的html就是我们浏览器请求的时候加载的html内容,/@vite/client.js 简单来讲就是支持vite-hmr热更新的一些代码,而/@react-refresh是 vite 支持 react 的热更新插件代码。
2、/@vite/client 请求
client.js 里面的代码主要用于与服务器进行 ws 通信来进行 hmr 热更新、以及重载页面等操作这个请求会进入到transformMiddleware中间件处理,命中isJSRequest(url) || isImportRequest(url) || isCSSRequest(url) || isHTMLProxy(url)的处理,会调用container的resolveId方法,依次遍历插件的resolveId方法,其中会调用aliasPlugin插件对别名进行处理,resolvePlugin返回处理之后的id,接着执行transformRequest方法,调用pluginContainer.load(id, { ssr })钩子加载文件,pluginContainer.transform转换代码,最后send调用返回给浏览器。其他路径的访问也是类似的,感兴趣的同学可以自己debug看一下都经过了哪些中间件和插件。
总结
vite的迭代速度非常快,最初vite的定义是No-bundle Dev Server ,不过发展到现在已经不是完全的bundless了。vite的预构建有点类似webpack的dll-plugin loader,可以提前把package.json中的依赖模块打包成多个esmodule模块,但是vite的预构建速度却提升了几十倍。和webpack相比提供了一种全新的思路,解决了开发过程中静态打包的过程,社区里也出现了一些webpack转化vite配置的工具,目前还是主要使用在开发环境中,生产环境使用rollup打包,生产环境中使用未打包的esm文件,会有很多的文件嵌套,增加网络负担,但是他的理念更加符合开发人员的需求,值得持续关注。
参考资料:
https://vitejs.cn/guide/
https://juejin.cn/post/6990147306125262856
https://jishuin.proginn.com/p/763bfbd29d7f
https://juejin.cn/post/6966401940573913119
https://zhuanlan.zhihu.com/p/352403391
https://www.jianshu.com/p/31744aa44824
https://mp.weixin.qq.com/s/XHz-2OG7W2cg23fMTkaaZQhttps://www.jianshu.com/p/31744aa44824