模块解析

本章介绍 Vite 的核心模块加载解析机制

启动 Koa 服务

  1. // src/index.ts 省略部分代码
  2. const app = new Koa<State, Context>()
  3. const server = resolveServer(config, app.callback())
  4. const watcher = chokidar.watch(root, {
  5. ignored: [/\bnode_modules\b/, /\b\.git\b/]
  6. }) as HMRWatcher
  7. const resolver = createResolver(root, resolvers, alias) // 解析模块逻辑
  8. const context: ServerPluginContext = {
  9. root, // 项目根目录
  10. app, // Koa 实例
  11. server, // 自定义的 server 主要服务 https/http2.0 的情况
  12. watcher, // 本地文件的watcher
  13. resolver, // 定义模块的解析逻辑
  14. config,
  15. port: config.port || 3000
  16. }
  17. const resolvedPlugins = [
  18. // 这里加载一堆插件,本质是 Koa 中间件增强服务端能力
  19. moduleRewritePlugin,
  20. moduleResolvePlugin,
  21. ...
  22. ]
  23. resolvedPlugins.forEach((m) => m && m(context))

通过上述代码我们可以看到了 Vite 通过 Koa 启动了一个 http 服务,并且加载了一堆插件。插件的本质是 Koa Middlewares 这也是核心代码,通过添加插件来对不同类型的文件做不同的逻辑处理。 模块的解析机制的相关插件是 moduleRewritePlugin 和 moduleResolvePlugin

添加静态目录功能

通过使用 koa-static 来将 public 目录 以及整个根目录设置为静态资源目录,使得我们可以直接通过访问 http://localhost:3000/src/App.vue 的方式来访问具体的本地文件

  1. // src/serverPluginServeStatic.ts
  2. app.use(require('koa-static')(root))
  3. app.use(require('koa-static')(path.join(root, 'public')))

模块路径重写

这里我们分析的核心文件是 src/node/server/serverPluginModuleRewrite.ts。 原生的 ES module 不支持裸模块的导入,所以 Vite 进行了模块加载路径的重写。这里我们可以通过使用 debug 模块的功能来总览 Vite 到底重写了哪些路径 模块解析 - 图1 通过 debug 模块的输出我们可以很直观的发现 “vue” —> “/@modules/vue.js”, 至于其他的导入路径也被重写了例如 “./App.vue” —> “/src/App.vue” 则是为了让 Vite 更方便的找到模块的具体绝对路径。

  1. await initLexer // 初始化 es-module-lexer 进行词法分析
  2. const importer = removeUnRelatedHmrQuery(
  3. // removeUnRelatedHmrQuery 移除无关的HMR请求后面的query参数
  4. resolver.normalizePublicPath(ctx.url)
  5. )
  6. ctx.body = rewriteImports(
  7. root,
  8. content!, // 文件源码
  9. importer, // 需要从源文件中替换的路径
  10. resolver,
  11. ctx.query.t
  12. )
  13. if (!isHmrRequest) {
  14. rewriteCache.set(cacheKey, ctx.body)
  15. }

在 rewriteImports 方法中 Vite 使用了 esbuild 提供的 es-module-lexer 来进行词法分析。并且将最终已经 replace 模块路径的结果赋值给 ctx.body

es-module-lexer

通过阅读 es-module-lexer 的文档我们可以发现它是用来分析代码中的模块加载导出关系的。

  1. import { init, parse } from 'es-module-lexer/dist/lexer.js';
  2. (async () => {
  3. await init;
  4. const source = `
  5. import { a } from 'asdf';
  6. export var p = 5;
  7. export function q () {
  8. };
  9. // Comments provided to demonstrate edge cases
  10. import('dynamic').then();
  11. import /*comment!*/.meta.asdf;
  12. `;
  13. const [imports, exports] = parse(source, 'optional-sourcename');
  14. console.log(imports)
  15. // [
  16. // { s: 24, e: 28, ss: 5, se: 29, d: -1 },
  17. // { s: 190, e: 199, ss: 183, se: 0, d: 183 },
  18. // { s: 213, e: 237, ss: 213, se: 237, d: -2 }
  19. // ]
  20. // Returns "asdf"
  21. source.substring(imports[0].s, imports[0].e); // 通过 start end 获取具体的 import path
  22. // Returns "import { a } from 'asdf';"
  23. source.substring(imports[0].ss, imports[0].se); // 获取完整的 import 语句
  24. // Returns "p,q"
  25. exports.toString();
  26. // Dynamic imports are indicated by imports[1].d > -1
  27. // In this case the "d" index is the start of the dynamic import
  28. // Returns true
  29. imports[1].d > -1; // 用来判断是否是动态加载,d 是动态加载的开始位
  30. // Returns "'asdf'"
  31. source.substring(imports[1].s, imports[1].e);
  32. // Returns "import /*comment!*/ ("
  33. source.substring(imports[1].d, imports[1].s);
  34. // import.meta is indicated by imports[2].d === -2
  35. // Returns true
  36. imports[2].d === -2; // 用来判断是否是 import.meta
  37. // Returns "import /*comment!*/.meta"
  38. source.substring(imports[2].s, imports[2].e);
  39. })();

