In-depth analysis of Vite configuration files

We know that the Vite build environment is divided into development environment and production environment. Different environments will have different build strategies, but no matter which environment it is, Vite will first parse the user configuration. Next, I will analyze with you what exactly Vite does during the configuration parsing process? That is how Vite loads the configuration file.

1. Process overview

Let’s sort out the overall process first. Configuration parsing in Vite is implemented by the resolveConfig function. You can learn together by referring to the source code.

1.1 Load configuration file

After making some necessary variable declarations, we enter the parsing configuration logic. The source code of the configuration file is as follows:

// The config here is the configuration specified on the command line, such as vite --configFile=xxx
let { configFile } = config
if (configFile !== false ) {
   // By default, the logic of loading the configuration file will be followed, unless you manually specify configFile as false 
  const loadResult = await loadConfigFromFile (
    configEnv,
    configFile,
    config.root,
    config.logLevel
  )
  if (loadResult) {
     // After parsing the contents of the configuration file, merge it with the command line configuration 
    config = mergeConfig (loadResult.config, config)
    configFile = loadResult.path
    configFileDependencies = loadResult.dependencies
  }
}

The first step is to parse the contents of the configuration file and then merge it with the command line configuration. It is worth noting that there is an operation to record configFileDependencies later. Because the configuration file code may depend on a third-party library, when the code that the third-party library depends on changes, Vite can detect the change through the configFileDependencies recorded in the HMR processing logic, and then restart the DevServer to ensure that the currently effective configuration is always Newest.

1.2 Parse user plug-ins

The second key link is parsing user plug-ins. First, we filter out the user plug-ins that need to take effect through the apply parameter. Why do this? Because some plug-ins only take effect in the development stage, or only in the production environment, we can specify them through apply: ‘serve’ or ‘build’, and we can also configure apply as a function to customize the conditions for the plug-in to take effect. The parsing code is as follows:

// resolve plugins 
const rawUserPlugins = ( config.plugins || [] ). flat () . filter ( (p ) => {
   if (!p) {
     return  false 
  } else  if (!p.apply) {
     return  true 
  } else  if ( typeof p.apply === 'function' ) {
      // When apply is a function 
    return p.apply({ ...config, mode }, configEnv)
  } else {
     return p.apply === command
  }
}) as Plugin[]
// Sort user plugins
const [prePlugins, normalPlugins, postPlugins] =
  sortUserPlugins(rawUserPlugins)

Then, Vite will get these filtered and sorted plug-ins, and call the plug-in config hook in turn to merge the configurations.

// run config hooks 
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
 for ( const p of userPlugins) {
   if (p.config) {
     const res = await p. config (config, configEnv )
     if (res) {
       // mergeConfig is a specific configuration merge function. If you are interested, you can read about the implementation 
      config = mergeConfig (config, res)
    }
  }
}

Then, the root directory of the project, that is, the root parameter, is parsed, and the result of process.cwd() is taken by default.

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

Next, deal with alias. Here you need to add some built-in alias rules, such as @vite/env, @vite/client, which directly redirect to modules inside Vite.

// resolve alias with internal client alias 
const resolvedAlias ​​= mergeAlias(
  clientAlias,
  config.resolve? .alias || config.alias || []
)


const resolveOptions: ResolvedConfig[ 'resolve' ] = {
  dedupe: config.dedupe,
  ...config.resolve,
  alias : resolvedAlias
}

1.3 Load environment variables

The implementation code for loading environment variables is as follows:

// load .env files
const envDir = config.envDir
  ? normalize Path( path . resolve ( resolvedRoot , config . envDir ) )
  : resolvedRoot
const userEnv =
  inlineConfig.envFile !== false &&
   load Env( mode , envDir , resolveEnvPrefix ( config ) )

loadEnv actually scans process.env and .env files and parses out the env object. It is worth noting that the properties of this object will eventually be mounted to the global object import.meta.env. The implementation idea of ​​parsing env object is as follows:

  • Traverse the properties of process.env, get the properties starting with the specified prefix (the default is VITE_), and mount the env object
  • Traverse the .env file, parse the file, and then mount the properties starting with the specified prefix to the env object. The order of traversed files is as follows (the development stage of the mode below is development, and the production environment is production)

Under special circumstances, if the NODE_ENV attribute is encountered midway, it will be linked to process.env.VITE_USER_NODE_ENV. Vite will use this attribute first to decide whether to build the production environment.

Next, there is the processing of the public path of the resource, that is, the base URL. The logic is concentrated in the resolveBaseUrl function:

//Resolve base url 
const BASE_URL = resolve BaseUrl( config . base , command  == = ' build ', logger ) 
//Resolve production environment build configuration 
const resolvedBuildOptions = resolve BuildOptions( config . build )

There are these processing rules in resolveBaseUrl that you need to pay attention to:

  • Null characters or ./ are treated specially during the development stage and are all rewritten to /
  • Paths starting with . are automatically rewritten as /
  • Paths starting with http(s):// are rewritten to the corresponding pathname in the development environment.
  • Make sure the path starts and ends with /

