# 模块解析
本章介绍 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 HMRWatcher
const resolver = createResolver(root, resolvers, alias) // 解析模块逻辑
const context: ServerPluginContext = {
root, // 项目根目录
app, // Koa 实例
server, // 自定义的 server 主要服务 https/http2.0 的情况
watcher, // 本地文件的watcher
resolver, // 定义模块的解析逻辑
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.ts
app.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 (opens new window) 来进行词法分析。并且将最终已经 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 cases
import('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 true
imports[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 true
imports[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.vue
import('./hmr').then(res => {
console.log(res)
})
// src/hmr.js
if (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 = false
console.log(id, dynamicIndex)
通过上面对 lexer 的介绍。我们知道当 dynamicIndex > -1 时代表是 dyanmic import。这里我们的 './hmr' > -1 所以针对这种导入。我们需要用正则来将 ' 符号中的具体路径提取出来
if (dynamicIndex >= 0) {
const literalIdMatch = id.match(/^(?:'([^']+)'|"([^"]+)")$/)
if (literalIdMatch) {
hasLiteralDynamicId = true
id = literalIdMatch[1] || literalIdMatch[2]
}
}
/^(?:'([^']+)'|"([^"]+)")$/
的正则提取用可视化工具 regexper (opens new window) 观察一下可以发现是用来提取 '内容'
或者 "内容"
中的具体内容
接着来我们处理正常的 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) || id
if (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 query
if (!query && path.extname(pathname) && !jsSrcRE.test(pathname)) {
query += `?import`
}
id = pathname + query
}
// 4. force re-fetch dirty imports by appending timestamp
if (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 version
id += `${id.includes(`?`) ? `&` : `?`}t=${latestVersionsMap.get(cleanId)}`
}
}
return id
}
# 来自接收模块的更新
这里写了个例子来验证什么情况下是脏文件的更新, 什么情况是latestVersionsMap
// src/components/helloworld.vue
import { count } from './hmr'
console.log(count) // 这里注意必须引用count,否则 helloworld.vue 无法接收到count的更新callback
if (import.meta.hot) {
import.meta.hot.acceptDeps(['./hmr.js'], (newFoo) => {
console.log(newFoo)
})
}
// src/components/hmr.js
export 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 1598002823963
dirty /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 version
id += `${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 (opens new window) 来覆盖之前的路径。通过上面词法分析拿到的 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.hot
s.prepend(
`import { createHotContext } from "${clientPublicPath}"; ` +
`import.meta.hot = createHotContext(${JSON.stringify(importer)}); `
)