Vite 源码解析(3) - 插件篇

Vite 源码解析(3) - 插件篇

116 views
Vite

无论是 webpack 还是 vite, 插件机制都极大的扩展了这些打包器的深度和广度, 这一篇我们深入 Vite 的插件机制, 了解它是如何运作的.

第一篇文章我们讲了在配置中, 通过 resolvePlugins 来聚合和解析一票插件, 留了一个未展开讲的函数 createPluginContainer, 我们知道 vite 插件扩展了设计出色的 rollup 接口, 因此为了和 rollup 插件打通, vite 搞了个 createPluginContainer 函数, 其实这个函数来自 wmr, 是 preact 打通 rollup 的包, vite 拿这个重新卷了一下.
这篇文章我们先来学习一下 createPluginContainer 的源码, 然后通过 @rollup/plugin-alias, plugins/esbuild 这两个库, 来看一看 createPluginContainer 是怎样与 rollup hooks 打通的.

createPluginContainer

整体来讲, createPluginContainer 可以划分为 前置逻辑, Context, TransformContext, container 和, 其中我们重点讲的是后三个. 当 vite 在构建到某个阶段时, 会调用 container 中的钩子函数, 这些钩子函数会遍历你所有的插件, 如果你的某些插件在这个钩子中要做些什么, 那就执行它, 否则就跳过它, 而这些钩子函数是和 roullup 插件体系打通的. 而 ContextTransformContext 相当于一个工厂, 将 vite 的插件转换成 rollup 可用的.

前置代码

const isDebug = process.env.DEBUG;
const seenResolves: Record<string, true | undefined> = {};
const debugResolve = createDebugger("vite:resolve");
const debugPluginResolve = createDebugger("vite:plugin-resolve", {
  onlyWhenFocused: "vite:plugin",
});
const debugPluginTransform = createDebugger("vite:plugin-transform", {
  onlyWhenFocused: "vite:plugin",
});
const debugSourcemapCombineFlag = "vite:sourcemap-combine";
const isDebugSourcemapCombineFocused = process.env.DEBUG?.includes(
  debugSourcemapCombineFlag
);
const debugSourcemapCombineFilter =
  process.env.DEBUG_VITE_SOURCEMAP_COMBINE_FILTER;
const debugSourcemapCombine = createDebugger("vite:sourcemap-combine", {
  onlyWhenFocused: true,
});
// ---------------------------------------------------------------------------
const watchFiles = new Set<string>();
// TODO: use import()
// createRequire 上次说了, 用来加载 cjs 文件
const _require = createRequire(import.meta.url);
// get rollup version
// 先拿到 node_modules/rollup/package,json
const rollupPkgPath = resolve(_require.resolve("rollup"), "../../package.json");
// 我们看到主要是取当前 rollup 的版本信息
const minimalContext: MinimalPluginContext = {
  meta: {
    rollupVersion: JSON.parse(fs.readFileSync(rollupPkgPath, "utf-8")).version,
    watchMode: true,
  },
};
// 不兼容的插件暴露 warning.
function warnIncompatibleMethod(method: string, plugin: string) {
  logger.warn(
    colors.cyan(`[plugin:${plugin}] `) +
      colors.yellow(
        `context method ${colors.bold(
          `${method}()`
        )} is not supported in serve mode. This plugin is likely not vite-compatible.`
      )
  );
}
// throw when an unsupported ModuleInfo property is accessed,
// so that incompatible plugins fail in a non-cryptic way.
// 这里很优雅的用到了 Proxy 和 Reflect, 用于拦截 module.info 的 getter
// 配合下面 getModuleInfo 这个函数看
const ModuleInfoProxy: ProxyHandler<ModuleInfo> = {
  get(info: any, key: string) {
    if (key in info) {
      return info[key];
    }
    throw Error(`[vite] The "${key}" property of ModuleInfo is not supported.`);
  },
};
// same default value of "moduleInfo.meta" as in Rollup
const EMPTY_OBJECT = Object.freeze({});
function getModuleInfo(id: string) {
  const module = moduleGraph?.getModuleById(id);
  if (!module) {
    return null;
  }
  // 如果没有 module.info, 就给 module.info 加上代理, 且
  // module.info 只有 id, meta 两个字段
  // 一旦你访问非 id, meta 字段, 比如 module.info.xxx 就会报错
  if (!module.info) {
    module.info = new Proxy(
      { id, meta: module.meta || EMPTY_OBJECT } as ModuleInfo,
      ModuleInfoProxy
    );
  }
  return module.info;
}
// 更新模块的 meta 信息
function updateModuleInfo(id: string, { meta }: { meta?: object | null }) {
  if (meta) {
    const moduleInfo = getModuleInfo(id);
    if (moduleInfo) {
      moduleInfo.meta = { ...moduleInfo.meta, ...meta };
    }
  }
}