Of course, there is also the analysis of cacheDir. This path is relative to the path where dependent products are written when Vite is precompiled:

// resolve cache directory 
const pkgPath = lookupFile (resolvedRoot, [`package.json`], true  /* pathOnly */ )
 // Default is node_module/.vite 
const cacheDir = config.cacheDir
  ? path. resolve (resolvedRoot, config.cacheDir)
  : pkgPath && path. join (path. dirname (pkgPath), `node_modules/.vite`)

Next, the user-configured assetsInclude is processed and converted into a filter function:

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

Then, Vite will merge the assetsInclude passed in by the user with the built-in rules:

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

This configuration determines whether to let Vite treat the corresponding suffix name as a static resource file (asset).

1.4 Path parser

The path parser mentioned here refers to the function that calls the plug-in container for path parsing. The code structure is as follows:

const  createResolver : ResolvedConfig [ 'createResolver' ] = ( options ) => {
   let  aliasContainer : PluginContainer | undefined 
  let  resolverContainer : PluginContainer | undefined 
  // The returned function can be understood as a resolver 
  return  async (id, importer, aliasOnly, ssr) => {
     let  container : PluginContainer 
    if (aliasOnly) {
      container =
        aliasContainer ||
        // Create new aliasContainer 
    } else {
      container =
        resolverContainer ||
        // Create new resolveContainer
    }
    return ( await container. resolveId (id, importer, undefined , ssr))?. id
  }
}

Moreover, this parser will be used when relying on pre-construction in the future. The specific usage is as follows:

const resolve = config.createResolver()
 // Call to get the react path 
rseolve( 'react' , undefined , undefined , false )

There are two tool objects, aliasContainer and resolverContainer. They both contain resolveId, a method that specializes in parsing paths. They can be called by Vite to obtain the parsing results. They are essentially PluginContainer.

Next, a public directory will be processed, which is the directory where Vite serves static resources:

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

At this point, the configuration has basically been parsed and finally organized through the resolved object:

const resolved: ResolvedC onfig  =  {
  ...config,
  configFile: configFile ? normalizePath(configFile) : undefined,
  configFileDependencies,
  inlineConfig,
  root: resolvedRoot,
   base: BASE_URL
  ... //Other configuration 
}

1.5 Generate plug-in pipeline

The code to generate the plug-in pipeline is as follows:

;(resolved.plugins as Plugin[]) = await resolvePlugins(
  resolved,
  prePlugins,
  normalPlugins,
  postPlugins
)


// call configResolved hooks
 await  Promise .all(userPlugins.map( (p) => p.configResolved?.(resolved)))

First generate a complete list of plug-ins and pass it to resolve.plugins, and then call the configResolved hook function of each plug-in. Among them, resolvePlugins has many internal details and the number of plug-ins is relatively large. We will not delve into the specific implementation for the time being. We will introduce it in detail in the section of the compilation pipeline.

At this point, all core configurations have been generated. However, Vite will also handle some edge cases later and give users corresponding prompts when their configuration is unreasonable. For example: when the user uses alias directly, Vite will prompt to use resolve.alias.

Finally, the resolveConfig function will return the resolved object, which is the final configuration collection, and the configuration resolution service will end.

2. Detailed explanation of loading configuration files

First, let’s take a look at the implementation of loading the configuration file (loadConfigFromFile):

const loadResult = await loadConfigFromFile( /*Omit parameters*/ )

The logic here is a bit complicated and difficult to sort out, so we might as well use the configuration parsing process we just sorted out to delve into the details of loadConfigFromFile and study Vite’s implementation ideas for loading configuration files.

Next, let’s analyze the types of configuration files that need to be processed. They can be divided into the following categories according to the file suffix and module format:

  • TS + ESM format
  • TS + CommonJS format
  • JS + ESM format
  • JS + CommonJS format

2.1 Identify the category of configuration files

First, Vite will check the project’s package.json file. If there is type: “module”, it will mark it with the isESM flag:

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

Then, Vite will look for the configuration file path. The code is simplified as follows:

let isTS = false 
let isESM = false 
let dependencies: string [] = [] 
// If the command line specifies the configuration file path 
if (configFile) {
  resolvedPath = path.resolve(configFile)
  // Determine whether it is ts or esm based on the suffix, and set the flag 
  isTS = configFile.ends With('. ts ') 
  if (configFile.ends With('. mjs ') ) {
      isESM = true
    }
} else {
   // Search the configuration file path from the project root directory, search order: 
  // - vite.config.js 
  // - vite.config.mjs 
  // - vite.config.ts 
  // - vite.config.cjs
  const jsconfigFile = path.resolve(configRoot, 'vite.config.js')
  if (fs.exists Sync( jsconfigFile ) ) {
    resolvedPath = jsconfigFile
  }


  if (!resolvedPath) {
    const mjsconfigFile = path.resolve(configRoot, 'vite.config.mjs')
    if (fs.exists Sync( mjsconfigFile ) ) {
      resolvedPath = mjsconfigFile
      isESM = true
    }
  }


  if (!resolvedPath) {
    const tsconfigFile = path.resolve(configRoot, 'vite.config.ts')
    if (fs.exists Sync( tsconfigFile ) ) {
      resolvedPath = tsconfigFile
      isTS = true
    }
  }
  
  if (!resolvedPath) {
    const cjsConfigFile = path.resolve(configRoot, 'vite.config.cjs')
    if (fs.exists Sync( cjsConfigFile ) ) {
      resolvedPath = cjsConfigFile
      isESM = false
    }
  }
}

