Vite是如何对我们写的(vite.config.x)进行解析?

1,456 阅读11分钟

Vite配置解析是怎么做的?

  • 本文为笔者学习 Vite 源码时的一些笔记,如有错误,请指出✊

  • 也就是 怎么解析 我们写的 vite.config.ts等的vite配置文件

  • 这一步是由 vite配置解析的resolveConfig函数来做的

  • export async function resolveConfig(
      inlineConfig: InlineConfig,
      command: 'build' | 'serve',
      defaultMode = 'development',
      defaultNodeEnv = 'development',
    ): Promise<ResolvedConfig> 
    

1. 加载配置文件

  • 大概思路是首先加载,解析配置文件,然后 合并命令行的配置

  • let { configFile } = config // config 是 resolveConfig 的参数 inlineConfig
    if (configFile !== false) {
        // 默认会走到这里 除非显示指定conFile为false
        const loadResult = await loadConfigFromFile(
          configEnv,
          configFile,
          config.root,
          config.logLevel,
        )
        if (loadResult) {
          // 解析配置后 应该与命令行的配置合并
          config = mergeConfig(loadResult.config, config)
          
          configFile = loadResult.path
          
          /* 
           * 因为配置文件代码可能会有第三方库的依赖,所以当第三方库依赖的代码更改时,Vite 			 * 可以通过 HMR 处理逻辑中记录的configFileDependencies检测到更改,再重启 				 *	DevServer ,来保证当前生效的配置永远是最新的 
           */
          configFileDependencies = loadResult.dependencies
        }
      }
    
  • loadConfigFromFile函数这里先不做详细介绍,他的主要作用是加载,解析配置文件

2. 解析用户插件

  • 这一步主要干了2件事:

根据apply参数,剔除不生效的插件, 给插件排好顺序

  • 有些插件只在开发阶段生效,或者说只在生产环境生效,我们可以通过 apply: 'serve' 或 'build' 来指定它们,同时也可以将apply配置为一个函数,来自定义插件生效的条件
  • 因为插件执行时机不一样,所以需要排序,顺便合并插件的配置
// user config may provide an alternative mode. But --mode has a higher priority
// 优先级为 命令行 > 配置文件声明 > 默认
  mode = inlineConfig.mode || config.mode || mode
  configEnv.mode = mode

  const filterPlugin = (p: Plugin) => {
    if (!p) {
      return false
    } else if (!p.apply) {
      // 没有显示声明apply,默认都执行
      return true
    } else if (typeof p.apply === 'function') {
      // 如果为函数的话 则执行这个函数 用函数来定义apply的话可以自定义插件生效时机
      return p.apply({ ...config, mode }, configEnv)
    } else {
      return p.apply === command
    }
  }
  
  ......
  
  // resolve plugins
  const rawUserPlugins = (
    (await asyncFlatten(config.plugins || [])) as Plugin[]
  ).filter(filterPlugin)

// 这里干了两件事 排序 + 过滤
  const [prePlugins, normalPlugins, postPlugins] =
    sortUserPlugins(rawUserPlugins) 
 

调用 插件的 config 钩子,进行配置合并

 // run config hooks
  // 这一步操作由runConfigHook这个函数内部实现
  const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
  config = await runConfigHook(config, userPlugins, configEnv)

解析root参数,alias参数

  • 如果在配置文件内没有指定的话,默认root解析的是 process.cwd()

  • 解析alias时,需要加上一些内置的 alias 规则,如@vite/env@vite/client这种直接重定向到 Vite 内部的模块

  •   // resolve root
      const resolvedRoot = normalizePath(
        config.root ? path.resolve(config.root) : process.cwd(),
      )
      
      // 内置alias规则
      const clientAlias = [
        {
          find: /^\/?@vite\/env/,
          replacement: path.posix.join(FS_PREFIX, normalizePath(ENV_ENTRY)),
        },
        {
          find: /^\/?@vite\/client/,
          replacement: path.posix.join(FS_PREFIX, normalizePath(CLIENT_ENTRY)),
        },
      ]
      
      // resolve alias with internal client alias
      const resolvedAlias = normalizeAlias(
        mergeAlias(clientAlias, config.resolve?.alias || []),
      )
    

