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.