Vite 源码(五)浏览器访问 `localhost:3000/` 时,Vite做了什么在之前几篇文章中我们知道了Vit
在之前几篇文章中我们知道了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
,并根据规则划分为postHooks
和preHooks
,并返回一个匿名函数。
这个匿名函数就是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
,并传入html
、preHooks
、devHtmlHook
、postHooks
和一些配置信息
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
分别加入bodyTags
、bodyPrependTags
、headTags
和headPrependTags
中。所有函数执行完后,再调用injectToHead
、injectToBody
插入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-dom
的parse
方法将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',
},
],
}
}
最后返回html
和tags
,这个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
)
此时会被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 将资源编译成浏览器能够识别的文件类型最后返回给浏览器。
而且这期间还会设置对比缓存和强制缓存,并缓存编译过的文件代码。
流程图
转载自:https://juejin.cn/post/7045206518450356231