Context

关于 Context 和 TransformContext 我们简单说, 它实现了 rollup 的 PluginContext 接口, 可以把它理解为一个工厂, 是将 vite 的插件转换成 rollup 可用的.

// we should create a new context for each async hook pipeline so that the
// active plugin in that pipeline can be tracked in a concurrency-safe manner.
// using a class to make creating new contexts more efficient
class Context implements PluginContext {
  meta = minimalContext.meta;
  ssr = false;
  _scan = false;
  _activePlugin: Plugin | null;
  _activeId: string | null = null;
  _activeCode: string | null = null;
  _resolveSkips?: Set<Plugin>;
  _addedImports: Set<string> | null = null;
  constructor(initialPlugin?: Plugin) {
    this._activePlugin = initialPlugin || null;
  }
  // 这里用到了 acorn 这个库, 用来将代码字符串转换为 AST
  // rollup, webpack 都用这个
  parse(code: string, opts: any = {}) {
    return parser.parse(code, {
      sourceType: "module",
      ecmaVersion: "latest",
      locations: true,
      ...opts,
    });
  }
  // 解析路径
  // src/container/Home.tsx -> /User/xxxx/vite-example/src/container/Home.tsx
  async resolve(
    id: string,
    importer?: string,
    options?: { skipSelf?: boolean }
  ) {
    // 收集需要跳过的插件
    let skip: Set<Plugin> | undefined;
    if (options?.skipSelf && this._activePlugin) {
      skip = new Set(this._resolveSkips);
      skip.add(this._activePlugin);
    }
    // 关于 resolveId 下面会讲到
    // 它就是获取当前模块在文件系统的绝对路径
    // 我们会跳过 skip 包含的插件
    let out = await container.resolveId(id, importer, {
      skip,
      ssr: this.ssr,
      scan: this._scan,
    });
    // 如果没有找到, 或者被跳过, 就返回 null
    // 否则返回路径
    if (typeof out === "string") out = { id: out };
    return out as ResolvedId | null;
  }
  // 获取模块信息
  getModuleInfo(id: string) {
    return getModuleInfo(id);
  }
  // 获取所有模块路径
  getModuleIds() {
    return moduleGraph
      ? moduleGraph.idToModuleMap.keys()
      : Array.prototype[Symbol.iterator]();
  }
  addWatchFile(id: string) {
    watchFiles.add(id);
    (this._addedImports || (this._addedImports = new Set())).add(id);
    if (watcher) ensureWatchedFile(watcher, id, root);
  }
  getWatchFiles() {
    return [...watchFiles];
  }
  // 未实现
  emitFile(assetOrFile: EmittedFile) {
    warnIncompatibleMethod(`emitFile`, this._activePlugin!.name);
    return "";
  }
  // 未实现
  setAssetSource() {
    warnIncompatibleMethod(`setAssetSource`, this._activePlugin!.name);
  }
  // 未实现
  getFileName() {
    warnIncompatibleMethod(`getFileName`, this._activePlugin!.name);
    return "";
  }
  warn(
    e: string | RollupError,
    position?: number | { column: number; line: number }
  ) {
    const err = formatError(e, position, this);
    const msg = buildErrorMessage(
      err,
      [colors.yellow(`warning: ${err.message}`)],
      false
    );
    logger.warn(msg, {
      clear: true,
      timestamp: true,
    });
  }
  error(
    e: string | RollupError,
    position?: number | { column: number; line: number }
  ): never {
    // error thrown here is caught by the transform middleware and passed on
    // the the error middleware.
    throw formatError(e, position, this);
  }
}

