大家好,我是码农小余。上一小节我们知道了 vite dev 时通过 resolveConfig 去获取并合并配置,处理插件顺序和执行 config 、configResolved 钩子,最后还学习了 alias 以及 env 的配置处理。
从全流程上看,我们在解析完配置后,就会创建服务器(createServer)、初始化文件监听器(watcher),这两个过程在 Vue 技术揭秘之 CreateServer 简述过,细节部分比较少,所以不会用单独的篇幅去展开讲。所以接着就会通过 new ModuleGraph 去创建模块图。
按照惯例,我们依然用 vite vanilla 模板构造最小 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.jsimport './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.jsexport * from './baz'export const name = 'module graph'// ./src/baz.jsexport 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 方法。文件之间的关系就如下图所示:
ModuleGraph & ModuleNode
在 createServer 时,会创建模块图的实例:
// 初始化模块图 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 的内容也需要热更功能;
- 最后还有一个在构造函数参数中传入的函数 resolveId:
- // 初始化模块图谱 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 的过程执行一遍上述流程:
- 初次加载 main.js 时,server._pendingRequests 中不存在这个请求,所以进入 doTransform;
- 浏览器中加载 main.js 的请求是 http://localhost:3000/main.js,所以 doTransform 的 url 参数是 /main.js。
- const module = await server.moduleGraph.getModuleByUrl(url, ssr) 复制代码
- 初次加载时,moduleGraph 中没有 url /main.js对应的模块。所以这里查找的结果是 undefined。
- 接着会通过插件容器去解析 url
- const id = (await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url 复制代码
- 插件容器的 API 我们留到下一章去分析;这里我们只需要知道经过 pluginContainer.resolveId 转换后的 id 是文件的绝对路径(在我本地的结果是 Users/xxx/vite/examples/module-gtaph/main.js );
- 获取到 id 后,执行 pluginContainer.load 钩子:
- 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 变量;
- 通过将 url 传入 moduleGraph.ensureEntryFromUrl 函数:
- const mod = await moduleGraph.ensureEntryFromUrl(url, ssr) 复制代码
- 在 ModuleGraph 类中我们已经了解 ensureEntryFromUrl 是将 url 解析后创建 ModuleNode 实例,并存到 urlToModuleMap、idToModuleMap、fileToModulesMap,最后返回的 mod 信息如下:
- 将 main.js 模块的 file 文件添加到文件监听实例中,达到后面修改 main.js 就会触发更新的效果;
- 将第4步拿到的 code 调用全部插件的 transform 钩子:
- 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 根据 code 结果生成文件 etag 信息,整个 doTransform 过程就就结束了。
- doTransform 结束后最后回到 transformMiddleware 中,拿到转换后的结果:
- // 使用插件容器解析、接在和转换 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 返回到浏览器。
- 浏览器拿到 main.js 的响应后,解析过程中遇到 import 就会接着发起资源请求,就又会进入 transformMiddleware 重复上述流程,一层一层地完成整个应用的资源加载,这里就利用浏览器支持 ESM 的设计。
总结
本文我们先学习了 ModuleGraph 类,了解到它的 4 个属性和 10 个方法;然后学习了 ModuleNode 类,知道 ModuleGraph 中的每一个节点都是 ModuleNode 的实例,以及每个节点上的属性。
在 dev 服务器启动完,我们在浏览器输入地址后:
浏览器就会向服务端请求 main.js,服务器通过中间件 transformMiddleware 拿到请求 url,通过解析(id、url)的处理,生成模块实例并被文件监听实例监听变化,然后调用插件的 load 和 transform 钩子完成完成代码转换。完成文件内容转换之后,将其响应给浏览器。浏览器解析转换后的 main.js,就会遇到 import ,从而继续加载资源……就这样,完成了整个 moduleGraph 的加载。
在本文中,我们多次在关键流程上遇到了 pluginContainer(插件容器),比如:
- 模块 url 解析(resolveUrl)通过 pluginContainer.resolveId 处理;
- 加载模块调用了 pluginContainer.load;
- 代码转换生成调用了 pluginContainer.transform。
那么 pluginContainer 到底是什么?下一篇我们就去认识它——插件容器。
码农小余,https://juejin.cn/post/7082922754164391972
版权声明:内容来源于互联网和用户投稿 如有侵权请联系删除