While searching for the path, Vite will also mark the current configuration file with the isESM and isTS identifiers to facilitate subsequent analysis.

2.2 Parse configuration according to category

2.2.1 ESM format

The processing code for ESM format configuration is as follows:

let  userConfig : UserConfigExport | undefined


if (isESM) {
   const fileUrl = require ( 'url' ). pathToFileURL (resolvedPath)
   // First package the code 
  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)
  }
}

As you can see, the configuration file is first compiled and packaged into js code through Esbuild:

const bundled = await bundleConfigFile(resolvedPath, true )
 // Record dependencies 
dependencies = bundled.dependencies

For TS configuration files, Vite will write the compiled js code into a temporary file, read the temporary content through Node’s native ESM Import to obtain the configuration content, and then delete the temporary file directly:

fs.write FileSync( resolvedPath + '. js ', bundled . code ) 
userConfig = (await dynamic Import(`${ fileUrl }. js ? t =${Date. now () }`)).default
fs.unlink Sync( resolvedPath + '. js ')

The above method of first compiling the configuration file, then writing the product to the temporary directory, and finally loading the product in the temporary directory is also a specific implementation of AOT (Ahead Of Time) compilation technology.

For JS configuration files, Vite will read them directly through Node’s native ESM Import, which also uses the logic of the dynamicImport function. The implementation of dynamicImport is as follows:

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

You may ask, why wrap it with new Function? This is to prevent packaging tools from processing this code, such as Rollup and TSC, and similar methods include eval. You may also ask, why does the import path result need to be added with a timestamp query? This is actually to allow the dev server to still read the latest configuration after restarting and avoid caching.

2.2.2 CommonJS format

For configuration files in CommonJS format, Vite centrally parses them:

// Valid for js/ts 
// Use esbuild to compile the configuration file into a bundle file in commonjs format 
const bundled = await bundle ConfigFile( resolvedPath )
dependencies = bundled.dependencies
//Load the compiled bundle code 
userConfig = await load ConfigFromBundledFile( resolvedPath , bundled . code )

The main function of the bundleConfigFile function is to package the configuration file through Esbuild and obtain the packaged bundle code and the dependencies of the configuration file. The next thing is to consider how to load the bundle code, which is what loadConfigFromBundledFile does.

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)
    }
  }
  // Clear the require cache 
  delete  require . cache [ require . resolve (fileName)]
   const raw = require (fileName)
   const config = raw. __esModule ? raw. default : raw
   require . extensions [extension] = defaultLoader
   return config
}

What loadConfigFromBundledFile generally accomplishes is to load the post-bundle configuration code by intercepting the loading function of native require.extensions. The code is as follows:

// Default loader 
const defaultLoader = require.extensions [ extension ] !
 // Intercept native require for loading of `.js` or `.ts` 
require.extensions [ extension ] = ( module : NodeModule , filename : string ) = > {
   // Special processing for loading the vite configuration file 
  if (filename === fileName) {
    ;( module  as NodeModuleWithCompile). _compile( bundledCode , filename ) 
  } else {
    default Loader( module , filename )
  }
}

The native require loading code for js files is as follows.

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

In fact, Node.js internally reads the file content first and then compiles the module. When calling module._compile in the code is equivalent to manually compiling a module, the method is implemented inside Node as follows:

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

After calling module._compile to compile the configuration code, perform a manual require to get the configuration object:

const raw = require (fileName)
 const config = raw.__esModule ? raw. default : raw
 // Restore the native loading method
require.extensions[extension] = defaultLoader
//Return config 
return config

This method of loading TS configuration at runtime is also called JIT (just in time compilation). The biggest difference between this method and AOT is that the js code calculated in the memory is not written to the disk and then loaded, but by intercepting Node.js The native require.extension method implements instant loading.

At this point, the content of the configuration file has been read. You can return after the post-processing is completed:

// Handle the case of function 
const config = await (typeof userConfig === ' function '
  ? user Config( configEnv )
  : userConfig)


if (!is Object( config ) ) {
  throw new  Error(` config  must  export  or  return  an  object .`)
}
// Next return the final configuration information
return {
  path: normalize Path( resolvedPath ) ,
  config,
  // Dependencies collected during esbuild packaging process
  dependencies
}

3. Summary

Let’s summarize the overall process of Vite configuration parsing and the method of loading configuration files:

First of all, the logic of Vite configuration file parsing is implemented uniformly by the resolveConfig function, which goes through the main processes of loading configuration files, parsing user plug-ins, loading environment variables, creating path parser factories and generating plug-in pipelines.

Secondly, during the process of loading configuration files, Vite needs to process four types of configuration files. For TS files in two formats, ESM and CommonJS, two compilation technologies, AOT and JIT, are used to implement configuration loading.