TransformContext

TransformContext 在 Context 的基础上加上了对 sourcemap 的处理. 具体可以看下面的 transform 部分.

class TransformContext extends Context {
  filename: string;
  originalCode: string;
  originalSourcemap: SourceMap | null = null;
  sourcemapChain: NonNullable<SourceDescription["map"]>[] = [];
  combinedMap: SourceMap | null = null;
  constructor(filename: string, code: string, inMap?: SourceMap | string) {
    super();
    this.filename = filename;
    this.originalCode = code;
    if (inMap) {
      this.sourcemapChain.push(inMap);
    }
  }
  _getCombinedSourcemap(createIfNull = false) {
    if (
      debugSourcemapCombineFilter &&
      this.filename.includes(debugSourcemapCombineFilter)
    ) {
      debugSourcemapCombine("----------", this.filename);
      debugSourcemapCombine(this.combinedMap);
      debugSourcemapCombine(this.sourcemapChain);
      debugSourcemapCombine("----------");
    }
    let combinedMap = this.combinedMap;
    for (let m of this.sourcemapChain) {
      if (typeof m === "string") m = JSON.parse(m);
      if (!("version" in (m as SourceMap))) {
        // empty, nullified source map
        combinedMap = this.combinedMap = null;
        this.sourcemapChain.length = 0;
        break;
      }
      if (!combinedMap) {
        combinedMap = m as SourceMap;
      } else {
        combinedMap = combineSourcemaps(cleanUrl(this.filename), [
          {
            ...(m as RawSourceMap),
            sourcesContent: combinedMap.sourcesContent,
          },
          combinedMap as RawSourceMap,
        ]) as SourceMap;
      }
    }
    if (!combinedMap) {
      return createIfNull
        ? new MagicString(this.originalCode).generateMap({
            includeContent: true,
            hires: true,
            source: cleanUrl(this.filename),
          })
        : null;
    }
    if (combinedMap !== this.combinedMap) {
      this.combinedMap = combinedMap;
      this.sourcemapChain.length = 0;
    }
    return this.combinedMap;
  }
  getCombinedSourcemap() {
    return this._getCombinedSourcemap(true) as SourceMap;
  }
}

container

const container: PluginContainer = {
  options: await(async () => {})(),
  // 从依赖图中取得模块信息, 上面介绍了
  getModuleInfo,
  async buildStart() {},
  async resolveId(rawId, importer = join(root, "index.html"), options) {},
  async load(id, ssr) {},
  async transform(code, id, inMap, ssr) {},
  watchChange(id, event = "update") {},
  async close() {},
};
return container;

上面我们讲到 rollup 的钩子, 而 vite 用到了如下几个:

  • options

  • buildStart

  • resolveId

  • load

  • transform
    下面我们依次看他们做了些什么.

options

options 是一个立即执行函数, 也是构建过程中执行的第一个钩子. 它会遍历执行所有插件的 options 方法, 最终返回合并后的 options.

const container: PluginContainer = {
  options: await(async () => {
    // 在 vite 配置文件中可以配置 build.rollupOptions
    // https://rollupjs.org/guide/en/#big-list-of-options
    let options = rollupOptions;
    for (const plugin of plugins) {
      if (!plugin.options) continue;
      options = (await plugin.options.call(minimalContext, options)) || options;
    }
    // 扩展 Acorn 的 Parser, 比如:
    // rollupOptions: {
    //  acornInjectPlugins: [ importAssertions ],
    // }
    if (options.acornInjectPlugins) {
      parser = acorn.Parser.extend(
        ...(arraify(options.acornInjectPlugins) as any)
      );
    }
    return {
      acorn,
      acornInjectPlugins: [],
      ...options,
    };
  })(),
};

buildStart

它的作用是就是开始构建之前获取上面 options 钩子的配置项, 用于其他钩子函数使用. 因为用了 Promise.all, 一个凉了就全部失败.

const container: PluginContainer = {
  async buildStart() {
    await Promise.all(
      plugins.map((plugin) => {
        if (plugin.buildStart) {
          return plugin.buildStart.call(
            new Context(plugin) as any,
            container.options as NormalizedInputOptions
          );
        }
      })
    );
  },
};

