阅读 470

Vite 源码(五)浏览器访问 `localhost:3000/` 时,Vite做了什么

在之前几篇文章中我们知道了Vite 的启动过程。当我们执行yarn run dev之后,Vite 会初始化配置项、预构建、注册中间件,并启动一个服务器。之后就不会再进行其他操作,直到我们访问localhost:3000/

当我们访问localhost:3000/时,会通过中间件拦截文件请求,并处理文件,最终将处理后的文件发送给客户端。来看下具体流程。最后也会放一张流程图

访问localhost:3000/触发的中间件

当我们访问localhost:3000/时,会被如下几个中间件拦截

// main transform middlewaref middlewares.use(transformMiddleware(server)) // spa fallback if (!middlewareMode || middlewareMode === 'html') {     middlewares.use(spaFallbackMiddleware(root)) } if (!middlewareMode || middlewareMode === 'html') {     // transform index.html     middlewares.use(indexHtmlMiddleware(server))     middlewares.use(function vite404Middleware(_, res) {       res.statusCode = 404       res.end()     }) } 复制代码

之后会依次解释下这三个中间件的实现原理

transformMiddleware中间件

首先被transformMiddleware拦截,大体代码如下

const knownIgnoreList = new Set(['/', '/favicon.ico']) export function transformMiddleware(     server: ViteDevServer ): Connect.NextHandleFunction {     // ...     return async function viteTransformMiddleware(req, res, next) {         if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {             return next()         }         // ...     } } 复制代码

由于req.url包含在knownIgnoreList内,所以直接跳过,进入spaFallbackMiddleware中间件

transformMiddleware中间件的具体作用稍后会详细说明

spaFallbackMiddleware中间件

import history from 'connect-history-api-fallback' export function spaFallbackMiddleware(   root: string ): Connect.NextHandleFunction {   const historySpaFallbackMiddleware = history({     // support /dir/ without explicit index.html     rewrites: [       {         from: /\/$/,         to({ parsedUrl }: any) {           // 如果匹配,则重写路由            const rewritten = parsedUrl.pathname + 'index.html'           if (fs.existsSync(path.join(root, rewritten))) {             return rewritten           } else {             return `/index.html`           }         }       }     ]   })   return function viteSpaFallbackMiddleware(req, res, next) {     return historySpaFallbackMiddleware(req, res, next)   } } 复制代码

先说一下connect-history-api-fallback这个包的作用,每当出现符合条件的请求时,它将把请求定位到指定的索引文件。这里就是/index.html,一般用于解决单页面应用程序 (SPA)刷新或直接通过输入地址的方式访问页面时返回404的问题。

但是这个中间件只匹配/,也就是说如果访问的是localhost:3000/,会被匹配成/index.html

继续向下,进入indexHtmlMiddleware中间件

indexHtmlMiddleware中间件,获取 HTML

export function indexHtmlMiddleware(     server: ViteDevServer ): Connect.NextHandleFunction {     return async function viteIndexHtmlMiddleware(req, res, next) {         // 获取url,/ 被 spaFallbackMiddleware 处理成了 /index.html         // 所以这里的 url 就是 /index.html         const url = req.url && cleanUrl(req.url)         // spa-fallback always redirects to /index.html         if (url?.endsWith('.html') && req.headers['sec-fetch-dest'] !== 'script') {             // 根据 config.root 获取 html 文件的绝对路径             const filename = getHtmlFilename(url, server)             if (fs.existsSync(filename)) {                 try {                     // 获取 html 文件内容                     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)                 }             }         }         next()     } } 复制代码

indexHtmlMiddleware这个中间件的作用就是处理html文件,首先获取html文件的绝对路径,根据绝对路径获取html字符串。

获取的内容如下

<!DOCTYPE html> <html lang="en">   <head>     <meta charset="UTF-8" />     <link rel="icon" href="/favicon.ico" />     <meta name="viewport" content="width=device-width, initial-scale=1.0" />     <title>Vite App</title>   </head>   <body>     <div id="app"></div>     <script type="module" src="/src/main.ts"></script>   </body> </html> 复制代码

转换 HTML

接下来调用server.transformIndexHtml函数转换 HTML,最后返回给客户端

html = await server.transformIndexHtml(     url,     html,     req.originalUrl ) return send(req, res, html, 'html') 复制代码

在启动服务的时候指定了server.transformIndexHtml,在createServer函数内

server.transformIndexHtml = createDevHtmlTransformFn(server) 复制代码

createDevHtmlTransformFn函数定义如下

export function createDevHtmlTransformFn( server: ViteDevServer ): (url: string, html: string, originalUrl: string) => Promise<string> {     // 遍历所有 plugin,获取 plugin.transformIndexHtml     // 如果 plugin.transformIndexHtml 是一个函数,添加到 postHooks中     // 如果 plugin.transformIndexHtml 是一个对象并且 transformIndexHtml.enforce === 'pre',添加到 preHooks 中     // 如果 plugin.transformIndexHtml 是一个对象并且 transformIndexHtml.enforce !== 'pre',添加到 postHooks 中     const [preHooks, postHooks] = resolveHtmlTransforms(server.config.plugins)     return (url: string, html: string, originalUrl: string): Promise<string> => {/* ... */} } 复制代码

createDevHtmlTransformFn函数遍历所有插件,获取插件中定义的transformIndexHtml,并根据规则划分为postHookspreHooks,并返回一个匿名函数。

这个匿名函数就是server.transformIndexHtml的值,看下函数定义

(url: string, html: string, originalUrl: string): Promise<string> => {     return applyHtmlTransforms(html, [...preHooks, devHtmlHook, ...postHooks], {     path: url,     filename: getHtmlFilename(url, server),     server,     originalUrl     }) } 复制代码

函数内部调用applyHtmlTransforms,并传入htmlpreHooksdevHtmlHookpostHooks和一些配置信息

export async function applyHtmlTransforms(     html: string,     hooks: IndexHtmlTransformHook[],     ctx: IndexHtmlTransformContext ): Promise<string> {     const headTags: HtmlTagDescriptor[] = []     const headPrependTags: HtmlTagDescriptor[] = []     const bodyTags: HtmlTagDescriptor[] = []     const bodyPrependTags: HtmlTagDescriptor[] = []     for (const hook of hooks) {         const res = await hook(html, ctx)         if (!res) {             continue         }         if (typeof res === 'string') {             html = res         } else {             let tags: HtmlTagDescriptor[]             if (Array.isArray(res)) {                 tags = res             } else {                 html = res.html || html                 tags = res.tags             }             for (const tag of tags) {                 if (tag.injectTo === 'body') {                     bodyTags.push(tag)                 } else if (tag.injectTo === 'body-prepend') {                     bodyPrependTags.push(tag)                 } else if (tag.injectTo === 'head') {                     headTags.push(tag)                 } else {                     headPrependTags.push(tag)                 }             }         }     }     // inject tags     if (headPrependTags.length) {         html = injectToHead(html, headPrependTags, true)     }     if (headTags.length) {         html = injectToHead(html, headTags)     }     if (bodyPrependTags.length) {         html = injectToBody(html, bodyPrependTags, true)     }     if (bodyTags.length) {         html = injectToBody(html, bodyTags)     }     return html } 复制代码

applyHtmlTransforms就是按顺序调用传入的函数,如果传入的函数返回值中有tags属性,是一个数组;遍历这个数组,根据injectTo属性分类,将这些tag分别加入bodyTagsbodyPrependTagsheadTagsheadPrependTags中。所有函数执行完后,再调用injectToHeadinjectToBody插入html中,最后返回转换后的html

传入的plugin.transformIndexHtml函数中,就包含 Vite 内部的一个函数devHtmlHook,看下定义

const devHtmlHook: IndexHtmlTransformHook = async (     html,     { path: htmlPath, server, originalUrl } ) => {     const config = server?.config!     const base = config.base || '/'     const s = new MagicString(html)     let scriptModuleIndex = -1     const filePath = cleanUrl(htmlPath)     await traverseHtml(html, htmlPath, (node) => {})     html = s.toString()     return {} } 复制代码

将传入的html交给traverseHtml处理

export async function traverseHtml(     html: string,     filePath: string,     visitor: NodeTransform ): Promise<void> {     const { parse, transform } = await import('@vue/compiler-dom')     // @vue/compiler-core doesn't like lowercase doctypes     html = html.replace(/<!doctype\s/i, '<!DOCTYPE ')     try {         const ast = parse(html, { comments: true })         transform(ast, {             nodeTransforms: [visitor],         })     } catch (e) {} } 复制代码

通过@vue/compiler-domparse方法将html转换成 AST,然后调用transform方法对每层 AST 调用传入的visitor,这个visitor(访问器)就是上面devHtmlHook传给traverseHtml函数的回调。也就是说每访问一层 AST 就会执行一次这个回调。

看下回调代码

export const assetAttrsConfig: Record<string, string[]> = {   link: ['href'],   video: ['src', 'poster'],   source: ['src', 'srcset'],   img: ['src', 'srcset'],   image: ['xlink:href', 'href'],   use: ['xlink:href', 'href'] } // traverseHtml await traverseHtml(html, htmlPath, (node) => {     // 如果 node.type !== 1 直接返回     if (node.type !== NodeTypes.ELEMENT) {         return     }     // 处理 script 标签     if (node.tag === 'script') {}     // elements with [href/src] attrs     const assetAttrs = assetAttrsConfig[node.tag]     if (assetAttrs) {} }) 复制代码

访问器只处理如下标签,这些标签都可以引入文件

  • script

  • link

  • video

  • source

  • img

  • image

  • use

先看下怎么处理script标签

// 处理 script 标签 if (node.tag === 'script') {     // 获取 src 属性     // isModule:是一个行内 js,并且有 type='module' 属性,则为 true     const { src, isModule } = getScriptInfo(node)     if (isModule) {         scriptModuleIndex++     }     if (src) {         processNodeUrl(src, s, config, htmlPath, originalUrl)     } else if (isModule) {} // 处理 type==='module' 的 行内js } 复制代码

这块代码主要是处理行内js和引入js文件的script标签

对于引入js文件的script标签,就是调用processNodeUrl函数重写src属性的路径

  • 如果以/或者\开头重写成config.base + 路径.slice(1)

  • 如果是相对路径以.开头、originalUrl(原始请求的 url) 不是/(比如/a/b)并且HTML文件路径是/index.html,则需要将路径重写,改成相对于/的路径;这样做的目的是如果不重写,最后请求的文件路径是localhost:3000/a/index.js,会导致服务器返回404

对于其他标签的处理如下

const assetAttrs = assetAttrsConfig[node.tag] if (assetAttrs) {     for (const p of node.props) {         if (             p.type === NodeTypes.ATTRIBUTE &&             p.value &&             assetAttrs.includes(p.name)         ) {             processNodeUrl(p, s, config, htmlPath, originalUrl)         }     } } 复制代码

遍历当前标签的所有属性,如果type(属性类型)为 6,并且属性名包含在assetAttrs中,则调用 processNodeUrl处理路径。

当所有AST遍历完成之后,回到devHtmlHook

const devHtmlHook: IndexHtmlTransformHook = async (     html,     { path: htmlPath, server, originalUrl } ) => {     // ...     await traverseHtml(html, htmlPath, (node) => {})     // 获取最新的 html 字符串     html = s.toString()     // 最后返回 html 和 tags     return {         html,         tags: [             {                 tag: 'script',                 attrs: {                     type: 'module',                     src: path.posix.join(base, CLIENT_PUBLIC_PATH),                 },                 injectTo: 'head-prepend',             },         ],     } } 复制代码

最后返回htmltags,这个tags会将下面的代码插入到head标签头部

<script type="module" src="/@vite/client"></script> 复制代码

最终indexHtmlMiddleware中间件向客户端发送转换后的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 App</title>     </head>     <body>         <div id="app"></div>         <script type="module" src="/src/main.ts"></script>     </body> </html> 复制代码

小结

当浏览器收到localhost:3000/这个请求时,会通过spaFallbackMiddleware中间件将其转换成/index.html,然后又被indexHtmlMiddleware中间件拦截,执行所有插件中的transformIndexHtml钩子函数和devHtmlHook方法去修改发送给客户端的HTML内容。其中devHtmlHook会将HTML转换成AST;处理引入的文件路径和行内js;还会将客户端接受热更新的代码注入。

返回 HTML 后发生了什么

上面分析了Vite时怎么将/请求转换成HTML并返回给客户端的。当客户端接收到HTML后,加载这个HTML,并请求HTML引入的js(/@vite/client/src/main.ts)

image.png

此时会被transformMiddleware中间件拦截

transformMiddleware中间件

transformMiddleware中间件实现逻辑比较长,一步一步的看,先以/src/main.ts为例子

return async function viteTransformMiddleware(req, res, next) {     if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {         return next()     }     // ...     // 将 url 的 t=xxx 去掉,并将 url 中的 __x00__ 替换成 \0     // url = /src/main.ts     let url = decodeURI(removeTimestampQuery(req.url!)).replace(         NULL_BYTE_PLACEHOLDER,         '\0'     )     // 去掉 hash 和 query     // withoutQuery = /src/main.ts     const withoutQuery = cleanUrl(url)     try {         // .map 文件相关         const isSourceMap = withoutQuery.endsWith('.map')         if (isSourceMap) {}         // 检查公共目录是否在根目录内         // ...         if (             isJSRequest(url) || // 加载的是 js 文件             isImportRequest(url) ||             isCSSRequest(url) ||             isHTMLProxy(url)         ) {/* ... */}     } catch (e) {         return next(e)     }     next() } 复制代码

首先是处理URL;然后判断文件类型,该中间件将处理下面这4种类型

  • js文件,包括没有后缀的文件、jsx、tsx、mjs、js、ts、vue

  • css文件,包括css、less、sass、scss、styl、stylus、pcss、postcss

  • url上挂有import参数的,Vite会对图片、JSON、客户端热更新时请求的文件等挂上import参数

  • url 匹配 /\?html-proxy&index=(\d+)\.js$/

处理逻辑如下

if (     isJSRequest(url) || // 加载的是 js 文件     isImportRequest(url) ||     isCSSRequest(url) ||     isHTMLProxy(url) ) {     // 删除 [?|&]import     url = removeImportQuery(url)     // 如果 url 以 /@id/ 开头,则去掉 /@id/     url = unwrapId(url)     // ...     // 获取请求头中的 if-none-match 值     const ifNoneMatch = req.headers['if-none-match']     // 从创建的 ModuleNode 对象中根据 url 获取 etag 并和 ifNoneMatch 比较     // 如果相同返回 304     if (         ifNoneMatch &&         (await moduleGraph.getModuleByUrl(url))?.transformResult             ?.etag === ifNoneMatch     ) {         res.statusCode = 304         return res.end()     }     // 依次调用所有插件的 resolve、load 和 transform 钩子函数     const result = await transformRequest(url, server, {         html: req.headers.accept?.includes('text/html'),     })     if (result) {         const type = isDirectCSSRequest(url) ? 'css' : 'js'         // true:url 上有 v=xxx 参数的,或者是以 cacheDirPrefix 开头的url         const isDep =             DEP_VERSION_RE.test(url) ||             (cacheDirPrefix && url.startsWith(cacheDirPrefix))         return send(             req,             res,             result.code,             type,             result.etag,             // 对预构建模块添加强缓存             isDep ? 'max-age=31536000,immutable' : 'no-cache',             result.map         )     } } 复制代码

如果文件类型符合上面几种,先判断能否使用对比缓存返回304。如果不能使用缓存,则通过transformRequest方法获取文件源码。然后设置缓存。对于 url 上有v=xxx参数的,或者是以缓存目录(比如_vite)开头的 url,设置强制缓存,即Cache-Control: max-age=31536000;反之设置对比缓存,即每次请求都要到服务器验证。

看下transformRequest函数的作用

export function transformRequest(     url: string,     server: ViteDevServer,     options: TransformOptions = {} ): Promise<TransformResult | null> {     // 是否正在请求     const pending = server._pendingRequests[url]     if (pending) {         debugTransform(             `[reuse pending] for ${prettifyUrl(url, server.config.root)}`         )         return pending     }     // doTransform 返回一个 Promise 对象     const result = doTransform(url, server, options)     // 防止多次请求     server._pendingRequests[url] = result     const onDone = () => {         server._pendingRequests[url] = null     }     // 设置回调     result.then(onDone, onDone)     return result } 复制代码

做了一层保障,防止正在请求的文件再次请求。调用doTransform获取result

async function doTransform(     url: string,     server: ViteDevServer,     options: TransformOptions ) {     url = removeTimestampQuery(url)     const { config, pluginContainer, moduleGraph, watcher } = server     const { root, logger } = config     const prettyUrl = isDebug ? prettifyUrl(url, root) : ''     const ssr = !!options.ssr     // 获取当前文件对应的 ModuleNode 对象     const module = await server.moduleGraph.getModuleByUrl(url)     // 获取当前文件转换后的代码,如果有则返回     const cached =         module && (ssr ? module.ssrTransformResult : module.transformResult)     if (cached) {         return cached     }     // 调用所有插件的 resolveId钩子函数,获取请求文件在项目中的绝对路径     // /xxx/yyy/zzz/src/main.ts     const id = (await pluginContainer.resolveId(url))?.id || url     // 去掉 id 中的 query 和 hash     const file = cleanUrl(id)     let code: string | null = null     let map: SourceDescription['map'] = null     // 调用所有插件的 load 钩子函数,如果所有插件的 load 钩子函数都没有处理过该文件,则返回 null     const loadResult = await pluginContainer.load(id, ssr)     if (loadResult == null) {         // ...         if (options.ssr || isFileServingAllowed(file, server)) {             try {                 // 读取文件中的代码                 code = await fs.readFile(file, 'utf-8')             } catch (e) {}         }              } else {         // 获取 code 和 map         if (isObject(loadResult)) {             code = loadResult.code             map = loadResult.map         } else {             code = loadResult         }     }     // ...     // 创建/获取当前文件的 ModuleNode 对象     const mod = await moduleGraph.ensureEntryFromUrl(url)     // 如果该文件的位置不在项目根路径以内,则添加监听     ensureWatchedFile(watcher, mod.file, root)     // transform     const transformResult = await pluginContainer.transform(code, id, map, ssr)     if (         transformResult == null ||         (isObject(transformResult) && transformResult.code == null)     ) {         // ...     } else {         code = transformResult.code!         map = transformResult.map     }     if (ssr) {         // ...     } else {         return (mod.transformResult = {             code,             map,             etag: getEtag(code, { weak: true }),         } as TransformResult)     } } 复制代码

doTransform函数会调用所有插件的resolveId钩子函数获取文件的绝对路径,然后创建/获取该文件的ModuleNode对象,并调用所有插件的load钩子函数,如果某个钩子函数有返回值,则这个返回值就是该文件的源码;如果没有返回值就根据绝对路径读取文件内容,最后调用所有插件的transform钩子函数转换源码。这个过程中会调用一个很重要的插件importAnalysis这个插件的作用主要作用是为该文件创建模块对象、设置模块之间的引用关系、解析代码中的导入路径;还会处理热更新相关逻辑,下一篇就会分析这个插件的具体实现逻辑。

最后,doTransform 函数返回转换后的代码、map信息和 etag值。

总结

当我们访问localhost:3000/时,会被中间件指向/index.html,并向/index.html中注入热更新相关的代码。最后返回这个HTML。当浏览器加载这个HTML时,通过原生ESM的方式请求js文件;会被transformMiddleware中间件拦截,这个中间件做的事就是将这个被请求文件转换成浏览器支持的文件;并会为该文件创建模块对象、设置模块之前的引用关系。

这也是 Vite 冷启动快的原因之一,Vite在启动过程中不会编译源码,只会对依赖进行预构建。当我们访问某个文件时,会拦截并通过 ESbuild 将资源编译成浏览器能够识别的文件类型最后返回给浏览器。

而且这期间还会设置对比缓存和强制缓存,并缓存编译过的文件代码。

流程图

2.jpg


作者:zygg不含糖
链接:https://juejin.cn/post/7045206518450356231

玩站网免费分享SEO网站优化 技术及文章 伪原创工具 https://www.237it.com/ 


文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