从零读 Vite 4.0 源码(五)—— vite 插件机制

根据上文,我们知道 vite 分为开发环境和生产环境,在生产环境使用 rollup 及其插件进行打包,在开发环境使用了自定义的 pluginContainer 来实现。

那么 vite 是如何借助 pluginContainer 来实现开发环境的插件机制,并使用了什么策略来抹平和生产环境的差异呢?

vite 借助了哪些插件,对源文件都做了什么处理呢?

这些都会在本节进行介绍。

首先我们先来看一下在生产环境下,是如何借助 rollup 及其插件来进行打包的:

async function build(config) {
  const resolvedConfig =  await resolveConfig(
    config,
    'build',
    'production',
    'production',
  )
  const rollupOptions = {
    // 指定是否保留入口模块的签名
    preserveEntrySignatures: false,
    // 指定 rollup的构建缓存
    cache: undefined,
    // 指定入口文件的路径
    input: path.resolve(config.root, 'index.html'),
    // 这里指要使用的 rollup插件
    plugins: resolvedConfig.plugins,
    // 指定要排除在外的外部模块
    external: undefined
  }

  const { rollup } = await import('rollup')
  // rollup 的 API 详见:https://rollupjs.org/javascript-api/
  const bundle = await rollup(rollupOptions)

  const res = []
  // outputs示例:
  // [{
  // 指定的输出目录
  //  dir: outDir,
  // 指定的输出的模块格式
  // format: 'es',
  // 指定输出模块的导出方式
  // exports:'auto',
  // sourcemap: true,
  // 指定生成的 iife 或 umd 格式的包名称
  // name: undefined,
  // // es2015 enables `generatedCode.symbols`
  // // - #764 add `Symbol.toStringTag` when build es module into cjs chunk
  // // - #1048 add `Symbol.toStringTag` for module default export
  // generatedCode: 'es2015',
  // 指定打包后的入口文件文件名
  // entryFileNames: path.posix.join(options.assetsDir, `[name]-[hash].js`),
  // 指定打包后非入口名称的文件名
  // chunkFileNames: path.posix.join(options.assetsDir, `[name]-[hash].js`),
  // 指定打包后的资源文件的文件名
  // assetFileNames: path.posix.join(options.assetsDir, `[name]-[hash].[ext]`),
  // 指定是否将动态导入的模块内联到输出文件中
  // inlineDynamicImports: false,
  // }]
  for (const output of outputs) {
    res.push(await bundle.generate(output))
  }
  return Array.isArray(outputs) ? res : res[0]
}

可以看到 vite 是通过指定 rollup 参数,用 rollup 进行打包。可以看到,在 build 之前对原始的 config 参数进行了一些处理,我们来看一下和 rollup 相关的配置逻辑:

function resolveConfig(config, command, defaultMode, defaultNodeEnv) {
  async function resolvePlugins() {
    const buildPlugins = await (await import('../build')).resolveBuildPlugin(config)

    return [
      // ... 省略部分不重要插件
      // 用于预构建的插件,生产模式和开发模式下使用了不同插件
      optimizedDepsBuildPlugin(config),
      isWatch ? ensureWatchPlugin() : null,
      // 仅生产模式有
      metadataPlugin(),
      watchPackageDataPlugin(),
      // 内置插件,用于在构建过程中处理模块路径别名
      // eg: 
      // resolve: {
      //   alias: {
      //     '@': '/src',
      //     'components': '/src/components',
      //     'utils': '/src/utils'
      //   }
      // }
      preAliasPlugin(),
      // @rollup/plugin-alias
      aliasPlugin(),
      modulePreloadPolyfillPlugin(config),
      // 内置插件,用于处理模块解析和导入的过程
      // - 解析相对路径,转换为实际文件路径
      resolvePlugin(),
      // 内置插件,处理 css
      cssPlugin(config),
      // 在开发过程中快速构建和转译 js 模块
      esbuildPlugin(config),
      // 内置插件,处理 json
      jsonPlugin(),
      // 内置插件,处理 asset
      assetPlugin(config),
      definePlugin(config),
      // 仅生产模式有
      buildHtmlPlugin(config),
      ...buildPlugins.pre,
      ...buildPlugins.post,

    ].filter(Boolean)
  }

  return {
    // ...
    plugins: await resolvePlugins()
  }
  
}