resolveId

下一章我们会讲到 transformMiddleware 这个中间件, 它用于拦截并处理模块请求, 其中就包含调用 resolveId 钩子函数, 我们在代码中会直接引用 node_modules 中的库, 也会相对引用其他模块的源码, 也会使用 alias 等等. 因此, 这个钩子就是用于解析文件路径, 变成绝对路径.

/**
 * @param rawId 代码中使用的路径, 比如 import { foo } from '../bar.js', 那 rawId 就是 '../bar.js'
 * @param importer 导入模块的位置, 默认是 index.html 那一级的路径
 * @param options 配置
 * @returns  Promise<PartialResolvedId | null>
 */
const container: PluginContainer = {
  async resolveId(rawId, importer = join(root, "index.html"), options) {
    const skip = options?.skip; // Set<Plugin> | undefined
    const ssr = options?.ssr;
    const scan = !!options?.scan;
    const ctx = new Context();
    ctx.ssr = !!ssr; // 是否为服务端渲染
    ctx._scan = scan; // 是否需要扫描
    ctx._resolveSkips = skip; // 需要跳过的模块集合
    const resolveStart = isDebug ? performance.now() : 0;
    let id: string | null = null;
    const partial: Partial<PartialResolvedId> = {};
    for (const plugin of plugins) {
      if (!plugin.resolveId) continue; // 没有 resolveId 钩子就跳过
      if (skip?.has(plugin)) continue; // 如果需要跳过, 就跳过
      // 此时这个插件就是当前正在执行 resolveId 钩子函数的
      ctx._activePlugin = plugin;
      const pluginResolveStart = isDebug ? performance.now() : 0;
      // 调用 resolveId 钩子函数
      const result = await plugin.resolveId.call(ctx as any, rawId, importer, {
        ssr,
        scan,
      });
      // 如果没有返回值继续调用剩余插件的 resolveId 钩子函数
      if (!result) continue;
      if (typeof result === "string") {
        id = result;
      } else {
        id = result.id;
        Object.assign(partial, result);
      }
      isDebug &&
        debugPluginResolve(
          timeFrom(pluginResolveStart),
          plugin.name,
          prettifyUrl(id, root)
        );
      // resolveId() is hookFirst - first non-null result is returned.
      // 拿到 id 后, 就可以终止 resolveId 钩子函数了
      // 也就是说只要有一个插件的 resolveId 方法返回有效值, 就终止循环
      break;
    }
    if (isDebug && rawId !== id && !rawId.startsWith(FS_PREFIX)) {
      const key = rawId + id;
      // avoid spamming
      if (!seenResolves[key]) {
        seenResolves[key] = true;
        debugResolve(
          `${timeFrom(resolveStart)} ${colors.cyan(rawId)} -> ${colors.dim(id)}`
        );
      }
    }
    // 最终返回一个对象, 对象内有一个属性 id, 值是解析后的绝对路径
    // external 是指引用的外部 url, 比如像 react, react-dom 走了 CDN
    if (id) {
      partial.id = isExternalUrl(id) ? id : normalizePath(id);
      return partial as PartialResolvedId;
    } else {
      return null;
    }
  },
};

load

在 resolveId 钩子结束后, 我们就拿到了当前文件的绝对路径, 接下来就是调用 load 钩子函数, 可以获取到文件的内容. 在 rollup 中, 如果你执行了 load 函数, 返回 null, 说明你没有对源码进行改变; 否则返回 SourceDescription 类型. 因此在下面的代码中, 我们也看到如果 result 是 Object 类型, 那么需要执行 updateModuleInfo 函数.
和 resolveId 类似, 只要有一个插件的 load 方法返回了 SourceDescription 类型的 result 就终止遍历.

const container: PluginContainer = {
  async load(id, options) {
    const ssr = options?.ssr;
    const ctx = new Context();
    ctx.ssr = !!ssr;
    for (const plugin of plugins) {
      if (!plugin.load) continue;
      ctx._activePlugin = plugin;
      const result = await plugin.load.call(ctx as any, id, { ssr });
      if (result != null) {
        if (isObject(result)) {
          updateModuleInfo(id, result);
        }
        return result;
      }
    }
    return null;
  },
};