3. 加载环境变量

  • 没有指定envDir的话,默认扫描process.cwd()目录下的.env文件

  • loadEnv函数会去扫描 process.env.env文件,解析出 env 对象,这个对象的属性最终会被挂载到import.meta.env 这个全局对象上

  •  // load .env files
      const envDir = config.envDir
        ? normalizePath(path.resolve(resolvedRoot, config.envDir))
        : resolvedRoot
      
      /*
       * loadEnv的具体步骤(详细代码在src/node/env.ts文件):
       * 1. 遍历 process.env 的属性,拿到指定前缀开头的属性(默认指定为VITE_),并挂载
       * 在 env 对象上
       * 2. 遍历 .env 文件,解析文件,然后往 env 对象挂载那些以指定前缀开头的属性。遍历的
       * 文件先后顺序如下:
       *	.env.${mode}.local
      	 *	.env.${mode}
      	 *  .env.local
      	 *	.env
       */
      const userEnv =
        inlineConfig.envFile !== false &&
        loadEnv(mode, envDir, resolveEnvPrefix(config))
    
  • 特殊情况: 如果在加载过程中遇到 NODE_ENV 属性,则挂到 process.env.VITE_USER_NODE_ENV,Vite 会优先通过这个属性来决定是否走生产环境的构建

  • 其他一些附带操作

    • /*
       * 解析资源公共路径 base
       * 关键在于 resolvebaseUrl 函数,里面的细节主要有:
       * 空字符或者 ./ 在开发阶段特殊处理,全部重写为/
       * .开头的路径,自动重写为 /
       * 以http(s)://开头的路径,在开发环境下重写为对应的 pathname
       * 确保路径开头和结尾都是/ 
      */
      // During dev, we ignore relative base and fallback to '/'
        // For the SSR build, relative base isn't possible by means
        // of import.meta.url.
        const resolvedBase = relativeBaseShortcut
          ? !isBuild || config.build?.ssr
            ? '/'
            : './'
          : resolveBaseUrl(config.base, isBuild, logger) ?? '/'
      
        // 解析生产环境的构建配置
        const resolvedBuildOptions = resolveBuildOptions(
          config.build,
          logger,
          resolvedRoot,
        )
        
        // 对cacheDir的解析,这个路径相对于在 Vite 预编译时写入依赖产物的路径
         // resolve cache directory
        const pkgDir = findNearestPackageData(resolvedRoot, packageCache)?.dir
        /*
        * 当显示指定cacheDir时,cache directory为配置文件中指定的位置
        * 否则 判断 pkgDir 是否存在 
        * 	存在的话 指定为 pkgDir下的 node_modules/.vite
        * 	不存在 则为 root 位置下的 .vite
        */
        const cacheDir = normalizePath(
          config.cacheDir
            ? path.resolve(resolvedRoot, config.cacheDir)
            : pkgDir
            ? path.join(pkgDir, `node_modules/.vite`)
            : path.join(resolvedRoot, `.vite`),
        )
        
        // 处理用户配置的assetsInclude,将其转换为一个过滤器函数:
        // Vite 在最终整理所有配置阶段,会将用户传入的 assetsInclude 和内置的规则合并
        // 这个配置决定是否让 Vite 将对应的后缀名视为静态资源文件(asset)来处理
          const assetsFilter =
          config.assetsInclude &&
          (!Array.isArray(config.assetsInclude) || config.assetsInclude.length)
            ? createFilter(config.assetsInclude)
            : () => false
       
          // 最终所有配置会被合并为这个对象
          const resolvedConfig: ResolvedConfig = {
            ......
           
                assetsInclude(file: string) {
            			return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file)
          			},
            ......
          }
        
      

4. 定义路径解析器工厂

主流程

  • 这里所说的路径解析器,是指调用插件容器进行路径解析的函数

  • // create an internal resolver to be used in special scenarios, e.g.
      // optimizer & handling css @imports
      const createResolver: ResolvedConfig['createResolver'] = (options) => {
        let aliasContainer: PluginContainer | undefined
        let resolverContainer: PluginContainer | undefined
        // 返回了一个函数 这个函数就是路径解析器
        return async (id, importer, aliasOnly, ssr) => {
          let container: PluginContainer
          if (aliasOnly) {
            // 新建 aliasPlugin
            container =
              aliasContainer ||
              (aliasContainer = await createPluginContainer({
                ...resolved,
                plugins: [aliasPlugin({ entries: resolved.resolve.alias })],
              }))
          } else {
              // 新建 resolvePlugin
            container =
              resolverContainer ||
              (resolverContainer = await createPluginContainer({
                ...resolved,
                plugins: [
                  aliasPlugin({ entries: resolved.resolve.alias }),
                  resolvePlugin({
                    ...resolved.resolve,
                    root: resolvedRoot,
                    isProduction,
                    isBuild: command === 'build',
                    ssrConfig: resolved.ssr,
                    asSrc: true,
                    preferRelative: false,
                    tryIndex: true,
                    ...options,
                    idOnly: true,
                  }),
                ],
              }))
          }
          return (
            await container.resolveId(id, importer, {
              ssr,
              scan: options?.scan,
            })
          )?.id
        }
      }
    // 这里有 aliasContainer 和 resolverContainer 两个工具对象,它们都含有 resolveId 这个专门解析路径的方法,可以被 Vite 调用来获取解析结果
    // container 的类型是 PluginContainer 这个我们后续在插件机制那块会讲到
    
  • 这个解析器 未来会用于依赖预构建过程

    const resolve = config.createResolver()
    // 调用以拿到 react 路径
    rseolve('react', undefined, undefined, false)
    

