Vite 技术揭秘之 ModuleGraph

发布一下 0 0

大家好,我是码农小余。上一小节我们知道了 vite dev 时通过 resolveConfig 去获取并合并配置,处理插件顺序和执行 config 、configResolved 钩子,最后还学习了 alias 以及 env 的配置处理。

Vite 技术揭秘之 ModuleGraph

从全流程上看,我们在解析完配置后,就会创建服务器(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 方法。文件之间的关系就如下图所示:

Vite 技术揭秘之 ModuleGraph

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:
  6. // 初始化模块图谱 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) 面板:

Vite 技术揭秘之 ModuleGraph

简单分析一下,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 技术揭秘之 ModuleGraph

当浏览器有一个请求到 Vite server 时,会经过 transform 中间件。中间件内部先依据是否以 .map 后缀判断 sourcemap 请求,是的话直接将 transformResult 对应的 map 返回。然后检查公共目录与根目录的位置关系,如果一个请求 url 以公共路径打头,就会触发如下的告警:

Vite 技术揭秘之 ModuleGraph

然后会对 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 模式下的代码,整个流程就比较清晰了:

Vite 技术揭秘之 ModuleGraph

我们就以初次加载 main.js 的过程执行一遍上述流程:

  1. 初次加载 main.js 时,server._pendingRequests 中不存在这个请求,所以进入 doTransform;
  2. 浏览器中加载 main.js 的请求是 http://localhost:3000/main.js,所以 doTransform 的 url 参数是 /main.js。
  3. const module = await server.moduleGraph.getModuleByUrl(url, ssr) 复制代码
  4. 初次加载时,moduleGraph 中没有 url /main.js对应的模块。所以这里查找的结果是 undefined。
  5. 接着会通过插件容器去解析 url
  6. const id = (await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url 复制代码
  7. 插件容器的 API 我们留到下一章去分析;这里我们只需要知道经过 pluginContainer.resolveId 转换后的 id 是文件的绝对路径(在我本地的结果是 Users/xxx/vite/examples/module-gtaph/main.js );
  8. 获取到 id 后,执行 pluginContainer.load 钩子:
  9. 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 } } // ... 复制代码
  10. 对于例子而言,因为我们没有自定义插件,所行全部内置插件的 load 钩子后,loadResult 最终结果是 null。这里 isFileServingAllowed(file, server) 会返回 ture,将 fs.readFile 读取 main.js 文件的内容存到 code 变量;
  11. 通过将 url 传入 moduleGraph.ensureEntryFromUrl 函数:
  12. const mod = await moduleGraph.ensureEntryFromUrl(url, ssr) 复制代码
  13. 在 ModuleGraph 类中我们已经了解 ensureEntryFromUrl 是将 url 解析后创建 ModuleNode 实例,并存到 urlToModuleMap、idToModuleMap、fileToModulesMap,最后返回的 mod 信息如下:

  14. 将 main.js 模块的 file 文件添加到文件监听实例中,达到后面修改 main.js 就会触发更新的效果;
  15. 将第4步拿到的 code 调用全部插件的 transform 钩子:
  16. const transformResult = await pluginContainer.transform(code, id, { inMap: map, ssr }) 复制代码
  17. 最终生成转换后的结果 transformResult。可以看到,上述所有步骤都是在处理 /main.js 这个 url 和对应的模块
  18. 那么 style.css 、foo.js 是怎么添加到 moduleGraph 中的呢?答案就是通过内置插件 vite:import-analysis ,在该插件的 transform 钩子中,会进行 import 的静态分析,如果有引用其他资源,那么也会添加到 moduleGraph 中。importAnalysis 源码会放到插件篇单独展开分析。但是我们可以看下 /main.js 经过 transform 钩子之后 moduleGraph 的结果:

  19. 从上图可以看到,main.js 依赖的 style.css 和 foo.js 已经被添加到 moduleGraph 中。不仅如此,对于彼此之间的依赖关系也已经形成,我们展开 main.js 和 style.css 两个模块看看:
Vite 技术揭秘之 ModuleGraph

Vite 技术揭秘之 ModuleGraph

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) } 复制代码
  2. main.js 处理之后 code 是文件内容,map 为 null。最后将转换后的结果存入模块的 transformResult 属性,并使用 etag 根据 code 结果生成文件 etag 信息,整个 doTransform 过程就就结束了。
  3. doTransform 结束后最后回到 transformMiddleware 中,拿到转换后的结果:
  4. // 使用插件容器解析、接在和转换 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 }) } 复制代码
  5. 对于 /main.js 而言,type 是脚本类型,最终通过 send 返回到浏览器。
  6. 浏览器拿到 main.js 的响应后,解析过程中遇到 import 就会接着发起资源请求,就又会进入 transformMiddleware 重复上述流程,一层一层地完成整个应用的资源加载,这里就利用浏览器支持 ESM 的设计。

总结

本文我们先学习了 ModuleGraph 类,了解到它的 4 个属性和 10 个方法;然后学习了 ModuleNode 类,知道 ModuleGraph 中的每一个节点都是 ModuleNode 的实例,以及每个节点上的属性。

在 dev 服务器启动完,我们在浏览器输入地址后:

Vite 技术揭秘之 ModuleGraph

浏览器就会向服务端请求 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

版权声明:内容来源于互联网和用户投稿 如有侵权请联系删除

本文地址:http://0561fc.cn/86355.html