可以看到在生产环境和开发环境下,共用了一套内置插件,通过这种方式去抹平生产和开发环境的差异。 那么 vite 在开发环境下,是如何去支持这些 rollup 插件的呢?如何去实现相应的钩子函数的呢? 我们接下来看一下 pluginContainer 的实现。

function createPluginContainer() {
  const container = {
    async buildStart() {
       await handleHookPromise(
        // 并行执行插件中的 buildStart钩子
        hookParallel(
          'buildStart',
          (plugin) => new Context(plugin),
          () => [container.options],
        ),
      )
    }
  }

  async resolveId(rawId, importer) {
    let id = null
    // 按顺序执行插件中的 resolveId 钩子
    for (const plugin of getSortedPlugins('resolveId')) {
      const result = await handleHookPromise(
        plugin.resolveId.call(ctx, rawId, importer),
      )

      if (typeof result === 'string') {
        id = result
      } else {
        id = result.id
      }
    }

    return id
  }

  async load(id, options) {
    // 按顺序执行插件中的 load 钩子
    for (const plugin of getSortedPlugins('load')) {
      const result = await handleHookPromise(
          handler.call(ctx, id),
      )
      // 若 result 不为 null, 则直接返回值
      if (result !== null) {
         return result;
      }
    }
  }

  async tranform(code, id) {
    const ctx = new TransformContext(id, code)
    // 按顺序执行插件中的 transform 钩子
    for (const plugin of getSortedPlugins('transform')) {
      const result = await handleHookPromise(
          handler.call(ctx, code, id),
      )

      code = result.code;
    }

    return {
      code,
      // 如何生成的 sourcemap
      map: ctx._getCombinedSourcemap()
    }

  }

  async close() {
    // 等待所有异步任务结束
    // 触发所有的 buildEnd 钩子
    await hookParallel(
      'buildEnd',
      () => ctx,
      () => [],
    )
    // 触发所有的 closeBundle 钩子
    await hookParallel(
      'closeBundle',
      () => ctx,
      () => [],
    )
  }

  return container;
}

可以看到 vite 在 createPluginContainer 中 mock 了rollup 中的对插件钩子的调用,包括 buildStart, load, transform, buildEnd。vite自定义的插件并没有在此处进行实现。

接下来让我们看下 pluginContainer 在哪里被使用到了:

在每次浏览器发送请求过来时,都会经过中间件,并执行 transformRequest 函数,对模块内容进行处理。

function transformRequest(url, server) {
  // 去掉 url 的时间戳后缀
  url = removeTimestampQuery(url)
  const module = await server.moduleGraph.getModuleByUrl(url, ssr)
  // _____________resolveId_____________
  // 如果 module 存在,表示该路径是已处理过的,不再调用 resolveId 去处理
  const resolved = module
    ? undefined
    : (await pluginContainer.resolveId(url)) ?? undefined
  
  const id = module?.id ?? resolve?.id ?? url

  // _____________load_____________
  let code = null

  let map = null
  const loadResult = await pluginContainer.load(id)

  if (loadResult == null) {
    code = await fsp.readFile(file, 'utf-8')
    
    // // ??
    // map = (
    //       convertSourceMap.fromSource(code) ||
    //       (await convertSourceMap.fromMapFileSource(
    //         code,
    //         createConvertSourceMapReadMap(file),
    //       ))
    //     )?.toObject()

    // code = code.replace(convertSourceMap.mapFileCommentRegex, blankReplacer)
  }
  
  // _____________transform_____________
  const transformResult = await pluginContainer.transform(code, id)
  code = transformResult.code!
  map = transformResult.map

  return result
}

