模块解析
本章介绍 Vite 的核心模块加载解析机制
启动 Koa 服务
// src/index.ts 省略部分代码const app = new Koa<State, Context>()const server = resolveServer(config, app.callback())const watcher = chokidar.watch(root, {ignored: [/\bnode_modules\b/, /\b\.git\b/]}) as HMRWatcherconst resolver = createResolver(root, resolvers, alias) // 解析模块逻辑const context: ServerPluginContext = {root, // 项目根目录app, // Koa 实例server, // 自定义的 server 主要服务 https/http2.0 的情况watcher, // 本地文件的watcherresolver, // 定义模块的解析逻辑config,port: config.port || 3000}const resolvedPlugins = [// 这里加载一堆插件,本质是 Koa 中间件增强服务端能力moduleRewritePlugin,moduleResolvePlugin,...]resolvedPlugins.forEach((m) => m && m(context))
通过上述代码我们可以看到了 Vite 通过 Koa 启动了一个 http 服务,并且加载了一堆插件。插件的本质是 Koa Middlewares 这也是核心代码,通过添加插件来对不同类型的文件做不同的逻辑处理。 模块的解析机制的相关插件是 moduleRewritePlugin 和 moduleResolvePlugin
添加静态目录功能
通过使用 koa-static 来将 public 目录 以及整个根目录设置为静态资源目录,使得我们可以直接通过访问 http://localhost:3000/src/App.vue 的方式来访问具体的本地文件
// src/serverPluginServeStatic.tsapp.use(require('koa-static')(root))app.use(require('koa-static')(path.join(root, 'public')))
模块路径重写
这里我们分析的核心文件是 src/node/server/serverPluginModuleRewrite.ts。 原生的 ES module 不支持裸模块的导入,所以 Vite 进行了模块加载路径的重写。这里我们可以通过使用 debug 模块的功能来总览 Vite 到底重写了哪些路径
通过 debug 模块的输出我们可以很直观的发现 “vue” —> “/@modules/vue.js”, 至于其他的导入路径也被重写了例如 “./App.vue” —> “/src/App.vue” 则是为了让 Vite 更方便的找到模块的具体绝对路径。
await initLexer // 初始化 es-module-lexer 进行词法分析const importer = removeUnRelatedHmrQuery(// removeUnRelatedHmrQuery 移除无关的HMR请求后面的query参数resolver.normalizePublicPath(ctx.url))ctx.body = rewriteImports(root,content!, // 文件源码importer, // 需要从源文件中替换的路径resolver,ctx.query.t)if (!isHmrRequest) {rewriteCache.set(cacheKey, ctx.body)}
在 rewriteImports 方法中 Vite 使用了 esbuild 提供的 es-module-lexer 来进行词法分析。并且将最终已经 replace 模块路径的结果赋值给 ctx.body
es-module-lexer
通过阅读 es-module-lexer 的文档我们可以发现它是用来分析代码中的模块加载导出关系的。
import { init, parse } from 'es-module-lexer/dist/lexer.js';(async () => {await init;const source = `import { a } from 'asdf';export var p = 5;export function q () {};// Comments provided to demonstrate edge casesimport('dynamic').then();import /*comment!*/.meta.asdf;`;const [imports, exports] = parse(source, 'optional-sourcename');console.log(imports)// [// { s: 24, e: 28, ss: 5, se: 29, d: -1 },// { s: 190, e: 199, ss: 183, se: 0, d: 183 },// { s: 213, e: 237, ss: 213, se: 237, d: -2 }// ]// Returns "asdf"source.substring(imports[0].s, imports[0].e); // 通过 start end 获取具体的 import path// Returns "import { a } from 'asdf';"source.substring(imports[0].ss, imports[0].se); // 获取完整的 import 语句// Returns "p,q"exports.toString();// Dynamic imports are indicated by imports[1].d > -1// In this case the "d" index is the start of the dynamic import// Returns trueimports[1].d > -1; // 用来判断是否是动态加载,d 是动态加载的开始位// Returns "'asdf'"source.substring(imports[1].s, imports[1].e);// Returns "import /*comment!*/ ("source.substring(imports[1].d, imports[1].s);// import.meta is indicated by imports[2].d === -2// Returns trueimports[2].d === -2; // 用来判断是否是 import.meta// Returns "import /*comment!*/.meta"source.substring(imports[2].s, imports[2].e);})();
重写普通/动态的 import
这里我们借助上文提到的 es-module-lexer 对模块的 source 源码进行词法分析。为了更全面的了解,这里我们写一个动态 import 的代码用于测试
// src/helloword.vueimport('./hmr').then(res => {console.log(res)})// src/hmr.jsif (import.meta.hot) {import.meta.hot.accept((newModule) => {console.log('updated: count is now ', newModule.count)})}export const count=1
在源码中打印一下此时的依赖收集结果
const { s: start, e: end, d: dynamicIndex } = imports[i]let id = source.substring(start, end)let hasLiteralDynamicId = falseconsole.log(id, dynamicIndex)

通过上面对 lexer 的介绍。我们知道当 dynamicIndex > -1 时代表是 dyanmic import。这里我们的 ‘./hmr’ > -1 所以针对这种导入。我们需要用正则来将 ‘ 符号中的具体路径提取出来
if (dynamicIndex >= 0) {const literalIdMatch = id.match(/^(?:'([^']+)'|"([^"]+)")$/)if (literalIdMatch) {hasLiteralDynamicId = trueid = literalIdMatch[1] || literalIdMatch[2]}}
/^(?:'([^']+)'|"([^"]+)")$/ 的正则提取用可视化工具 regexper 观察一下可以发现是用来提取 '内容' 或者 "内容" 中的具体内容

接着来我们处理正常的 import 以及经过正则处理过后的 dynamic import。import.meta.hot 以及 import.meta.env 在后续进行处理。
if (dynamicIndex === -1 || hasLiteralDynamicId) {// do not rewrite external imports}
如果模块是外部模块则不进行处理
const externalRE = /^(https?:)?\/\//const isExternalUrl = (url: string) => externalRE.test(url)if (isExternalUrl(id)) {continue}
通过原 id 获得 rewrite 之后的 resolved
const resolved = resolveImport(root,importer,id,resolver,timestamp)export const resolveImport = (root: string,importer: string,id: string,resolver: InternalResolver,timestamp?: string): string => {id = resolver.alias(id) || idif (bareImportRE.test(id)) {// vue => /@modules/vue.js// 处理裸模块。从模块的 package.json 中找到entry字段并且返回,这里 Vue 的 entry 是 '@vue/shared/dist/shared.esm-bundler.js',// 由于 Vite 在预优化时对所有 package.json 中的 dependencies 模块进行了预优化,所以返回的是统一 optimize 后的路径,这里会在预优化章节进行解析id = `/@modules/${resolveBareModuleRequest(root, id, importer, resolver)}`} else {// 相对路径转绝对路径let { pathname, query } = resolver.resolveRelativeRequest(importer, id)// 2. resolve dir index and extensions.// 标准化路径,兼容不同的操作系统pathname = resolver.normalizePublicPath(pathname)// 3. mark non-src imports// 记录没有query参数且后缀名不是js的操作。例如 import './index.css' import png from 'xxx.png' 在后面加上 import queryif (!query && path.extname(pathname) && !jsSrcRE.test(pathname)) {query += `?import`}id = pathname + query}// 4. force re-fetch dirty imports by appending timestampif (timestamp) {// 在 模块 文件更新时在链接后面加上 t 参数防止浏览器缓存const dirtyFiles = hmrDirtyFilesMap.get(timestamp)const cleanId = cleanUrl(id)// only rewrite if:if (dirtyFiles && dirtyFiles.has(cleanId)) {// 1. this is a marked dirty file (in the import chain of the changed file)// 标志是来自脏文件的更新console.log('dirty', id, timestamp)id += `${id.includes(`?`) ? `&` : `?`}t=${timestamp}`} else if (latestVersionsMap.has(cleanId)) {// 2. this file was previously hot-updated and has an updated versionid += `${id.includes(`?`) ? `&` : `?`}t=${latestVersionsMap.get(cleanId)}`}}return id}
来自接收模块的更新
这里写了个例子来验证什么情况下是脏文件的更新, 什么情况是latestVersionsMap
// src/components/helloworld.vueimport { count } from './hmr'console.log(count) // 这里注意必须引用count,否则 helloworld.vue 无法接收到count的更新callbackif (import.meta.hot) {import.meta.hot.acceptDeps(['./hmr.js'], (newFoo) => {console.log(newFoo)})}// src/components/hmr.jsexport const count = 1
修改 hmr.js 后可以看到浏览器发起了三个请求
并且在终端中输出了以下信息
[vite:hmr] /src/components/HelloWorld.vue hot updated due to change in /src/components/hmr.js.dirty /src/components/hmr.js 1598002823963dirty /src/components/HelloWorld.vue?type=template 1598002823963
这里我们可以看到由于我们修改了 hmr.js 所以首先被文件 watcher 检测到了触发了 serverPluginHmr.ts 中的 handleJSReload 方法。这块我们后续会在热替换章节进行更细致的分析。
send({type: 'multi',updates: boundaries.map((boundary) => {return {type: boundary.endsWith('vue') ? 'vue-reload' : 'js-update',path: boundary,changeSrcPath: publicPath,timestamp}})
在 handleJSReload 中我们收集了接收 hmr.js 更新的模块。在这个例子里面是 helloworld.vue。 所以通过 socket 向客户端发送了类型为 vue-reload 的消息。此时浏览器去加载了新的 http://localhost:3000/src/components/HelloWorld.vue?t=1598002823963 组件。
if (timestamp) {const dirtyFiles = hmrDirtyFilesMap.get(timestamp)const cleanId = cleanUrl(id)// only rewrite if:if (dirtyFiles && dirtyFiles.has(cleanId)) {// 1. this is a marked dirty file (in the import chain of the changed file)id += `${id.includes(`?`) ? `&` : `?`}t=${timestamp}`} else if (latestVersionsMap.has(cleanId)) {console.log('last', id)// 2. this file was previously hot-updated and has an updated versionid += `${id.includes(`?`) ? `&` : `?`}t=${latestVersionsMap.get(cleanId)}`}}
这时候我们的新请求的 query 参数中含有 timestamp 这个参数了。并且在 handleJSReload 中,我们在 hmrDirtyFilesMap 把 hmr.js set进了 dirtyFiles。所以这里我们走到了 if 分支。在 hmr.js 的 path 后面加上了 t 参数。最后一个 type 为 template 的请求我们在后续组件渲染的章节进行讲解。
简单总结一下当我们使用 import.meta.hot 接收了某个模块的更新后,当它更新时,会触发 dirtyFiles 的逻辑
模块自身的更新
至于下面的 else 分支我们修改 helloworld.vue 时会触发 serverPluginVue.ts 的文件监控,进而发送 vue-reload 消息,让客户端进行新的 helloworld.vue 文件的请求。并且由于 Vite 本身的缓存机制,这里我们只有在第一次修改 helloworld.vue 时才会发起对 hmr.js 的实际请求。之后 Vite 检测到 hmr.js 文件并没有修改就不会再发起新的请求了。
即在模块因为自身的修改而更新时, 走下面的 else 分支。

重写路径
通过上面的代码拿到不同类型的路径导入。如:裸模块,相对路径,hmr更新。我们用不同的策略来重写。拿到结果后,通过 magic-string 来覆盖之前的路径。通过上面词法分析拿到的 import 的分析结果。我们可以通过 overwrite 来重写之前 import 的路径为新的。
if (resolved !== id) {debug(` "${id}" --> "${resolved}"`)s.overwrite(start,end,hasLiteralDynamicId ? `'${resolved}'` : resolved // 由于lexer的分析结果对于动态导入的情况会包含外层的引号,所以这里我们需要手动添加,否则最终的结果将不存在引号导致报错)hasReplaced = true}
对于使用了 import.meta.hot 的模块我们需要在模块顶层注入以下代码,使得其具有 import.meta.hot api提供的能力。(在 HMR 章节将进行更具体的分析)import.meta.env 同理也需要注入。这里不再赘述。
if (hasHMR) {debugHmr(`rewriting ${importer} for HMR.`)rewriteFileWithHMR(root, source, importer, resolver, s)hasReplaced = true}// src/server/serverPluginHmr.ts// inject import.meta.hots.prepend(`import { createHotContext } from "${clientPublicPath}"; ` +`import.meta.hot = createHotContext(${JSON.stringify(importer)}); `)