transform

transform 似乎是我们最常用的钩子, 它用于转换源码, 像 JSON, YAML, 图片等非 JavaScript 文件都需要在这里进行转换. 前段时间写了个插件, 叫做 rollup-plugin-toml, 它可以将 toml 文件转换为 ESM 模块.

export interface SourceDescription extends Partial<PartialNull<ModuleOptions>> {
  ast?: AcornNode;
  code: string;
  map?: SourceMapInput;
}
const container: PluginContainer = {
  /**
   * @param code 文件源码
   * @param id 文件路径
   * @param options sourcemap 相关
   * @returns {SourceDescription | null}
   */
  async transform(code, id, options) {
    const inMap = options?.inMap;
    const ssr = options?.ssr;
    const ctx = new TransformContext(id, code, inMap as SourceMap);
    ctx.ssr = !!ssr;
    for (const plugin of plugins) {
      if (!plugin.transform) continue;
      ctx._activePlugin = plugin; // 当前正在使用的插件
      ctx._activeId = id; // 当前正在处理的模块路径
      ctx._activeCode = code; // 当前正在处理的源码字符串
      const start = isDebug ? performance.now() : 0;
      let result: TransformResult | string | undefined;
      try {
        // 调用插件的 transform 方法
        result = await plugin.transform.call(ctx as any, code, id, { ssr });
      } catch (e) {
        ctx.error(e);
      }
      if (!result) continue;
      isDebug &&
        debugPluginTransform(
          timeFrom(start),
          plugin.name,
          prettifyUrl(id, root)
        );
      if (isObject(result)) {
        // 说明源码发生了变动
        if (result.code !== undefined) {
          // 复写源码
          code = result.code;
          if (result.map) {
            if (isDebugSourcemapCombineFocused) {
              // @ts-expect-error inject plugin name for debug purpose
              result.map.name = plugin.name;
            }
            ctx.sourcemapChain.push(result.map);
          }
        }
        // 由于结果变了, 需要更新模块信息
        updateModuleInfo(id, result);
      } else {
        // 由于 transform 也可以返回字符串, 那么这个 result 就是 transform 之后的源码.
        code = result;
      }
    }
    // 返回新的 code 和 map 供下个插件使用
    return {
      code,
      map: ctx._getCombinedSourcemap(),
    };
  },
};

close

不多说, 我们直接摘抄 rollup 的原话: the last one is always buildEnd. If there is a build error, closeBundle will be called after that. 翻译一下就是在 build 最后会调用 buildEnd 钩子, 如果发生了构建错误, 最后还会额外调用 closeBundle 钩子.

const container: PluginContainer = {
  async close() {
    if (closed) return;
    const ctx = new Context();
    await Promise.all(
      plugins.map((p) => p.buildEnd && p.buildEnd.call(ctx as any))
    );
    await Promise.all(
      plugins.map((p) => p.closeBundle && p.closeBundle.call(ctx as any))
    );
    closed = true;
  },
};

@rollup/plugin-alias

最后我们看两个插件, 作为对 createPluginContainer 的一个复习. 首先看一下 @rollup/plugin-alias.
我们在平时开发中, 为了防止 ../../../xxx.js 这种路径引用, 一般都会配置 alias 配置, 比如:

module.exports = {
  plugins: [
    alias({
      entries: [
        { find: 'utils', replacement: '../../../utils' },
        { find: 'batman-1.0.0', replacement: './joker-1.5.0' }
        { find:/^i18n\!(.*)/, replacement: '$1.js' }
      ]
    }),
    alias({
      entries: {
        '@': path.resolve(__dirname, 'src')
      }
    });
  ]
};

这样的好处是, 当我们引用 ../../../xxx.js 时, 可以写成 @/xxx.js, 代码会非常的干净. 想必大家也能猜出, 这个插件最重要地方的是对 resolveId 这个钩子的处理, 我们来学习下这块.