重写普通/动态的 import

这里我们借助上文提到的 es-module-lexer 对模块的 source 源码进行词法分析。为了更全面的了解,这里我们写一个动态 import 的代码用于测试

  1. // src/helloword.vue
  2. import('./hmr').then(res => {
  3. console.log(res)
  4. })
  5. // src/hmr.js
  6. if (import.meta.hot) {
  7. import.meta.hot.accept((newModule) => {
  8. console.log('updated: count is now ', newModule.count)
  9. })
  10. }
  11. export const count=1

在源码中打印一下此时的依赖收集结果

  1. const { s: start, e: end, d: dynamicIndex } = imports[i]
  2. let id = source.substring(start, end)
  3. let hasLiteralDynamicId = false
  4. console.log(id, dynamicIndex)

模块解析 - 图2

通过上面对 lexer 的介绍。我们知道当 dynamicIndex > -1 时代表是 dyanmic import。这里我们的 ‘./hmr’ > -1 所以针对这种导入。我们需要用正则来将 ‘ 符号中的具体路径提取出来

  1. if (dynamicIndex >= 0) {
  2. const literalIdMatch = id.match(/^(?:'([^']+)'|"([^"]+)")$/)
  3. if (literalIdMatch) {
  4. hasLiteralDynamicId = true
  5. id = literalIdMatch[1] || literalIdMatch[2]
  6. }
  7. }

/^(?:'([^']+)'|"([^"]+)")$/ 的正则提取用可视化工具 regexper 观察一下可以发现是用来提取 '内容' 或者 "内容" 中的具体内容 模块解析 - 图3

接着来我们处理正常的 import 以及经过正则处理过后的 dynamic import。import.meta.hot 以及 import.meta.env 在后续进行处理。

  1. if (dynamicIndex === -1 || hasLiteralDynamicId) {
  2. // do not rewrite external imports
  3. }

如果模块是外部模块则不进行处理

  1. const externalRE = /^(https?:)?\/\//
  2. const isExternalUrl = (url: string) => externalRE.test(url)
  3. if (isExternalUrl(id)) {
  4. continue
  5. }

通过原 id 获得 rewrite 之后的 resolved

  1. const resolved = resolveImport(
  2. root,
  3. importer,
  4. id,
  5. resolver,
  6. timestamp
  7. )
  8. export const resolveImport = (
  9. root: string,
  10. importer: string,
  11. id: string,
  12. resolver: InternalResolver,
  13. timestamp?: string
  14. ): string => {
  15. id = resolver.alias(id) || id
  16. if (bareImportRE.test(id)) {
  17. // vue => /@modules/vue.js
  18. // 处理裸模块。从模块的 package.json 中找到entry字段并且返回,这里 Vue 的 entry 是 '@vue/shared/dist/shared.esm-bundler.js',
  19. // 由于 Vite 在预优化时对所有 package.json 中的 dependencies 模块进行了预优化,所以返回的是统一 optimize 后的路径,这里会在预优化章节进行解析
  20. id = `/@modules/${resolveBareModuleRequest(root, id, importer, resolver)}`
  21. } else {
  22. // 相对路径转绝对路径
  23. let { pathname, query } = resolver.resolveRelativeRequest(importer, id)
  24. // 2. resolve dir index and extensions.
  25. // 标准化路径,兼容不同的操作系统
  26. pathname = resolver.normalizePublicPath(pathname)
  27. // 3. mark non-src imports
  28. // 记录没有query参数且后缀名不是js的操作。例如 import './index.css' import png from 'xxx.png' 在后面加上 import query
  29. if (!query && path.extname(pathname) && !jsSrcRE.test(pathname)) {
  30. query += `?import`
  31. }
  32. id = pathname + query
  33. }
  34. // 4. force re-fetch dirty imports by appending timestamp
  35. if (timestamp) {
  36. // 在 模块 文件更新时在链接后面加上 t 参数防止浏览器缓存
  37. const dirtyFiles = hmrDirtyFilesMap.get(timestamp)
  38. const cleanId = cleanUrl(id)
  39. // only rewrite if:
  40. if (dirtyFiles && dirtyFiles.has(cleanId)) {
  41. // 1. this is a marked dirty file (in the import chain of the changed file)
  42. // 标志是来自脏文件的更新
  43. console.log('dirty', id, timestamp)
  44. id += `${id.includes(`?`) ? `&` : `?`}t=${timestamp}`
  45. } else if (latestVersionsMap.has(cleanId)) {
  46. // 2. this file was previously hot-updated and has an updated version
  47. id += `${id.includes(`?`) ? `&` : `?`}t=${latestVersionsMap.get(cleanId)}`
  48. }
  49. }
  50. return id
  51. }

