大家好,我是码农小余。
在 解析配置时,Vite 做了这些事 一文中,我们知道了 vite dev 时通过 resolveConfig 去获取并合并配置,处理插件顺序和执行 config 、configResolved 钩子,最后还学习了 alias 以及 env 的配置处理。
从全流程上看,我们在解析完配置后,就会创建服务器(createServer)、初始化文件监听器(watcher),这两个过程在 敲下 vite 命令后,server 做了哪些事? 简述过,细节部分比较少,所以不会用单独的篇幅去展开讲。接着就会通过 new ModuleGraph
去创建模块图。
按照惯例,我们依然用 vite vanilla 模板[1]构造最小 DEMO:
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<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="/main.js"></script>
</body>
</html>
// main.js
import './style.css'
import { sayHello, name } from './src/foo'
document.querySelector('#app').innerHTML = `
<h1>${sayHello(name)}</h1>
<a href="https://vitejs.dev/guide/features.html" target="_blank">Documentation</a>
`
// ./src/foo.js
export * from './baz'
export const name = 'module graph'
// ./src/baz.js
export const sayHello = (msg) => {
return `Hello, ${msg}`
}
// style.css
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
上述代码,根目录 index.html 加载 main.js。main.js 引用了 style.css 的样式文件,style.css 通过 @import 引入 common.css;main.js 同时还引用了 foo.js,.src/foo 引入了 sayHello、name;在文件 ./src/foo.js 中通过 export * from './baz' 导出了 sayHello 方法。文件之间的关系就如下图所示:
在 createServer[2] 时,会创建模块图的实例:
// 初始化模块图
const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>
container.resolveId(url, undefined, { ssr })
)
通过 new ModuleGraph 创建实例,现在我们就去了解 ModuleGraph 定义:
export class ModuleGraph {
// url 和模块的映射
urlToModuleMap = new Map<string, ModuleNode>()
// id 和模块的映射
idToModuleMap = new Map<string, ModuleNode>()
// 文件和模块的映射,一个文件对应多个模块,比如 SFC 就对应多个模块
fileToModulesMap = new Map<string, Set<ModuleNode>>()
// /@fs 的模块
safeModulesPath = new Set<string>()
constructor(
// 内部的 resolvceId 是通过构造函数传进来的,指向的是插件容器的 resolveId 方法
private resolveId: (
url: string,
ssr: boolean
) => Promise<PartialResolvedId | null>
) {}
/**
* 通过url获取模块
*/
async getModuleByUrl(
rawUrl: string,
ssr?: boolean
): Promise<ModuleNode | undefined> {
const [url] = await this.resolveUrl(rawUrl, ssr)
return this.urlToModuleMap.get(url)
}
/**
* 通过 id 获取模块
*/
getModuleById(id: string): ModuleNode | undefined {
return this.idToModuleMap.get(removeTimestampQuery(id))
}
/**
* 通过文件获取模块
*/
getModulesByFile(file: string): Set<ModuleNode> | undefined {
return this.fileToModulesMap.get(file)
}
/**
* 文件修改的事件
*/
onFileChange(file: string): void {
const mods = this.getModulesByFile(file)
if (mods) {
const seen = new Set<ModuleNode>()
mods.forEach((mod) => {
this.invalidateModule(mod, seen)
})
}
}
/**
* 使指定模块失效
*/
invalidateModule(mod: ModuleNode, seen: Set<ModuleNode> = new Set()): void {
mod.info = undefined
mod.transformResult = null
mod.ssrTransformResult = null
invalidateSSRModule(mod, seen)
}
/**
* 使全部模块失效
*/
invalidateAll(): void {
const seen = new Set<ModuleNode>()
this.idToModuleMap.forEach((mod) => {
this.invalidateModule(mod, seen)
})
}
/**
* Update the module graph based on a module's updated imports information
* If there are dependencies that no longer have any importers, they are
* returned as a Set.
*
* 更新模块依赖信息
*/
async updateModuleInfo(
mod: ModuleNode,
importedModules: Set<string | ModuleNode>,
acceptedModules: Set<string | ModuleNode>,
isSelfAccepting: boolean,
ssr?: boolean
): Promise<Set<ModuleNode> | undefined> {
// ...
}
/**
* 根据 url 生成模块
*/
async ensureEntryFromUrl(rawUrl: string, ssr?: boolean): Promise<ModuleNode> {
const [url, resolvedId, meta] = await this.resolveUrl(rawUrl, ssr)
// 根据 url 获取模块
let mod = this.urlToModuleMap.get(url)
if (!mod) {
// 实例化一个模块节点
mod = new ModuleNode(url)
// 设置模块节点元信息
if (meta) mod.meta = meta
// 存入 url 跟模块的 map 中
this.urlToModuleMap.set(url, mod)
// id 就是 import 进来的路径
mod.id = resolvedId
// 存入 id 跟模块的 map 中
this.idToModuleMap.set(resolvedId, mod)
// 设置节点的 file 信息
const file = (mod.file = cleanUrl(resolvedId))
// 处理 file 跟模块的关系
let fileMappedModules = this.fileToModulesMap.get(file)
if (!fileMappedModules) {
fileMappedModules = new Set()
this.fileToModulesMap.set(file, fileMappedModules)
}
fileMappedModules.add(mod)
}
return mod
}
/**
* 根据引入生成file,比如 css 常用的 import,在 css 代码里面没有 url
* 但是这种也属于模块图中的节点
*/
createFileOnlyEntry(file: string): ModuleNode {
file = normalizePath(file)
// 获取文件对应的模块
let fileMappedModules = this.fileToModulesMap.get(file)
if (!fileMappedModules) {
fileMappedModules = new Set()
this.fileToModulesMap.set(file, fileMappedModules)
}
// 已经有对应的对应当前的 file
const url = `${FS_PREFIX}${file}`
for (const m of fileMappedModules) {
if (m.url === url || m.id === file) {
return m
}
}
// 没有模块对应文件,通过url创建模块实例,然后加入到fileMappedModules
const mod = new ModuleNode(url)
mod.file = file
fileMappedModules.add(mod)
return mod
}
/**
* 解析url,做两件事:
* 1. 移除 HMR 的时间戳
* 2. 处理文件后缀,保证文件名一致时(后缀即使不一样)也能够映射到同一个模块
*/
async resolveUrl(url: string, ssr?: boolean): Promise<ResolvedUrl> {
// 移除url中的时间戳参数
url = removeImportQuery(removeTimestampQuery(url))
// 使用 pluginContainer.resolveId 去解析 url
const resolved = await this.resolveId(url, !!ssr)
const resolvedId = resolved?.id || url
// 获取 id 的扩展
const ext = extname(cleanUrl(resolvedId))
const { pathname, search, hash } = parseUrl(url)
if (ext && !pathname!.endsWith(ext)) {
url = pathname + ext + (search || '') + (hash || '')
}
return [url, resolvedId, resolved?.meta]
}
}
ModuleGraph 定义了 4 个属性:
urlToModuleMap
:url 跟模块的映射;idToModuleMap
:id 跟模块的映射;fileToModulesMap
:文件跟模块的映射,注意这里的 Modules 是复数,说明一个文件可以对应多个模块;safeModulesPath
:/@fs 模块集合;@fs 模块具体指代哪些模块呢?这里先留一个悬念;并提供了一系列获取、更新这些属性的方法:
getModuleByUrl
、getModuleById
、getModulesByFile
分别是通过 url、id、file 获取模块的方法;onFileChange
、invalidateAll
、invalidateModule
文件改变时的响应函数以及清除模块方法;updateModuleInfo
更新模块时触发,这部分代码在热更新时才会涉及,先不看这部分;resolveUrl
用于解析网址,createFileOnlyEntry
对于一些 import 会生成 file 和 模块节点,比如 css 中的 @import,对于 @import 的内容也需要热更功能;// 初始化模块图谱
const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>
container.resolveId(url, undefined, { ssr })
)
// 创建插件容器
const container = await createPluginContainer(config, moduleGraph, watcher)
可以看到,resolveId 指向的是插件容器的 resolveId 方法;
上面多次提到“模块”,到底什么才是模块呢?这就要看到 ModuleNode 的定义:
/**
* 模块节点类
*/
export class ModuleNode {
/**
* Public served url path, starts with /
* 模块url -> 公共服务的 url,以 / 开头
*/
url: string
/**
* Resolved file system path + query
* 模块id -> 解析的文件系统路径+查询
*/
id: string | null = null
// 文件路径
file: string | null = null
// 模块类型,脚本还是样式
type: 'js' | 'css'
// 模块信息,引用 rollup 的 ModuleInfo
info?: ModuleInfo
// 模块元信息
meta?: Record<string, any>
// 引用者,代表哪些模块引用了这个模块,也叫前置依赖
importers = new Set<ModuleNode>()
// 依赖模块,当前模块依赖引入了哪些模块,也叫后置依赖
importedModules = new Set<ModuleNode>()
// 当前模块热更“接受”的模块
acceptedHmrDeps = new Set<ModuleNode>()
// 是否自我“接受”
isSelfAccepting = false
// 转换结果
transformResult: TransformResult | null = null
ssrTransformResult: TransformResult | null = null
ssrModule: Record<string, any> | null = null
// 模块最后的热更时间
lastHMRTimestamp = 0
// 构造函数
constructor(url: string) {
this.url = url
this.type = isDirectCSSRequest(url) ? 'css' : 'js'
}
}
当 Vite 解析完全部配置后,就会去创建模块图实例,这节我们知道了模块图类有 4 个属性,分别是 url、id、file 和 /@fs 与对应模块的关系;还有约 10 个方法,用来对 4 个属性的信息进行增删改查;我们也了解到了图中的每一个节点都是 ModuleNode 的实例,每一个节点都有大量的属性,具体可以见上述 ModuleNode 的定义。
明白了 ModuleGraph 和 ModuleNode 的定义,接下来我们分析一下,ModuleGraph 是如何将 ModuleNode 关联起来的?从本文的例子入手,index.html 只加载了 main.js 模块,Vite server 会如何去处理这个文件呢?我们接着探索。
将我们本节案例在浏览器中跑起来,然后进入 F12 模式,打开网络(network) 面板:
简单分析一下,client 是 vite 自身的客户端脚本,我们先略过。
从 main.js 开始,我们不难注意到的点:根据瀑布关系,main.js 加载并编译完成之后,才去加载 style.css 和 foo.js;foo.js 加载编译完成之后再去加载 baz.js;这种管理跟我们开头的模块文件依赖关系是一致的。这时我们就可以聚焦在 main.js 身上了,回到 createServer 主流程中,在浏览器请求 main.js 这个资源时,会发生什么事情?
// 接收传入配置创建服务
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
// ...
// 初始化模块图谱
const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>
container.resolveId(url, undefined, { ssr })
)
// 创建插件容器
const container = await createPluginContainer(config, moduleGraph, watcher)
// 用字面量的形式初始化 server 配置
const server: ViteDevServer = {
// ...
}
})
// ...
// 一系列内部的中间件...
middlewares.use(transformMiddleware(server))
// ...
return server
}
当浏览器发起资源请求时,会经过一系列中间件,其中 transformMiddleware 就是关键:
/**
* 文件转换中间件
* @param {ViteDevServer} server http服务
* @returns {Connect.NextHandleFunction} 中间件
*/
export function transformMiddleware(
server: ViteDevServer
): Connect.NextHandleFunction {
const {
config: { root, logger, cacheDir },
moduleGraph
} = server
// ...
return async function viteTransformMiddleware(req, res, next) {
// 如果请求不是GET、url在忽略列表中,直接到下一个中间件
if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
return next()
}
// ...
try {
// 省略sourcemap、publicDir的逻辑处理...
// 如果是js、import查询、css、html-proxy
if (
isJSRequest(url) ||
isImportRequest(url) ||
isCSSRequest(url) ||
isHTMLProxy(url)
) {
// 去掉 import 的查询参数
url = removeImportQuery(url)
// 去除有效的 id 前缀。这是由 importAnalysis 插件在解析的不是有效浏览器导入说明符的 Id 之前添加的
url = unwrapId(url)
// 区分 css 请求和导入
if (
isCSSRequest(url) &&
!isDirectRequest(url) &&
req.headers.accept?.includes('text/css')
) {
url = injectQuery(url, 'direct')
}
// 二次加载,利用 etag 做协商缓存
const ifNoneMatch = req.headers['if-none-match']
if (
ifNoneMatch &&
(await moduleGraph.getModuleByUrl(url, false))?.transformResult
?.etag === ifNoneMatch
) {
isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)
res.statusCode = 304
return res.end()
}
// 使用插件容器解析、接在和转换
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html')
})
if (result) {
const type = isDirectCSSRequest(url) ? 'css' : 'js'
const isDep =
DEP_VERSION_RE.test(url) ||
(cacheDirPrefix && url.startsWith(cacheDirPrefix))
// 输出结果
return send(req, res, result.code, type, {
etag: result.etag,
// allow browser to cache npm deps!
cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
headers: server.config.server.headers,
map: result.map
})
}
}
} catch (e) {
return next(e)
}
next()
}
}
transformMiddleware(server) 传入 server 对象,利用闭包特性返回中间件函数——viteTransformMiddleware 的整体流程如下图所示:
当浏览器有一个请求到 Vite server 时,会经过 transform 中间件。中间件内部先依据是否以 .map 后缀判断 sourcemap 请求,是的话直接将 transformResult 对应的 map 返回。然后检查公共目录与根目录的位置关系,如果一个请求 url 以公共路径打头,就会触发如下的告警:
然后会对 url 做以下处理:移除 import 参数、移除 /@id 前缀(这玩意是在 importAnalysis 插件中添加,后面单独分析)、如果是 css import 的话,会加入 direct 参数。接着缓存如果命中,直接返回,否则就会进入 transformRequest:
export function transformRequest(
url: string,
server: ViteDevServer,
options: TransformOptions = {}
): Promise<TransformResult | null> {
const cacheKey = (options.ssr ? 'ssr:' : options.html ? 'html:' : '') + url
// 判断请求队列中是否存在当前的请求
let request = server._pendingRequests.get(cacheKey)
// 如果不存在就解析、编译模块
if (!request) {
// 模块解析、转换
request = doTransform(url, server, options)
// 将当前请求存到 _pendingRequests 中
server._pendingRequests.set(cacheKey, request)
// 请求完成后就从 _pendingRequests 删除
const done = () => server._pendingRequests.delete(cacheKey)
// 请求完成之后在 _pendingRequests 删除
request.then(done, done)
}
return request
}
进入 transformRequest,先生成 cacheKey,如果是 ssr 或 html,就在 url 前面加上对应的前缀。通过 server._pendingRequests 判断当前 url 是否还在请求中,如果存在就直接返回 _pendingRequests 中的请求;不存在就调用 doTransform 做转换,并将请求缓存到 _pendingRequests 中,最后请求完成后(不管请求成功还是失败)都会从 _pendingRequests 删除。接下来就来分析 doTransform 具体做了什么事:
/**
* 解析转换
* @param {string} url 请求进来的路径
* @param {ViteDevServer} server http服务
* @param {TransformOptions} options 转换配置,{ html: false }
*/
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
// 根据 url 获取模块
const module = await server.moduleGraph.getModuleByUrl(url, ssr)
// dev 下缓存解析转换后的结果
const cached =
module && (ssr ? module.ssrTransformResult : module.transformResult)
if (cached) {
return cached
}
// 拿到模块对应的绝对路径 /Users/yjcjour/Documents/code/vite/examples/vite-learning/main.js
const id =
(await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url
// 净化id,去除hash和query参数
const file = cleanUrl(id)
let code: string | null = null
let map: SourceDescription['map'] = null
// load
const loadStart = isDebug ? performance.now() : 0
// 执行插件的 load 钩子
const loadResult = await pluginContainer.load(id, { ssr })
// 执行 load 后返回 null
if (loadResult == null) {
// ...
if (options.ssr || isFileServingAllowed(file, server)) {
try {
code = await fs.readFile(file, 'utf-8')
} catch (e) {
}
}
// 省略 sourcemap 代码...
} else {
// 如果 load 返回的结果不是为空,并且返回的是一个对象,code 和 map
// 也有可能返回的是一个字符串,也就是 code
if (isObject(loadResult)) {
code = loadResult.code
map = loadResult.map
} else {
code = loadResult
}
}
// 确保模块在模块图中正常加载
const mod = await moduleGraph.ensureEntryFromUrl(url, ssr)
// 确保模块文件被文件监听器监听
ensureWatchedFile(watcher, mod.file, root)
// 核心核心核心!调用转换钩子
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
ssr
})
// 省略 debug、sourcemap、ssr 的逻辑
// 返回转换结果
return (mod.transformResult = {
code,
map,
etag: getEtag(code, { weak: true })
} as TransformResult)
}
doTransform 删减 sourcemap、ssr、debug 模式下的代码,整个流程就比较清晰了:
我们就以初次加载 main.js 的过程执行一遍上述流程:
const module = await server.moduleGraph.getModuleByUrl(url, ssr)
初次加载时,moduleGraph 中没有 url /main.js
对应的模块。所以这里查找的结果是 undefined。
const id =
(await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url
插件容器的 API 我们留到下一章去分析;这里我们只需要知道经过 pluginContainer.resolveId 转换后的 id 是文件的绝对路径(在我本地的结果是 Users/xxx/vite/examples/module-gtaph/main.js );
let code: string | null = null
let map: SourceDescription['map'] = null
// 执行插件的 load 钩子
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 {
// ...
if (isObject(loadResult)) {
code = loadResult.code
map = loadResult.map
} else {
code = loadResult
}
}
// ...
对于例子而言,因为我们没有自定义插件,所行全部内置插件的 load 钩子后,loadResult 最终结果是 null。这里 isFileServingAllowed(file, server) 会返回 ture,将 fs.readFile 读取 main.js 文件的内容存到 code 变量;
const mod = await moduleGraph.ensureEntryFromUrl(url, ssr)
在 ModuleGraph 类中我们已经了解 ensureEntryFromUrl 是将 url 解析后创建 ModuleNode 实例,并存到 urlToModuleMap、idToModuleMap、fileToModulesMap,最后返回的 mod 信息如下:
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
ssr
})
最终生成转换后的结果 transformResult。可以看到,上述所有步骤都是在处理 /main.js 这个 url 和对应的模块
那么 style.css 、foo.js 是怎么添加到 moduleGraph 中的呢?答案就是通过内置插件 vite:import-analysis ,在该插件的 transform 钩子中,会进行 import 的静态分析,如果有引用其他资源,那么也会添加到 moduleGraph 中。importAnalysis 源码会放到插件篇单独展开分析。但是我们可以看下 /main.js 经过 transform 钩子之后 moduleGraph 的结果:
从上图可以看到,main.js 依赖的 style.css 和 foo.js 已经被添加到 moduleGraph 中。不仅如此,对于彼此之间的依赖关系也已经形成,我们展开 main.js 和 style.css 两个模块看看:
main.js 模块通过 importedModules 关联了两个子模块(style.css、foo.js)的信息,style 模块通过 importers 关联了父模块(main.js)的信息。
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)
}
main.js 处理之后 code 是文件内容,map 为 null。最后将转换后的结果存入模块的 transformResult 属性,并使用 etag[3] 根据 code 结果生成文件 etag 信息,整个 doTransform 过程就就结束了。
// 使用插件容器解析、接在和转换
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html')
})
if (result) {
// 判断是脚本还是样式类型
const type = isDirectCSSRequest(url) ? 'css' : 'js'
const isDep =
DEP_VERSION_RE.test(url) ||
(cacheDirPrefix && url.startsWith(cacheDirPrefix))
return send(req, res, result.code, type, {
etag: result.etag,
// allow browser to cache npm deps!
cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
headers: server.config.server.headers,
map: result.map
})
}
对于 /main.js 而言,type 是脚本类型,最终通过 send 返回到浏览器。
本文我们先学习了 ModuleGraph 类,了解到它的 4 个属性和 10 个方法;然后学习了 ModuleNode 类,知道 ModuleGraph 中的每一个节点都是 ModuleNode 的实例,以及每个节点上的属性。
在 dev 服务器启动完,我们在浏览器输入地址后:
浏览器就会向服务端请求 main.js,服务器通过中间件 transformMiddleware 拿到请求 url,通过解析(id、url)的处理,生成模块实例并被文件监听实例监听变化,然后调用插件的 load 和 transform 钩子完成完成代码转换。完成文件内容转换之后,将其响应给浏览器。浏览器解析转换后的 main.js,就会遇到 import ,从而继续加载资源……就这样,完成了整个 moduleGraph 的加载。
在本文中,我们多次在关键流程上遇到了 pluginContainer(插件容器),比如:
那么 pluginContainer 到底是什么?下一篇我们就去认识它——插件容器。
[1]
vite vanilla 模板: https://github.com/Jouryjc/vite/blob/main/examples/module-graph/package.json
[2]
createServer: ./create-server
[3]
etag: https://www.npmjs.com/package/etag