解析 public 参数

// 顺带解析了 public 参数 -> 静态资源目录
const { publicDir } = config
  const resolvedPublicDir =
    publicDir !== false && publicDir !== ''
      ? path.resolve(
          resolvedRoot,
          typeof publicDir === 'string' ? publicDir : 'public',
        )
      : ''

最终阶段

  • 对上面所有解析结果进行合并

  • // 上述的解析 只列举了几个 详细的所有配置解析 可以自行查看源码
    const resolvedConfig: ResolvedConfig = {
        configFile: configFile ? normalizePath(configFile) : undefined,
        configFileDependencies: configFileDependencies.map((name) =>
          normalizePath(path.resolve(name)),
        ),
        inlineConfig,
        root: resolvedRoot,
        base: resolvedBase.endsWith('/') ? resolvedBase : resolvedBase + '/',
        rawBase: resolvedBase,
        resolve: resolveOptions,
        publicDir: resolvedPublicDir,
        cacheDir,
        command,
        mode,
        ssr,
        isWorker: false,
        mainConfig: null,
        isProduction,
        plugins: userPlugins,
        esbuild:
          config.esbuild === false
            ? false
            : {
                jsxDev: !isProduction,
                ...config.esbuild,
              },
        server,
        build: resolvedBuildOptions,
        preview: resolvePreviewOptions(config.preview, server),
        envDir,
        env: {
          ...userEnv,
          BASE_URL,
          MODE: mode,
          DEV: !isProduction,
          PROD: isProduction,
        },
        assetsInclude(file: string) {
          return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file)
        },
        logger,
        packageCache,
        createResolver,
        optimizeDeps: {
          disabled: 'build',
          ...optimizeDeps,
          esbuildOptions: {
            preserveSymlinks: resolveOptions.preserveSymlinks,
            ...optimizeDeps.esbuildOptions,
          },
        },
        worker: resolvedWorkerOptions,
        appType: config.appType ?? (middlewareMode === 'ssr' ? 'custom' : 'spa'),
        experimental: {
          importGlobRestoreExtension: false,
          hmrPartialAccept: false,
          ...config.experimental,
        },
        getSortedPlugins: undefined!,
        getSortedPluginHooks: undefined!,
      }
    
      const resolved: ResolvedConfig = {
        ...config,
        ...resolvedConfig,
      }
    

5. 生成插件流水线

  • // 先生成完整插件列表传给resolve.plugins
    // 细节都在  resolvePlugins 函数内部 后续会详细研究这个函数
    ;(resolved.plugins as Plugin[]) = await resolvePlugins(
        resolved,
        prePlugins,
        normalPlugins,
        postPlugins,
      )
    
    ......
    
      // call configResolved hooks
    // 调用每个插件的 configResolved 钩子函数
      await Promise.all([
        ...resolved
          .getSortedPluginHooks('configResolved')
          .map((hook) => hook(resolved)),
        ...resolvedConfig.worker
          .getSortedPluginHooks('configResolved')
          .map((hook) => hook(workerResolved)),
      ])
    
    ......
    
  • 最后 这个 resolvedConfig函数会 返回 最终的 配置结果 -> resolved

加载配置文件中 的关键函数 loadConfigFromFile

  • // 定义部分 接受四个参数
    export async function loadConfigFromFile(
      configEnv: ConfigEnv,
      configFile?: string,
      configRoot: string = process.cwd(),
      logLevel?: LogLevel,
    ): Promise<{
      path: string
      config: UserConfig
      dependencies: string[]
    } | null> 
    

主要思路

  • 既然是 加载配置文件,那么就需要处理 不同的配置文件类型,主要有以下四种

    • TS + ESM
    • TS + CJS
    • JS + ESM
    • JS + CJS
  • 所以,要做的就首先识别 配置文件的类型,然后根据不同的类型,进行解析