来自接收模块的更新

这里写了个例子来验证什么情况下是脏文件的更新, 什么情况是latestVersionsMap

  1. // src/components/helloworld.vue
  2. import { count } from './hmr'
  3. console.log(count) // 这里注意必须引用count,否则 helloworld.vue 无法接收到count的更新callback
  4. if (import.meta.hot) {
  5. import.meta.hot.acceptDeps(['./hmr.js'], (newFoo) => {
  6. console.log(newFoo)
  7. })
  8. }
  9. // src/components/hmr.js
  10. export const count = 1

修改 hmr.js 后可以看到浏览器发起了三个请求 模块解析 - 图4 并且在终端中输出了以下信息

  1. [vite:hmr] /src/components/HelloWorld.vue hot updated due to change in /src/components/hmr.js.
  2. dirty /src/components/hmr.js 1598002823963
  3. dirty /src/components/HelloWorld.vue?type=template 1598002823963

这里我们可以看到由于我们修改了 hmr.js 所以首先被文件 watcher 检测到了触发了 serverPluginHmr.ts 中的 handleJSReload 方法。这块我们后续会在热替换章节进行更细致的分析。

  1. send({
  2. type: 'multi',
  3. updates: boundaries.map((boundary) => {
  4. return {
  5. type: boundary.endsWith('vue') ? 'vue-reload' : 'js-update',
  6. path: boundary,
  7. changeSrcPath: publicPath,
  8. timestamp
  9. }
  10. })

在 handleJSReload 中我们收集了接收 hmr.js 更新的模块。在这个例子里面是 helloworld.vue。 所以通过 socket 向客户端发送了类型为 vue-reload 的消息。此时浏览器去加载了新的 http://localhost:3000/src/components/HelloWorld.vue?t=1598002823963 组件。

  1. if (timestamp) {
  2. const dirtyFiles = hmrDirtyFilesMap.get(timestamp)
  3. const cleanId = cleanUrl(id)
  4. // only rewrite if:
  5. if (dirtyFiles && dirtyFiles.has(cleanId)) {
  6. // 1. this is a marked dirty file (in the import chain of the changed file)
  7. id += `${id.includes(`?`) ? `&` : `?`}t=${timestamp}`
  8. } else if (latestVersionsMap.has(cleanId)) {
  9. console.log('last', id)
  10. // 2. this file was previously hot-updated and has an updated version
  11. id += `${id.includes(`?`) ? `&` : `?`}t=${latestVersionsMap.get(cleanId)}`
  12. }
  13. }

这时候我们的新请求的 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 分支。

模块解析 - 图5

重写路径

通过上面的代码拿到不同类型的路径导入。如:裸模块,相对路径,hmr更新。我们用不同的策略来重写。拿到结果后,通过 magic-string 来覆盖之前的路径。通过上面词法分析拿到的 import 的分析结果。我们可以通过 overwrite 来重写之前 import 的路径为新的。

  1. if (resolved !== id) {
  2. debug(` "${id}" --> "${resolved}"`)
  3. s.overwrite(
  4. start,
  5. end,
  6. hasLiteralDynamicId ? `'${resolved}'` : resolved // 由于lexer的分析结果对于动态导入的情况会包含外层的引号,所以这里我们需要手动添加,否则最终的结果将不存在引号导致报错
  7. )
  8. hasReplaced = true
  9. }

对于使用了 import.meta.hot 的模块我们需要在模块顶层注入以下代码,使得其具有 import.meta.hot api提供的能力。(在 HMR 章节将进行更具体的分析)import.meta.env 同理也需要注入。这里不再赘述。

  1. if (hasHMR) {
  2. debugHmr(`rewriting ${importer} for HMR.`)
  3. rewriteFileWithHMR(root, source, importer, resolver, s)
  4. hasReplaced = true
  5. }
  6. // src/server/serverPluginHmr.ts
  7. // inject import.meta.hot
  8. s.prepend(
  9. `import { createHotContext } from "${clientPublicPath}"; ` +
  10. `import.meta.hot = createHotContext(${JSON.stringify(importer)}); `
  11. )