当Vite启动开发服务器之前会完成依赖预构建工作,这个工作整个流程简单来说是通过入口文件扫描所有源码分析相关import语句得到使用的第三方依赖包名,之后使用esbuild对依赖进行编译,至此完成整个预编译过程。之后会启动开发服务器并在相关端口进行监听,当启动开发服务器后,Vite会如何处理源码呢?整个过程的执行逻辑具体是什么样的?这篇文章就是来学习Vite开发服务器启动后整个的处理过程。
Vite与webapck bundle机制不同,Vite是no bundle类型的构建工具。从Vite官网实际上可以知道下面的信息:
Vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。
webpack的机制是先打包再加载,webpack在开发服务器启动时会打包所有代码生成对应的chunk,所以随着代码量的增大这个过程的耗时会非常长,打包完成后会将内容存入内存中,以便后续文件修改利用动态模块热重载(HMR)优化开发体验。
相比webpack,Vite在开发服务器启动时只预编译依赖即第三方模块。在开发服务器启动后,在浏览器访问本地index.html地址,按需编译逻辑就正式开始了。
本文以vite脚手架创建的vue模板项目为例进行逻辑梳理,vite版本2.7.2。
在之前Vite预编译文章中,知道在创建开发服务器过程中注册了一系列的中间件,中间件的运行就是在资源请求过程中。当在浏览器中请求vite对应的index.html时即访问localhost:3000时,请求会到达本地开发服务,就会经过一系列的中间件(按照创建开发服务器时注册的中间件顺序执行),其中涉及到主要的中间件有:
// /public
if (config.publicDir) {
middlewares.use(servePublicMiddleware(config.publicDir))
}
middlewares.use(transformMiddleware(server))
// serve static files
middlewares.use(serveRawFsMiddleware(server))
middlewares.use(serveStaticMiddleware(root, server))
// spa fallback
if (!middlewareMode || middlewareMode === 'html') {
middlewares.use(spaFallbackMiddleware(root))
}
if (!middlewareMode || middlewareMode === 'html') {
// transform index.html
middlewares.use(indexHtmlMiddleware(server))
}
可知按照其注册的逻辑先后,初始过程必然是下面的执行顺序:
实际上Vite提供了–debug参数,可以比较清晰知道整个过程的关键处理逻辑,当然也可以在源码中手动打印日志。
Vite启动开发服务器后默认会接管本地3000端口的访问,当在浏览器中输入localhost:3000访问本地项目页面时,中间件逻辑执行如下:
真正首先起作用的是spaFallbackMiddleware中间件,该中间件就是用于支持通过/、/dir、/dir/index.html等路径访问,最后都会重置到index.html页面,保证访问到正确的index.html。
请求被重置到请求/index.html,indexHtmlMiddleware紧接着spaFallbackMiddleware后面执行,该中间件的核心逻辑如下:
if (url?.endsWith('.html') && req.headers['sec-fetch-dest'] !== 'script') {
const filename = getHtmlFilename(url, server)
if (fs.existsSync(filename)) {
try {
let html = fs.readFileSync(filename, 'utf-8')
html = await server.transformIndexHtml(url, html, req.originalUrl)
return send(req, res, html, 'html')
} catch (e) {
return next(e)
}
}
}
实际上逻辑点很清晰:如果是html文件并且该文件存在就会同步读取HTML文件内容,此时是原始的HTML内容。因为vite或者相关插件需要向HTML中插入相关代码等逻辑,所以会对HTML文件做转换。server.transformIndexHtml这个方法就是转换的核心。
server.transformIndexHtml = createDevHtmlTransformFn(server);
function createDevHtmlTransformFn(server) {
const [preHooks, postHooks] = resolveHtmlTransforms(server.config.plugins);
return (url, html, originalUrl) => {
return applyHtmlTransforms(html, [...preHooks, devHtmlHook, ...postHooks], {
path: url,
filename: getHtmlFilename(url, server),
server,
originalUrl
});
};
}
在上面函数中会应用针对html的插件,vite插件支持指定一个 enforce 属性(enforce 的值可以是pre 或 post)来调整它的应用顺序。主要是调用applyHtmlTransforms方法来做具体处理,这里暂不关心处理的过程。
通过vite脚手架创建Vue模板的项目,原始HTML通过indexHtmlMiddleware中间件处理后,实际上只添加了一个JavaScript模块,即@vite/client。经过该中间处理后html内容如下:
DOCTYPE html>
<html lang="en">
<head>
<script type="module" src="/@vite/client">script>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite Apptitle>
head>
<body>
<div id="app">div>
<script type="module" src="/src/main.js">script>
body>
html>
至此开发服务器中默认的主要中间件在初始请求html的这一次请求中执行完毕。拿到html文件后浏览器就会解析HTML文件,其中就会加载外部资源。
解析HTML文件首先会请求@vite/client文件,每一次请求都要经过上面说明的主要中间件逻辑。
function viteServePublicMiddleware(req, res, next) {
// skip import request and internal requests `/@fs/ /@vite-client` etc...
if (isImportRequest(req.url) || isInternalRequest(req.url)) {
return next();
}
}
该中间会跳过@vite/client请求,流程流转到到下一个中间件transformMiddleware中。
transformMiddleware中间件的逻辑非常重要,资源的本地加载、解析和转换都是在该中间件中完成的。该中间件中核心逻辑具体如下:
if (isJSRequest(url) ||
isImportRequest(url) ||
isCSSRequest(url) ||
isHTMLProxy(url)) {
...
if (isCSSRequest(url) &&
!isDirectRequest(url) &&
((_e = req.headers.accept) === null || _e === void 0 ? void 0 : _e.includes('text/css'))) {
url = injectQuery(url, 'direct');
}
// 对于没有变更的第二模块加载请求304处理,避免再次转换
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch) {
res.statusCode = 304;
return res.end();
}
// resolve, load and transform using the plugin container
const result = await transformRequest(url, server, {
html: (_h = req.headers.accept) === null || _h === void 0 ? void 0 : _h.includes('text/html')
});
if (result) {
const type = isDirectCSSRequest(url) ? 'css' : 'js';
const isDep = DEP_VERSION_RE.test(url) ||
(cacheDirPrefix && url.startsWith(cacheDirPrefix));
return send$1(
req, res, result.code, type,
result.etag,
// allow browser to cache npm deps!
isDep ? 'max-age=31536000,immutable' : 'no-cache', result.map);
}
针对一些特定的请求做相关处理:
对于上面的请求在初始时都会通过transformRequest函数处理,该函数的具体逻辑如下:
function transformRequest(url, server, options = {}) {
const cacheKey = (options.ssr ? 'ssr:' : options.html ? 'html:' : '') + url;
let request = server._pendingRequests.get(cacheKey);
if (!request) {
request = doTransform(url, server, options);
server._pendingRequests.set(cacheKey, request);
const done = () => server._pendingRequests.delete(cacheKey);
request.then(done, done);
}
return request;
}
每一次转换问价后都会以该文件地址为key缓存到_pendingRequests,以便下次直接返回。在该方法中又调用doTransform方法来实现具体的逻辑,其核心逻辑归纳如下:
// resolve
const _a = await pluginContainer.resolveId(url)
const id = (_a === null || _a === void 0 ? void 0 : _a.id) || url;
// load
const loadResult = await pluginContainer.load(id, { ssr });
// 加载成功确保模块在依赖图中
const mod = await moduleGraph.ensureEntryFromUrl(url);
// 对文件进行监听
ensureWatchedFile(watcher, mod.file, root);
// transform
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
ssr
});
会发现模块的resolve、load、transform都是通过pluginContainer来实现的,为pluginContainer是在创建开发服务器时通过createPluginContainer函数创建的。pluginContainer背后逻辑实际上是基于WMR下的rollup-plugin-container文件上实现的。pluginContainer本质就是一个对象,提供了resolveId、load、transform、getModuleInfo等方法。而实际上这些方法都是执行调用createPluginContainer时传入的一系列vite插件对应的resolveId、load、transform方法。这些插件具体如下:
上面插件都是在创建开发服务器合并配置文件这个过程中调用resolveConfig来实现插件的注册逻辑,包含内置插件和第三方插件,上面的vite:vue插件就是第三方插件。实际上pluginContainer中执行resolveId、load、transform就是循环依次执行上面所有插件对应的resolvedId、load、transform。如果插件对应的方法存在的话,就会执行,否则会退出本次循环。
resolveId、load、transform是Rollup插件提供的相关钩子,具体可看Rollup的Build Hooks章节。Vite插件扩展了设计出色的 Rollup接口,所以可以编写兼容Rollup的插件,当然Vite也存在自己专属的钩子,这里不再扩展讨论。
不同插件相关钩子的逻辑不同,但是其钩子的作用的是固定的,这里就以主要的resolveId、load、transform钩子来说明:
上面内置插件相关逻辑暂时不关注,当请求@vite/client时实际上通过上面对模块地址解析后得到其模块地址是:/项目聚绝对地址/vite/dist/client/client.mjs,而该文件实际上主要做了两件事:
当@vite/client请求经过transformMiddleware中间件后就会完成内容转换,之后对调用send$1响应请求,返回内容给客户端。需要注意的一点是send$1方法中会设置响应Header从而利用浏览器缓存机制尽可能缓存符合条件的模块文件,相关Header如下:
源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求
当浏览器加载到@vite/client对应的文件后,并不会立即执行代码,因为vite都是以module script方式加载文件,而module script默认是defer模式的,即加载完成后等到HTML解析完成后才会执行。
Vite默认加载JS文件都是以module script方式的,实际上页面就会并行来加载相关JS文件,在本次实例中实际上@vite/client和src/main.js会并行加载。上面整个流程是@vite/client的处理过程,实际上src/main.js的整个处理过程基本相似。
@vite/client对应着vite下client.mjs,当@vite/client加载完成后等到HTML解析完成后就会执行代码,src/main.js的文件也是如此,从而加载对应的模块依赖。而每一次文件加载请求都要按照注册的中间件顺序执行一遍,在对应的中间件做相关的工作。
按需加载是Vite基于ESM的特性之一,而按需编译机制是依靠浏览器和Vite内部相关处理共同实现的,不同于bundle机制的webpack等构建工具的全部编译,Vite将源码的按需编译放在每次请求过程中处理。本文以Vite脚手架创建的Vue模板项目为例,主要介绍了请求文件@vite/client以及开发服务器对其请求过程的主要流程做了梳理,总结如下:
transformMiddleware中间件是按需编译的核心逻辑,包含resolve、load、transform步骤,实际上就是调用相关对象的resolveId、load、transform方法,这些方法是Rollup插件提供的钩子,每个模块请求时都会被调用。而其背后实际上就是执行一系列插件的resolveId、load、transform方法。通过插件机制和中间件,按需编译的整体处理过程非常清晰。