1. 寻找配置文件路径

  • // node/contants.ts
    export const DEFAULT_CONFIG_FILES = [
      'vite.config.js',
      'vite.config.mjs',
      'vite.config.ts',
      'vite.config.cjs',
      'vite.config.mts',
      'vite.config.cts',
    ]
    
    // node/config.ts
    let resolvedPath: string | undefined
    // configfile 就是 传入的参数 也就是 在命令行启动 vite 的时候指定的参数
      if (configFile) {
        // explicit config path is always resolved from cwd
        // configFile 存在的话 则用这个路径来 resolve
        resolvedPath = path.resolve(configFile)
      } else {
        // implicit config file loaded from inline root (if present)
        // otherwise from cwd
        // 否则的话 从默认的 跟路径 process.cwd() 来resolve
        for (const filename of DEFAULT_CONFIG_FILES) {
          const filePath = path.resolve(configRoot, filename)
          if (!fs.existsSync(filePath)) continue
    
          resolvedPath = filePath
          break
        }
      }
    
    // 这不到 则返回 null ,同时,给出提示
      if (!resolvedPath) {
        debug?.('no config file found.')
        return null
      }
    

2. 识别配置文件的类别

  • let isESM = false
    // vite 首先会 检查 这个跟路径的命名,是否包含 mjs , cjs 的后缀,
    // 如果有的话,会修改isESM 的标识
      if (/\.m[jt]s$/.test(resolvedPath)) {
        isESM = true
      } else if (/\.c[jt]s$/.test(resolvedPath)) {
        isESM = false
      } else {
        // check package.json for type: "module" and set `isESM` to true
        // 没有的话 会查看 package.json 文件,
        // 如果有 type: "module"则打上 isESM 的标识
        try {
          const pkg = lookupFile(configRoot, ['package.json'])
          isESM =
            !!pkg && JSON.parse(fs.readFileSync(pkg, 'utf-8')).type === 'module'
        } catch (e) {}
      }
    

