咱们晓得,Vite 构建环境分为开发环境和生产环境,不同环境会有不同的构建策略,但不论是哪种环境,Vite 都会首先解析用户配置。那接下来,我就与你剖析配置解析过程中 Vite 到底做了什么?即Vite是如何加载配置文件的。

一、流程梳理

咱们先来梳理整体的流程,Vite 中的配置解析由 resolveConfig 函数来实现,你能够对照源码一起学习。

1.1 加载配置文件

进行一些必要的变量申明后,咱们进入到解析配置逻辑中,配置文件的源码如下:

// 这里的 config 是命令行指定的配置,如 vite --configFile=xxxlet { configFile } = configif (configFile !== false) {  // 默认都会走到上面加载配置文件的逻辑,除非你手动指定 configFile 为 false  const loadResult = await loadConfigFromFile(    configEnv,    configFile,    config.root,    config.logLevel  )  if (loadResult) {    // 解析配置文件的内容后,和命令行配置合并    config = mergeConfig(loadResult.config, config)    configFile = loadResult.path    configFileDependencies = loadResult.dependencies  }}

第一步是解析配置文件的内容,而后与命令行配置合并。值得注意的是,前面有一个记录configFileDependencies的操作。因为配置文件代码可能会有第三方库的依赖,所以当第三方库依赖的代码更改时,Vite 能够通过 HMR 解决逻辑中记录的configFileDependencies检测到更改,再重启 DevServer ,来保障以后失效的配置永远是最新的。

1.2 解析用户插件

第二个重点环节是 解析用户插件。首先,咱们通过 apply 参数 过滤出须要失效的用户插件。为什么这么做呢?因为有些插件只在开发阶段失效,或者说只在生产环境失效,咱们能够通过 apply: 'serve' 或 'build' 来指定它们,同时也能够将apply配置为一个函数,来自定义插件失效的条件。解析代码如下:

// resolve pluginsconst rawUserPlugins = (config.plugins || []).flat().filter((p) => {  if (!p) {    return false  } else if (!p.apply) {    return true  } else if (typeof p.apply === 'function') {     // apply 为一个函数的状况    return p.apply({ ...config, mode }, configEnv)  } else {    return p.apply === command  }}) as Plugin[]// 对用户插件进行排序const [prePlugins, normalPlugins, postPlugins] =  sortUserPlugins(rawUserPlugins)

接着,Vite 会拿到这些过滤且排序实现的插件,顺次调用插件 config 钩子,进行配置合并。

// run config hooksconst userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]for (const p of userPlugins) {  if (p.config) {    const res = await p.config(config, configEnv)    if (res) {      // mergeConfig 为具体的配置合并函数,大家有趣味能够浏览一下实现      config = mergeConfig(config, res)    }  }}

而后,解析我的项目的根目录即 root 参数,默认取 process.cwd()的后果。

// resolve rootconst resolvedRoot = normalizePath(  config.root ? path.resolve(config.root) : process.cwd())

紧接着解决 alias ,这里须要加上一些内置的 alias 规定,如@vite/env、@vite/client这种间接重定向到 Vite 外部的模块。

// resolve alias with internal client aliasconst resolvedAlias = mergeAlias(  clientAlias,  config.resolve?.alias || config.alias || [])const resolveOptions: ResolvedConfig['resolve'] = {  dedupe: config.dedupe,  ...config.resolve,  alias: resolvedAlias}

1.3 加载环境变量

加载环境变量的实现代码如下:

// load .env filesconst envDir = config.envDir  ? normalizePath(path.resolve(resolvedRoot, config.envDir))  : resolvedRootconst userEnv =  inlineConfig.envFile !== false &&  loadEnv(mode, envDir, resolveEnvPrefix(config))

loadEnv 其实就是扫描 process.env 与 .env文件,解析出 env 对象,值得注意的是,这个对象的属性最终会被挂载到import.meta.env 这个全局对象上。解析 env 对象的实现思路如下:

  • 遍历 process.env 的属性,拿到指定前缀结尾的属性(默认指定为VITE_),并挂载 env 对象上
  • 遍历 .env 文件,解析文件,而后往 env 对象挂载那些以指定前缀结尾的属性。遍历的文件先后顺序如下(上面的 mode 开发阶段为 development,生产环境为production)