function alias(options = {}) {
  // 获取别名列表
  const entries = getEntries(options);
  if (entries.length === 0) {
    return {
      name: "alias",
      resolveId: () => null,
    };
  }
  return {
    name: "alias",
    async buildStart(inputOptions) {
      await Promise.all(
        [
          ...(Array.isArray(options.entries) ? options.entries : []),
          options,
        ].map(
          ({ customResolver }) =>
            customResolver &&
            typeof customResolver === "object" &&
            typeof customResolver.buildStart === "function" &&
            customResolver.buildStart.call(this, inputOptions)
        )
      );
    },
    resolveId(importee, importer, resolveOptions) {
      if (!importer) {
        return null;
      }
      // First match is supposed to be the correct one
      // 找到第一个匹配的配置
      const matchedEntry = entries.find((entry) =>
        matches(entry.find, importee)
      );
      if (!matchedEntry) {
        return null;
      }
      // @containers/Home.tsx -> src/containers/Home.tsx
      const updatedId = importee.replace(
        matchedEntry.find,
        matchedEntry.replacement
      );
      // 如果提供了自定义的 resolve 算法, 那就用自定义的
      if (matchedEntry.resolverFunction) {
        return matchedEntry.resolverFunction.call(
          this,
          updatedId,
          importer,
          resolveOptions
        );
      }
      // 否则用默认的 resolve 方法, 这个上面讲 Context 的时候说到了
      return this.resolve(
        updatedId,
        importer,
        // 当然上面有一点没说明的就是 skipSelf: true
        // 这句话的意思是这个 id 已经将要被解析成绝对路径了
        // 如果后面有插件还想解析这个 id, 直接用现成了, 跳过就好
        Object.assign({ skipSelf: true }, resolveOptions)
      ).then((resolved) => resolved || { id: updatedId });
    },
  };
}

vite:esbuild

上面我们讲到了一个使用 resolveId 的实例. 这次我们讲下 vite 内置的 vite:esbuild. 我们知道 vite 快的一个根本原因就是用了 esbuild. esbuild 在 vite 开发环境体现在以下三个阶段:

  • 依赖预构建: 这也是 vite 冷启为什么快的原因, 我们在第六章会讲到

  • ESM 请求源码: 也就是我们一会儿要讲到了, 在前端请求源码时, 会受到 transformMiddleware 的拦截, 我们知道源码是无法被浏览器直接使用的, 所以这里就会通过 rollup 进行构建, 而在开发环境, vite 在 rollup 中使用了 esbuild 插件

  • HMR: 和上一条类似, 都是使用 esbuild 加速构建过程
    vite:esbuild 中最核心的就是 transform 钩子, 它调用了 esbuild 的 transform 函数, 该函数用于转换单个文件, 可以用来压缩 JavaScript, 把 ts/tsx 转换成 JavaScript, 或者把高版本 ECMAScript 转成低版本. 它返回一个 Promise, 当 Promise 被 resolve 时, 返回一个 TransformResult 类型的对象; 如果被 reject, 就返回一个 TransformFailure 类型的对象.

