Vite原理学习之按需编译

前言

当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。

请求html文件

在之前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))
}

可知按照其注册的逻辑先后,初始过程必然是下面的执行顺序:

  • servePublicMiddleware
  • transformMiddleware
  • serveRawFsMiddleware
  • serveStaticMiddleware
  • spaFallbackMiddleware
  • indexHtmlMiddleware

实际上Vite提供了–debug参数,可以比较清晰知道整个过程的关键处理逻辑,当然也可以在源码中手动打印日志。

Vite启动开发服务器后默认会接管本地3000端口的访问,当在浏览器中输入localhost:3000访问本地项目页面时,中间件逻辑执行如下:

spaFallbackMiddleware

真正首先起作用的是spaFallbackMiddleware中间件,该中间件就是用于支持通过/、/dir、/dir/index.html等路径访问,最后都会重置到index.html页面,保证访问到正确的index.html。

indexHtmlMiddleware

请求被重置到请求/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外部资源加载

解析HTML文件首先会请求@vite/client文件,每一次请求都要经过上面说明的主要中间件逻辑。

servePublicMiddleware中间件
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中间件

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);
    }

针对一些特定的请求做相关处理:

  • isJSRequest:js、ts、jsx、tsx、mjs、vue、svelte等后缀的文件
  • isImportRequest:以?或&i开头的import
  • isCSSRequest:css、less、sass、scss、styl、stylus、pcss、postcss后缀的文件
  • isHTMLProxy:?html-proxy&index=*.js文件

对于上面的请求在初始时都会通过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方法。这些插件具体如下:

  • vite:pre-alias:存在resolveId钩子
  • alias:存在resolveId钩子
  • vite:modulepreload-polyfill:存在resolvedId、load钩子
  • vite:resolve:存在resolvedId、load钩子
  • vite:html-inline-script-proxy:存在resolvedId、load钩子
  • vite:css:存在transform钩子
  • vite:esbuild:存在transform钩子
  • vite:json:存在transform钩子
  • vite:wasm:存在resolvedId、load钩子
  • vite:worker:存在load、transform钩子
  • vite:asset:存在resolvedId、load钩子
  • vite:vue:存在resolvedId、load、transform钩子
  • vite:define:存在transform钩子
  • vite:css-post:存在transform钩子
  • vite:client-inject:存在transform钩子
  • vite:import-analysis:存在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钩子来说明:

  • resolveId:解析生成id,实际上就是解析生成或自定义模块路径,一般是模块的绝对路径
  • load:加载模块,一般插件处理逻辑是在本地加载模块内容即通过Node fs模块同步读取模块文件内容,根据相关情况可能会在模块内容前追加export default文本构成模块输出
  • transform:转换模块

上面内置插件相关逻辑暂时不关注,当请求@vite/client时实际上通过上面对模块地址解析后得到其模块地址是:/项目聚绝对地址/vite/dist/client/client.mjs,而该文件实际上主要做了两件事:

  • 加载@vite/env模块
  • 创建WebSocket用于后续HMR

当@vite/client请求经过transformMiddleware中间件后就会完成内容转换,之后对调用send$1响应请求,返回内容给客户端。需要注意的一点是send$1方法中会设置响应Header从而利用浏览器缓存机制尽可能缓存符合条件的模块文件,相关Header如下:

  • ETag + If-Non-Match用于304判断
  • 设置 Cache-Control = max-age=31536000,immutable

源码模块的请求会根据 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以及开发服务器对其请求过程的主要流程做了梳理,总结如下:

  • Vite通过开发服务器来响应基于此项目的资源请求
  • 每一次请求都需要通过不同中间件逻辑来处理以获取正确的资源文件
    • servePublicMiddleware、serveStaticMiddleware、serveRawFsMiddleware处理相关静态资源
    • transformMiddleware处理大部分请求例如js文件、vue文件、jsx文件等等的请求
    • spaFallbackMiddleware支持相关路径定向到index.html
    • indexHtmlMiddleware处理index.html,包括代码注入等等所有针对index.html的处理工作
  • 对于CSS文件、JS文件、Vue文件等请求会通过transformMiddleware中间件来响应请求,在该中间件中会对文件源码进行转换工作,这是按需编译的核心逻辑,包括resolve、load、transform步骤
    • resolve:模块文件对应的路径解析或自定义操作,就是找到文件地址
    • load:拿到文件地址后加载文件内容
    • transform:转换文件内容以满足需求
  • 对于编译的模块,Vite内部会采用缓存机制,避免不必要的二次编译处理
  • 对于编译后的模块,Vite会利用浏览器的缓存机制,通过设置Etag、If-None-Match、Cache-Control响应头来对相关文件进行缓存

transformMiddleware中间件是按需编译的核心逻辑,包含resolve、load、transform步骤,实际上就是调用相关对象的resolveId、load、transform方法,这些方法是Rollup插件提供的钩子,每个模块请求时都会被调用。而其背后实际上就是执行一系列插件的resolveId、load、transform方法。通过插件机制和中间件,按需编译的整体处理过程非常清晰。

你可能感兴趣的:(前端工程化相关,javascript,vite,按需编译)