非凡状况下,如果中途遇到 NODE_ENV 属性,则挂到 process.env.VITE_USER_NODE_ENV,Vite 会优先通过这个属性来决定是否走生产环境的构建。

接下来,是对资源公共门路即base URL的解决,逻辑集中在 resolveBaseUrl 函数当中:

// 解析 base urlconst BASE_URL = resolveBaseUrl(config.base, command === 'build', logger)// 解析生产环境构建配置const resolvedBuildOptions = resolveBuildOptions(config.build)

resolveBaseUrl外面有这些解决规定须要留神:

  • 空字符或者 ./ 在开发阶段非凡解决,全副重写为/
  • .结尾的门路,主动重写为 /
  • 以http(s)://结尾的门路,在开发环境下重写为对应的 pathname
  • 确保门路结尾和结尾都是/

当然,还有对cacheDir的解析,这个门路绝对于在 Vite 预编译时写入依赖产物的门路:

// resolve cache directoryconst pkgPath = lookupFile(resolvedRoot, [`package.json`], true /* pathOnly */)// 默认为 node_module/.viteconst cacheDir = config.cacheDir  ? path.resolve(resolvedRoot, config.cacheDir)  : pkgPath && path.join(path.dirname(pkgPath), `node_modules/.vite`)

紧接着解决用户配置的assetsInclude,将其转换为一个过滤器函数:

const assetsFilter = config.assetsInclude  ? createFilter(config.assetsInclude)  : () => false

而后,Vite 前面会将用户传入的 assetsInclude 和内置的规定合并:

assetsInclude(file: string) {  return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file)}

这个配置决定是否让 Vite 将对应的后缀名视为动态资源文件(asset)来解决。

1.4 门路解析器

这里所说的门路解析器,是指调用插件容器进行门路解析的函数,代码构造如下所示:

const createResolver: ResolvedConfig['createResolver'] = (options) => {  let aliasContainer: PluginContainer | undefined  let resolverContainer: PluginContainer | undefined  // 返回的函数能够了解为一个解析器  return async (id, importer, aliasOnly, ssr) => {    let container: PluginContainer    if (aliasOnly) {      container =        aliasContainer ||        // 新建 aliasContainer    } else {      container =        resolverContainer ||        // 新建 resolveContainer    }    return (await container.resolveId(id, importer, undefined, ssr))?.id  }}

并且,这个解析器将来会在依赖预构建的时候用上,具体用法如下:

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

这里有aliasContainer和resolverContainer两个工具对象,它们都含有resolveId这个专门解析门路的办法,能够被 Vite 调用来获取解析后果,实质都是PluginContainer。

接着,会顺便解决一个 public 目录,也就是 Vite 作为动态资源服务的目录:

const { publicDir } = configconst resolvedPublicDir =  publicDir !== false && publicDir !== ''    ? path.resolve(        resolvedRoot,        typeof publicDir === 'string' ? publicDir : 'public'      )    : ''

至此,配置曾经基本上解析实现,最初通过 resolved 对象来整顿一下:

const resolved: ResolvedConfig = {  ...config,  configFile: configFile ? normalizePath(configFile) : undefined,  configFileDependencies,  inlineConfig,  root: resolvedRoot,  base: BASE_URL  ... //其余配置}

1.5 生成插件流水线

生成插件流水线的代码如下:

;(resolved.plugins as Plugin[]) = await resolvePlugins(  resolved,  prePlugins,  normalPlugins,  postPlugins)// call configResolved hooksawait Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)))

学生成残缺插件列表传给resolve.plugins,而后调用每个插件的 configResolved 钩子函数。其中 resolvePlugins 外部细节比拟多,插件数量比拟宏大,咱们临时不去深究具体实现,编译流水线这一大节再来具体介绍。

至此,所有外围配置都生成结束。不过,前面 Vite 还会解决一些边界状况,在用户配置不合理的时候,给用户对应的提醒。比方:用户间接应用alias时,Vite 会提醒应用resolve.alias。

最初,resolveConfig 函数会返回 resolved 对象,也就是最初的配置汇合,那么配置解析服务到底也就完结了。

二、加载配置文件详解

首先,咱们来看一下加载配置文件(loadConfigFromFile)的实现:

const loadResult = await loadConfigFromFile(/*省略传参*/)

这里的逻辑略微有点简单,很难梳理分明,所以咱们无妨借助方才梳理的配置解析流程,深刻loadConfigFromFile 的细节中,钻研下 Vite 对于配置文件加载的实现思路。

接下来,咱们来剖析下须要解决的配置文件类型,依据文件后缀和模块格局能够分为上面这几类:

  • TS + ESM 格局
  • TS + CommonJS 格局
  • JS + ESM 格局
  • JS + CommonJS 格局

2.1 辨认配置文件的类别

首先,Vite 会查看我的项目的 package.json文件,如果有type: "module"则打上 isESM 的标识:

try {  const pkg = lookupFile(configRoot, ['package.json'])  if (pkg && JSON.parse(pkg).type === 'module') {    isMjs = true  }} catch (e) {  }

而后,Vite 会寻找配置文件门路,代码简化后如下:

let isTS = falselet isESM = falselet dependencies: string[] = []// 如果命令行有指定配置文件门路if (configFile) {  resolvedPath = path.resolve(configFile)  // 依据后缀判断是否为 ts 或者 esm,打上 flag  isTS = configFile.endsWith('.ts')  if (configFile.endsWith('.mjs')) {      isESM = true    }} else {  // 从我的项目根目录寻找配置文件门路,寻找程序:  // - vite.config.js  // - vite.config.mjs  // - vite.config.ts  // - vite.config.cjs  const jsconfigFile = path.resolve(configRoot, 'vite.config.js')  if (fs.existsSync(jsconfigFile)) {    resolvedPath = jsconfigFile  }  if (!resolvedPath) {    const mjsconfigFile = path.resolve(configRoot, 'vite.config.mjs')    if (fs.existsSync(mjsconfigFile)) {      resolvedPath = mjsconfigFile      isESM = true    }  }  if (!resolvedPath) {    const tsconfigFile = path.resolve(configRoot, 'vite.config.ts')    if (fs.existsSync(tsconfigFile)) {      resolvedPath = tsconfigFile      isTS = true    }  }    if (!resolvedPath) {    const cjsConfigFile = path.resolve(configRoot, 'vite.config.cjs')    if (fs.existsSync(cjsConfigFile)) {      resolvedPath = cjsConfigFile      isESM = false    }  }}

在寻找门路的同时, Vite 也会给以后配置文件打上isESM和isTS的标识,不便后续的解析。

2.2 依据类别解析配置

2.2.1 ESM 格局

对于 ESM 格局配置的解决代码如下:

let userConfig: UserConfigExport | undefinedif (isESM) {  const fileUrl = require('url').pathToFileURL(resolvedPath)  // 首先对代码进行打包  const bundled = await bundleConfigFile(resolvedPath, true)  dependencies = bundled.dependencies  // TS + ESM  if (isTS) {    fs.writeFileSync(resolvedPath + '.js', bundled.code)    userConfig = (await dynamicImport(`${fileUrl}.js?t=${Date.now()}`))      .default    fs.unlinkSync(resolvedPath + '.js')    debug(`TS + native esm config loaded in ${getTime()}`, fileUrl)  }   //  JS + ESM  else {    userConfig = (await dynamicImport(`${fileUrl}?t=${Date.now()}`)).default    debug(`native esm config loaded in ${getTime()}`, fileUrl)  }}

能够看到,首先通过 Esbuild 将配置文件编译打包成 js 代码:

const bundled = await bundleConfigFile(resolvedPath, true)// 记录依赖dependencies = bundled.dependencies

对于 TS 配置文件来说,Vite 会将编译后的 js 代码写入临时文件,通过 Node 原生 ESM Import 来读取这个长期的内容,以获取到配置内容,再间接删掉临时文件:

fs.writeFileSync(resolvedPath + '.js', bundled.code)userConfig = (await dynamicImport(`${fileUrl}.js?t=${Date.now()}`)).defaultfs.unlinkSync(resolvedPath + '.js')

以上这种先编译配置文件,再将产物写入长期目录,最初加载长期目录产物的做法,也是 AOT (Ahead Of Time)编译技术的一种具体实现。

而对于 JS 配置文件来说,Vite 会间接通过 Node 原生 ESM Import 来读取,也是应用 dynamicImport 函数的逻辑,dynamicImport 的实现如下:

export const dynamicImport = new Function('file', 'return import(file)')

你可能会问,为什么要用 new Function 包裹?这是为了防止打包工具解决这段代码,比方 Rollup 和 TSC,相似的伎俩还有 eval。你可能还会问,为什么 import 门路后果要加上工夫戳 query?这其实是为了让 dev server 重启后依然读取最新的配置,防止缓存。

2.2.2 CommonJS 格局

对于 CommonJS 格局的配置文件,Vite 集中进行了解析:

// 对于 js/ts 均失效// 应用 esbuild 将配置文件编译成 commonjs 格局的 bundle 文件const bundled = await bundleConfigFile(resolvedPath)dependencies = bundled.dependencies// 加载编译后的 bundle 代码userConfig = await loadConfigFromBundledFile(resolvedPath, bundled.code)

bundleConfigFile函数的次要性能是通过 Esbuild 将配置文件打包,拿到打包后的 bundle 代码以及配置文件的依赖(dependencies)。而接下来的事件就是思考如何加载 bundle 代码了,这也是loadConfigFromBundledFile 要做的事件。

async function loadConfigFromBundledFile(  fileName: string,  bundledCode: string): Promise<UserConfig> {  const extension = path.extname(fileName)  const defaultLoader = require.extensions[extension]!  require.extensions[extension] = (module: NodeModule, filename: string) => {    if (filename === fileName) {      ;(module as NodeModuleWithCompile)._compile(bundledCode, filename)    } else {      defaultLoader(module, filename)    }  }  // 革除 require 缓存  delete require.cache[require.resolve(fileName)]  const raw = require(fileName)  const config = raw.__esModule ? raw.default : raw  require.extensions[extension] = defaultLoader  return config}

loadConfigFromBundledFile大体实现的是通过拦挡原生 require.extensions 的加载函数来实现对 bundle 后配置代码的加载,代码如下:

// 默认加载器const defaultLoader = require.extensions[extension]!// 拦挡原生 require 对于`.js`或者`.ts`的加载require.extensions[extension] = (module: NodeModule, filename: string) => {  // 针对 vite 配置文件的加载非凡解决  if (filename === fileName) {    ;(module as NodeModuleWithCompile)._compile(bundledCode, filename)  } else {    defaultLoader(module, filename)  }}

而原生 require 对于 js 文件的加载代码如下所示。

Module._extensions['.js'] = function (module, filename) {  var content = fs.readFileSync(filename, 'utf8')  module._compile(stripBOM(content), filename)}

事实上,Node.js 外部也是先读取文件内容,而后编译该模块。当代码中调用module._compile 相当于手动编译一个模块,该办法在 Node 外部的实现如下:

Module.prototype._compile = function (content, filename) {  var self = this  var args = [self.exports, require, self, filename, dirname]  return compiledWrapper.apply(self.exports, args)}

在调用完 module._compile 编译完配置代码后,进行一次手动的 require,即可拿到配置对象:

const raw = require(fileName)const config = raw.__esModule ? raw.default : raw// 复原原生的加载办法require.extensions[extension] = defaultLoader// 返回配置return config

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

至此,配置文件的内容曾经读取实现,等后处理实现再返回即可:

// 解决是函数的状况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}

三、总结

上面咱们来总结一下Vite 配置解析的整体流程和加载配置文件的办法:

首先,Vite 配置文件解析的逻辑由 resolveConfig 函数对立实现,其中经验了加载配置文件、解析用户插件、加载环境变量、创立门路解析器工厂和生成插件流水线这几个次要的流程。

其次,在加载配置文件的过程中,Vite 须要解决四种类型的配置文件,其中对于 ESM 和 CommonJS 两种格局的 TS 文件,别离采纳了AOT和JIT两种编译技术实现了配置加载。