我们来看一下 vite 借助了哪些核心插件,对源文件进行何种处理?借此了解 vite 处理文件的过程和 rollup 的插件机制。

让我们先来看下 vite:esbuild 插件是如何工作的:

在开发模式下,vite 天然支持 .ts 文件,对 ts文件的处理是由 esbuild 完成的。但正如官网所述, vite 仅执行 ts 的转译工作,并不执行任何类型检查。而 vite 不把类型检查作为转换过程的一部分,也容易理解。因为转译可以在每个文件的基础上进行,与 vite 按需编译模式吻合。而类型检查就需要了解整个模块图。如果要支持类型检查,那么速度就会变慢。

esbuild 转换 ts 代码时,本身也并不会执行类型检查。

除此之外,对 tsconfig.json中 的配置项有哪些需要注意的地方?

isolatedModules 应设置为 true isolatedModules 用于控制模块间的独立性,当设置为 true 时,ts 编译器将对每个文件进行单独的编译,而不考虑其他文件中的类型信息。 这是因为 esbuild 并不支持一些需要类型信息的特性,如 const enum 和隐式类型导入。

import { transform } from 'esbuild'

function esbuildPlugin() {
  const transformOptions = {
    target: 'esnext',
    charset: 'utf8',
    minify: false,
    minifyIdentifiers: false,
    minifySyntax: false,
    minifyWhitespace: false,
    treeShaking: false,
    // keepNames is not needed when minify is disabled.
    // Also transforming multiple times with keepNames enabled breaks
    // tree-shaking. (#9164)
    keepNames: false,
  }

  return {
    name: 'vite:esbuild',
    async transform(code, id) {
      // 重点在于如何配置 esbuild
      // vite 会收集 tsconfig 中的以下配置传给 esbuild
      const meaningfulFields = [
        // 启用严格模式
        'alwaysStrict',
        // 启用实验性的装饰器语法
        'experimentalDecorators',
        // 设置为 remove, ts 会在生成的 js 代码中删除未使用的导入语句
        'importsNotUsedAsValues',
        // 执行 jsx 的转换方式
        'jsx',
        // 指定 jsx 元素的工厂函数名称
        'jsxFactory',
        // 指定 jsx片段的工厂函数名称
        'jsxFragmentFactory',
        // 指定用于导入 JSX 相关库的模块路径
        'jsxImportSource',
        // 生成 js 代码中保留导入语句的原始形式
        'preserveValueImports',
        // 指定 js 版本
        'target',
        // 使用 defineProperty 来处理字段的初始化
        'useDefineForClassFields',
        // 保留模块语法的原始形式,不进行转换
        'verbatimModuleSyntax',
      ]

      // 如果处理的是 ts 后缀的文件,则与 tsconfig.json 中的 compilerOptions 配置进行 merge
      const loadedTsconfig = await loadTsconfigJsonForFile(filename)
      const loadedCompilerOptions = loadedTsconfig.compilerOptions ?? {}

      for (const field of meaningfulFields) {
        if (field in loadedCompilerOptions) {
          compilerOptionsForFile[field] = loadedCompilerOptions[field]
        }
      }

      // 标准的 ECMAScript 的运行时行为
      if (
        compilerOptions.useDefineForClassFields === undefined &&
        compilerOptions.target === undefined
      ) {
        compilerOptions.useDefineForClassFields = false
      }

      const resolvedOptions = {
        sourcemap: true,
        // ensure source file name contains full query
        sourcefile: filename,
        // ts, js .etc
        loader: 'ts',
        compilerOptions
      }

      const result = await transform(code, resolvedOptions)

      return {
        code: result.code,
        map: result.map,
      }
    }
  }
}

可以看到 vite 在开发模式下,通过 esbuild 插件来编译单文件。