3. 利用 esbuild 打包,解析 配置文件

  • try {
      // 首先 用 esbuild 将配置文件 编译,打包为为 js 文件 (因为 可能为 ts 格式 所以需要先转一下)
        const bundled = await bundleConfigFile(resolvedPath, isESM)
        // 解析 打包后的配置文件 这个函数 详细信息在下面,
        // 主要就是 分为 esm cjs 格式去做不同的解析
        const userConfig = await loadConfigFromBundledFile(
          resolvedPath,
          bundled.code,
          isESM,
        )
        debug?.(`bundled config file loaded in ${getTime()}`)
    
      // 读取 配置文件后, 处理 是函数的情况 
        const config = await (typeof userConfig === 'function'
          ? userConfig(configEnv)
          : userConfig)
        if (!isObject(config)) {
          throw new Error(`config must export or return an object.`)
        }
      // 接下来返回最终的配置信息
        return {
          path: normalizePath(resolvedPath),
          config,
          // esbuild 打包过程中收集的依赖信息
          dependencies: bundled.dependencies,
        }
      } catch (e) {
        createLogger(logLevel).error(
          colors.red(`failed to load config from ${resolvedPath}`),
          { error: e },
        )
        throw e
      }
    
    ......
    // loadConfigFromBundledFile 函数
    
    // 创建 require 函数 用于 下面的 cjs 格式配置文件处理
    // 这个 createRequire 方法 来自于 node:module
    const _require = createRequire(import.meta.url)
    async function loadConfigFromBundledFile(
      fileName: string,
      bundledCode: string,
      isESM: boolean,
    ): Promise<UserConfigExport> {
      // for esm, before we can register loaders without requiring users to run node
      // with --experimental-loader themselves, we have to do a hack here:
      // write it to disk, load it with native Node ESM, then delete the file.
      // 如果是 ESM格式,Vite 会将编译后的 js 代码写入临时文件,通过 Node 原生 ESM Import 来读取这个临时的内容,以获取到配置内容,再直接删掉临时文件
        if (isESM) {
        // import 路径结果要加上时间戳 query,是因为
        // 为了让 dev server 重启后仍然读取最新的配置,避免缓存
        const fileBase = `${fileName}.timestamp-${Date.now()}-${Math.random()
          .toString(16)
          .slice(2)}`
        const fileNameTmp = `${fileBase}.mjs`
        const fileUrl = `${pathToFileURL(fileBase)}.mjs`
        await fsp.writeFile(fileNameTmp, bundledCode)
        try {
          // 通过 Node 原生 ESM Import 来读取这个临时的内容,以获取到配置内容
          return (await dynamicImport(fileUrl)).default
        } finally {
          // 最后直接 删掉临时文件
          fs.unlink(fileNameTmp, () => {}) // Ignore errors
        }
      }
        
      // for cjs, we can register a custom loader via `_require.extensions`
        // 如果是 cjs 格式,那么主要的思路是
        // 通过拦截原生 require.extensions 的加载函数来实现对 bundle 后配置代码的加载
      else {
        // 默认加载器
        const extension = path.extname(fileName)
        // We don't use fsp.realpath() here because it has the same behaviour as
        // fs.realpath.native. On some Windows systems, it returns uppercase volume
        // letters (e.g. "C:\") while the Node.js loader uses lowercase volume letters.
        // See https://github.com/vitejs/vite/issues/12923
        
        // 拿到 promisifyed 过的真实的文件名字
        const realFileName = await promisifiedRealpath(fileName)
        // 默认 拦截原生 require 对于 js 文件的加载
        const loaderExt = extension in _require.extensions ? extension : '.js'
        
        // 先保存 一份 原来的 加载器 -> loader
        const defaultLoader = _require.extensions[loaderExt]!
              
        // 这里 进行 拦截,重写
        _require.extensions[loaderExt] = (module: NodeModule, filename: string) => {
          // 如果加载的文件 是 该配置文件 则 调用 module._compile 方法进行编译
          if (filename === realFileName) {
            ;(module as NodeModuleWithCompile)._compile(bundledCode, filename)
          } else {
            defaultLoader(module, filename)
          }
        }
        // clear cache in case of server restart
        delete _require.cache[_require.resolve(fileName)]
        // 编译后 再 进行一次手动的 require 即可拿到配置对象
        const raw = _require(fileName)
        // 恢复原生的加载方法
        _require.extensions[loaderExt] = defaultLoader
        return raw.__esModule ? raw.default : raw
      }
    }
    
    
    
    
    // node/utils.ts
    // 这里 注释已经给的很明显了 在非 jest 下 dynamicImport 返回的是 
    // new Function('file', 'return import(file)')
    
    // @ts-expect-error jest only exists when running Jest
    export const usingDynamicImport = typeof jest === 'undefined'
    
    /**
     * Dynamically import files. It will make sure it's not being compiled away by TS/Rollup.
     *
     * As a temporary workaround for Jest's lack of stable ESM support, we fallback to require
     * if we're in a Jest environment.
     * See https://github.com/vitejs/vite/pull/5197#issuecomment-938054077
     *
     * @param file File path to import.
     */
    // 为什么不直接 import, 而是要用 new Function 包裹?
    // 这是为了避免打包工具处理这段代码,比如 Rollup 和 TSC,类似的手段还有 eval
    export const dynamicImport = usingDynamicImport
      ? new Function('file', 'return import(file)')
      : _require
    
  • 在处理 ESM类型的配置文件时,采用的是将bundle(打包编译)后的 js 代码写入临时文件,通过 Node 原生 ESM Import 来读取这个临时的内容,以获取到配置内容,再直接删掉临时文件

    • 这种先编译配置文件,再将产物写入临时目录,最后加载临时目录产物的做法,也是 AOT (Ahead Of Time)编译技术的一种具体实现
  • 在处理 CJS类型的配置文件时, 采用的是拦截原生 require.extensions 的加载函数来实现对 bundle(打包编译) 后的 js 代码的加载

    • 这种运行时加载 JS配置的方式,也叫做 JIT(即时编译),这种方式和 AOT 最大的区别在于不会将内存中计算出来的 js 代码写入磁盘再加载,而是通过拦截 Node.js 原生 require.extension 方法实现即时加载

总结

  • 主要梳理了 Vite 配置解析的整体流程加载配置文件的方法

  • Vite 配置文件解析的逻辑由 resolveConfig 函数统一实现

    • 经历了加载配置文件、解析用户插件、加载环境变量、创建路径解析器工厂和生成插件流水线这几个主要的流程
  • 加载配置文件的过程中,Vite 需要处理四种类型的配置文件((TS, JS)-(ESM, CJS)

    • 首先先 用 esbuildTS代码 打包编译为 JS代码
    • 其中对于 ESMCJS 两种格式文件,分别采用了AOTJIT两种编译技术实现了配置加载
  • 学习链接