This function transforms a single JavaScript file. It can be used to minify JavaScript, convert TypeScript/JSX to JavaScript, or convert newer JavaScript to older JavaScript. It returns a promise that is either resolved with a "TransformResult" object or rejected with a "TransformFailure" object.
我们简单过一下代码, 了解一下一般我们会在 transform 钩子做什么. 至于 esbuild 的 transform 函数怎么运作的, 超纲了...
export async function transformWithEsbuild(
  code: string,
  filename: string,
  options?: TransformOptions,
  inMap?: object
): Promise<ESBuildTransformResult> {
  let loader = options?.loader;
  // loader 是 esbuild Transform API 中的一个参数, 它一般就是文件的扩展名, 比如 .js, .css, .tsx 等等
  // 它用于告知 esbuild 用什么 loader 来处理指定格式的文件
  if (!loader) {
    // if the id ends with a valid ext, use it (e.g. vue blocks)
    // otherwise, cleanup the query before checking the ext
    const ext = path
      .extname(/\.\w+$/.test(filename) ? filename : cleanUrl(filename))
      .slice(1);
    if (ext === "cjs" || ext === "mjs") {
      loader = "js";
    } else {
      loader = ext as Loader;
    }
  }
  // tsconfigRaw 也是 esbuild Transform API 中的一个参数, 它以字符串的形式传入 tsconfig.json 的配置
  // https://esbuild.github.io/api/#tsconfig-raw
  // 如果你传入了这个参数, 那么 esbuild 就不会去读取 tsconfig.json 文件了
  let tsconfigRaw = options?.tsconfigRaw;
  // if options provide tsconfigraw in string, it takes highest precedence
  if (typeof tsconfigRaw !== "string") {
    // these fields would affect the compilation result
    // https://esbuild.github.io/content-types/#tsconfig-json
    const meaningfulFields: Array<keyof TSCompilerOptions> = [
      "target",
      "jsxFactory",
      "jsxFragmentFactory",
      "useDefineForClassFields",
      "importsNotUsedAsValues",
      "preserveValueImports",
    ];
    // 如果是 ts 或者 tsx, 就找 tsconfig.json 的配置
    // 如果有 meaningfulFields 的这六个, 就把它缓存到 compilerOptionsForFile 对象中
    const compilerOptionsForFile: TSCompilerOptions = {};
    if (loader === "ts" || loader === "tsx") {
      const loadedTsconfig = await loadTsconfigJsonForFile(filename);
      const loadedCompilerOptions = loadedTsconfig.compilerOptions ?? {};
      for (const field of meaningfulFields) {
        if (field in loadedCompilerOptions) {
          // @ts-ignore TypeScript can't tell they are of the same type
          compilerOptionsForFile[field] = loadedCompilerOptions[field];
        }
      }
    }
    // 最终合并成一个新的 tsconfigRaw
    tsconfigRaw = {
      ...tsconfigRaw,
      compilerOptions: {
        ...compilerOptionsForFile,
        ...tsconfigRaw?.compilerOptions,
      },
    };
  }
  const resolvedOptions = {
    sourcemap: true,
    // ensure source file name contains full query
    sourcefile: filename,
    ...options,
    loader,
    tsconfigRaw,
  } as ESBuildOptions;
  delete resolvedOptions.include;
  delete resolvedOptions.exclude;
  delete resolvedOptions.jsxInject;
  try {
    // 这里就是 esbuild 真正转换文件的核心了, 有兴趣可以去看 esbuild 的源码
    // 对于绝大多数 transfrom 钩子的实现, 我们大多都是读取文件内容, 然后通过三方库来转换, 然后再写入文件
    // 比如我写的 [rollup-plugin-toml](https://github.com/YanceyOfficial/rollup-plugin-toml)
    // 实际用到的转换器是 @iarna/toml 这个库
    const result = await transform(code, resolvedOptions);
    let map: SourceMap;
    if (inMap && resolvedOptions.sourcemap) {
      const nextMap = JSON.parse(result.map);
      nextMap.sourcesContent = [];
      map = combineSourcemaps(filename, [
        nextMap as RawSourceMap,
        inMap as RawSourceMap,
      ]) as SourceMap;
    } else {
      map = resolvedOptions.sourcemap
        ? JSON.parse(result.map)
        : { mappings: "" };
    }
    if (Array.isArray(map.sources)) {
      map.sources = map.sources.map((it) => toUpperCaseDriveLetter(it));
    }
    return {
      ...result,
      map,
    };
  } catch (e: any) {
    debug(`esbuild error with options used: `, resolvedOptions);
    // patch error information
    if (e.errors) {
      e.frame = "";
      e.errors.forEach((m: Message) => {
        e.frame += `
` + prettifyMessage(m, code);
      });
      e.loc = e.errors[0].location;
    }
    throw e;
  }
}

总结

这一章我们了解了 vite 的插件机制, vite 的插件本质需要在 rollup 体系下执行, 因此 vite 封装了 createPluginContainer 函数, 通过它, vite 插件才得以在 rollup 的各个 hooks 中被调用.
此外, 我们还通过两个例子, 来了解插件是如何实现钩子函数的. 通过 vite:esbuild 这个例子, 我们知道了 vite 快的原因之一, 就是源码是按需编译的, 且编译使用了 esbuild.
下一章我们将重点学习 vite 的开发 server 是如何搭建的, 以及中间件机制是怎样运行的. 中间件在拦截请求后会调用这一章学到的插件机制, 来进行源码的转化. 敬请期待~