Vite 技术揭秘之 ModuleGraph

1,824 阅读14分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情

大家好,我是码农小余。上一小节我们知道了 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.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 方法。文件之间的关系就如下图所示:

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 个属性:

  1. urlToModuleMap:url 跟模块的映射;
  2. idToModuleMap:id 跟模块的映射;
  3. fileToModulesMap:文件跟模块的映射,注意这里的 Modules 是复数,说明一个文件可以对应多个模块;
  4. safeModulesPath:/@fs 模块集合;@fs 模块具体指代哪些模块呢?这里先留一个悬念;

并提供了一系列获取、更新这些属性的方法:

  1. getModuleByUrlgetModuleByIdgetModulesByFile 分别是通过 url、id、file 获取模块的方法;

  2. onFileChangeinvalidateAllinvalidateModule 文件改变时的响应函数以及清除模块方法;

  3. updateModuleInfo 更新模块时触发,这部分代码在热更新时才会涉及,先不看这部分;

  4. resolveUrl 用于解析网址,createFileOnlyEntry 对于一些 import 会生成 file 和 模块节点,比如 css 中的 @import,对于 @import 的内容也需要热更功能;

  5. 最后还有一个在构造函数参数中传入的函数 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 的过程执行一遍上述流程:

  1. 初次加载 main.js 时,server._pendingRequests 中不存在这个请求,所以进入 doTransform;

  2. 浏览器中加载 main.js 的请求是 http://localhost:3000/main.js,所以 doTransform 的 url 参数是 /main.js。

    const module = await server.moduleGraph.getModuleByUrl(url, ssr)
    

    初次加载时,moduleGraph 中没有 url /main.js对应的模块。所以这里查找的结果是 undefined。

  3. 接着会通过插件容器去解析 url

    const id =
        (await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url
    

    插件容器的 API 我们留到下一章去分析;这里我们只需要知道经过 pluginContainer.resolveId 转换后的 id 是文件的绝对路径(在我本地的结果是 Users/xxx/vite/examples/module-gtaph/main.js );

  4. 获取到 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 变量;

  5. 通过将 url 传入 moduleGraph.ensureEntryFromUrl 函数:

     const mod = await moduleGraph.ensureEntryFromUrl(url, ssr)
    

    在 ModuleGraph 类中我们已经了解 ensureEntryFromUrl 是将 url 解析后创建 ModuleNode 实例,并存到 urlToModuleMap、idToModuleMap、fileToModulesMap,最后返回的 mod 信息如下:

  6. 将 main.js 模块的 file 文件添加到文件监听实例中,达到后面修改 main.js 就会触发更新的效果;

  7. 将第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)的信息。

  1. 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 过程就就结束了。

  2. 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 返回到浏览器。

  3. 浏览器拿到 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 到底是什么?下一篇我们就去认识它——插件容器