Skip to content

Configuration Resolution

Whether in development or production, configuration file resolution is necessary. The resolution process mainly consists of vite.config configuration resolution, plugins sorting and initialization (executing each plugin's config hook), and loading .env files (none by default).

Loading the vite.config Module

js
let { configFile } = config;
if (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;
  }
}

By default, config is inlineConfig

js
const inlineConfig = {
  root,
  base: options.base,
  mode: options.mode,
  configFile: options.config,
  logLevel: options.logLevel,
  clearScreen: options.clearScreen,
  optimizeDeps: { force: options.force },
  build: buildOptions
};

options are configurations made by users through commands in the console. For example, the configFile configuration item's value comes from the user executing vite --config=xxx, which is undefined by default. Therefore, the conditional statement will execute by default unless vite --config=false is executed.

In the loadConfigFromFile function, the following work is mainly done:

  1. Get the path of vite.config in the file system

The suffix of vite.config should actually have 6 types, including all suffixes of js and ts under both ESM and CJS

js
const DEFAULT_CONFIG_FILES = [
  'vite.config.js',
  'vite.config.mjs',
  'vite.config.ts',
  'vite.config.cjs',
  'vite.config.mts',
  'vite.config.cts'
];

The priority of searching for configuration files in the file system decreases from top to bottom. The default configuration module of Vite (cannot be modified) is in the project's root directory, so the default search will only check if it exists in the root directory, searching from top to bottom until it finds the project's configuration file.

  1. Determine if the configuration module is an ESM module or a CJS module

    Determining which module the configuration module belongs to is simple, usually by directly checking the configuration file suffix. For special suffixes, you can check if type: module is set in the project's package.json module.

    js
    let isESM = false;
    if (/\.m[jt]s$/.test(resolvedPath)) {
      isESM = true;
    } else if (/\.c[jt]s$/.test(resolvedPath)) {
      isESM = false;
    } else {
      try {
        const pkg = lookupFile(configRoot, ['package.json']);
        isESM = !!pkg && JSON.parse(pkg).type === 'module';
      } catch (e) {}
    }

    Another point to note is that the search for package.json is implemented through the lookupFile method. The implementation logic is to start searching from the configRoot directory, and if not found, continue searching in the parent directory until the package.json module is found. If not found, it returns undefined.

  2. Load different modules in different ways

This step is quite interesting, as the parsing solutions for ESM and CJS are quite different. However, both rely on esbuild to execute the build process.

  • Why execute the build process?

    Actually, configuration modules are the same as regular modules, they may depend on other modules. But you might think that since we're in the runtime phase, we could directly load the configuration file through import (esm) or require (cjs), without needing to bundle first. My consideration goes back to the meaning of bundling, which in simple terms is container compatibility and optimizing project loading process (including reducing package size, split chunk, etc.). Here, it should be for compatibility reasons, as there might be module dependencies between different specifications, which could lead to module resolution errors. Another reason is to optimize parsing speed. Node's module resolution process (demo) can be simply summarized as load module -> compile module -> execute module -> load module -> ... and so on recursively resolving various modules. This process will inevitably be time-consuming for large-scale dependencies. If we bundle first, then for node it only needs load module -> compile module -> execute module, or even just compile module -> execute module. Combined with esbuild as a native build tool, the bundling speed is very fast, so overall it will improve the module resolution process.

  • Esbuild Bundling Process

    In the bundling process, Vite injects two plugins: externalize-deps and inject-file-scope-variables. The purpose of the former plugin is to filter out modules that don't use relative paths (e.g., import react from '@vitejs/plugin-react', import { defineConfig } from '/demo'); the purpose of the latter plugin is to inject three path constants related to modules (__dirname, __filename, __vite_injected_original_import_meta_url) for non-filtered modules. The code implementation is as follows:

    js
    async function bundleConfigFile(fileName, isESM = false) {
      const importMetaUrlVarName = '__vite_injected_original_import_meta_url';
      const result = await build$3({
        // ... other configuration items omitted
        format: isESM ? 'esm' : 'cjs',
        plugins: [
          {
            name: 'externalize-deps',
            setup(build) {
              build.onResolve({ filter: /.*/ }, args => {
                const id = args.path;
                if (id[0] !== '.' && !path$o.isAbsolute(id)) {
                  return {
                    external: true
                  };
                }
              });
            }
          },
          {
            name: 'inject-file-scope-variables',
            setup(build) {
              build.onLoad({ filter: /\.[cm]?[jt]s$/ }, async args => {
                const contents = await fs$l.promises.readFile(
                  args.path,
                  'utf8'
                );
                const injectValues =
                  `const __dirname = ${JSON.stringify(path$o.dirname(args.path))};` +
                  `const __filename = ${JSON.stringify(args.path)};` +
                  `const ${importMetaUrlVarName} = ${JSON.stringify(pathToFileURL(args.path).href)};`;
                return {
                  loader: isTS(args.path) ? 'ts' : 'js',
                  contents: injectValues + contents
                };
              });
            }
          }
        ]
      });
      const { text } = result.outputFiles[0];
      return {
        code: text,
        dependencies: result.metafile
          ? Object.keys(result.metafile.inputs)
          : []
      };
    }
  • ESM Module Processing

    Write the bundled configuration module to the file system with a .mjs suffix, then dynamically load the module information through import, and finally remove the .mjs suffix configuration module from the file system. The code implementation is as follows:

    js
    const dynamicImport = new Function('file', 'return import(file)');
    if (isESM) {
      if (isTS(resolvedPath)) {
        fs$l.writeFileSync(resolvedPath + '.mjs', bundled.code);
        try {
          userConfig = (await dynamicImport(`${fileUrl}.mjs?t=${Date.now()}`))
            .default;
        } finally {
          fs$l.unlinkSync(resolvedPath + '.mjs');
        }
      } else {
        userConfig = (await dynamicImport(`${fileUrl}?t=${Date.now()}`))
          .default;
      }
    }
  • CJS Module Processing

    To allow require to directly execute the compilation process without needing to execute the loading process, rewrite the require.extensions['.js'] method to allow the bundled configuration module to be directly compiled. The code implementation is as follows:

    js
    async function loadConfigFromBundledFile(fileName, bundledCode) {
      const realFileName = fs$l.realpathSync(fileName);
      const defaultLoader = _require.extensions['.js'];
      _require.extensions['.js'] = (module, filename) => {
        if (filename === realFileName) {
          module._compile(bundledCode, filename);
        } else {
          defaultLoader(module, filename);
        }
      };
      delete _require.cache[_require.resolve(fileName)];
      const raw = _require(fileName);
      _require.extensions['.js'] = defaultLoader;
      return raw.__esModule ? raw.default : raw;
    }
    
    if (!userConfig) {
      const bundled = await bundleConfigFile(resolvedPath);
      dependencies = bundled.dependencies;
      userConfig = await loadConfigFromBundledFile(
        resolvedPath,
        bundled.code
      );
    }
  • Merging Configuration Modules

    The overall idea is that vite.config is the basic configuration module. Traverse the variable values in inlineConfig (non-undefined and non-null), and merge the values into the corresponding properties of vite.config. The merging details code is as follows:

    js
    function arraify(target) {
      return Array.isArray(target) ? target : [target];
    }
    function isObject$2(value) {
      return Object.prototype.toString.call(value) === '[object Object]';
    }
    // Since the execution order of alias is top-down, the order should be reversed here, meaning that items sorted later have higher priority.
    function mergeAlias(a, b) {
      if (!a) return b;
      if (!b) return a;
      if (isObject$2(a) && isObject$2(b)) {
        return { ...a, ...b };
      }
      return [...normalizeAlias(b), ...normalizeAlias(a)];
    }
    function mergeConfigRecursively(defaults, overrides, rootPath) {
      const merged = { ...defaults };
      for (const key in overrides) {
        const value = overrides[key];
        if (value == null) {
          continue;
        }
        const existing = merged[key];
        if (existing == null) {
          merged[key] = value;
          continue;
        }
        if (key === 'alias' && (rootPath === 'resolve' || rootPath === '')) {
          merged[key] = mergeAlias(existing, value);
          continue;
        } else if (key === 'assetsInclude' && rootPath === '') {
          merged[key] = [].concat(existing, value);
          continue;
        } else if (
          key === 'noExternal' &&
          rootPath === 'ssr' &&
          (existing === true || value === true)
        ) {
          merged[key] = true;
          continue;
        }
        if (Array.isArray(existing) || Array.isArray(value)) {
          merged[key] = [...arraify(existing ?? []), ...arraify(value ?? [])];
          continue;
        }
        if (isObject$2(existing) && isObject$2(value)) {
          merged[key] = mergeConfigRecursively(
            existing,
            value,
            rootPath ? `${rootPath}.${key}` : key
          );
          continue;
        }
        merged[key] = value;
      }
      return merged;
    }

Note that through the bundling process, we get the current module's dependencies (including the module itself) dependencies, which will be related to the subsequent HMR handleHMRUpdate update.

Plugin Initialization and Sorting

In Vite, plugins are mainly divided into two types: user-written plugins and Vite built-in plugins. For user-written plugins, they are divided into user plugin and worker plugin. The former will be called for entry modules using this.option.entry, import(), this.emitChunk, etc.; while the latter is only for worker modules. The calling process is as follows:

ts
import type { ResolvedConfig } from 'vite';
import type { OutputChunk } from 'rollup';
const postfixRE = /[?#].*$/;
function cleanUrl(url: string): string {
  return url.replace(postfixRE, '');
}
export async function bundleWorkerEntry(
  config: ResolvedConfig,
  id: string,
  query: Record<string, string> | null
): Promise<OutputChunk> {
  // bundle the file as entry to support imports
  const { rollup } = await import('rollup');
  const { plugins, rollupOptions, format } = config.worker;
  const bundle = await rollup({
    ...rollupOptions,
    input: cleanUrl(id),
    plugins,
    onwarn(warning, warn) {
      onRollupWarning(warning, warn, config);
    },
    preserveEntrySignatures: false
  });
  // ...
}

It can be seen that for worker module processing, it will directly call rollup to generate the module, and the plugins used here are the worker plugins mentioned above. The processing timing of worker plugins is before user normal plugins. For plugin details, you can jump to worker plugin.

  1. Initialize Plugins

    • Flatten all plugins in the vite.config module, which means Vite supports asynchronous operations and one plugin can export multiple plugins in plugin implementation.

      js
      async function asyncFlatten(arr) {
        do {
          arr = (await Promise.all(arr)).flat(Infinity);
        } while (arr.some(v => v?.then));
        return arr;
      }
    • Filter out plugins that don't need to be enabled. If a plugin has an apply property, determine whether the current plugin needs to be used based on the value of the apply property. The code is as follows:

      js
      const rawUserPlugins = (
        await asyncFlatten(config.plugins || [])
      ).filter(p => {
        if (!p) {
          // Filter out plugins that don't exist or are determined to be unnecessary after promise async execution
          return false;
        } else if (!p.apply) {
          // By default, the current plugin needs to be used
          return true;
        } else if (typeof p.apply === 'function') {
          // Execute the apply function in the plugin to determine whether to use the current plugin based on the function's return value.
          return p.apply({ ...config, mode }, configEnv);
        } else {
          // Plugin is compatible with the environment.
          return p.apply === command;
        }
      });
  2. Sort Plugins, Determine Plugin Priority

    Sort plugins according to their priority. The code is as follows:

    js
    function sortUserPlugins(plugins) {
      const prePlugins = [];
      const postPlugins = [];
      const normalPlugins = [];
      if (plugins) {
        plugins.flat().forEach(p => {
          if (p.enforce === 'pre') prePlugins.push(p);
          else if (p.enforce === 'post') postPlugins.push(p);
          else normalPlugins.push(p);
        });
      }
      return [prePlugins, normalPlugins, postPlugins];
    }

    From the code, we can understand that Vite determines priority by configuring the enforce property in the plugin, that is, determining the execution order based on the enforce value of pre, post, and relative position of plugin configuration.

  3. Execute and merge the config hook of user configuration plugins in the above sorting order. The execution of this hook also marks the final determination of vite configuration information (which can be modified by users).

    js
    const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins];
    for (const p of userPlugins) {
      if (p.config) {
        const res = await p.config(config, configEnv);
        if (res) {
          config = mergeConfig(config, res);
        }
      }
    }
  4. Resolve Plugins

Loading env Files

In vite.config, you can define all variables in process.env and envFiles modules that start with prefixes by configuring envPrefix (default is [VITE_]). Vite divides env module paths into the following 4 categories.

js
const envFiles = [
  /** mode local file */ `.env.${mode}.local`,
  /** mode file */ `.env.${mode}`,
  /** local file */ '.env.local',
  /** default file */ '.env'
];

The search method starts from the root directory by default (you can modify the default path by setting the envDir property in the vite.config configuration module). If not found, it continues searching in the parent directory until the env module path is found. The search method is similar to the package.json search method.

Loading env modules will use dotenv's capabilities for parsing. However, it should be noted that env modules will not be injected into process.env.

js
// let environment variables use each other
main({
  parsed,
  // Avoid affecting process.env
  ignoreProcessEnv: true
});

After loading the env module, a JS Object will be obtained, and then all non-empty key-value pairs starting with prefixes will be extracted.

js
function loadEnv(mode, envDir, prefixes = 'VITE_') {
  if (mode === 'local') {
    throw new Error(
      '"local" cannot be used as a mode name because it conflicts with ' +
        'the .local postfix for .env files.'
    );
  }
  prefixes = arraify(prefixes);
  const env = {};
  const envFiles = [
    /** mode local file */ `.env.${mode}.local`,
    /** mode file */ `.env.${mode}`,
    /** local file */ '.env.local',
    /** default file */ '.env'
  ];
  // check if there are actual env variables starting with VITE_*
  // these are typically provided inline and should be prioritized
  for (const key in process.env) {
    if (
      prefixes.some(prefix => key.startsWith(prefix)) &&
      env[key] === undefined
    ) {
      env[key] = process.env[key];
    }
  }
  for (const file of envFiles) {
    const path = lookupFile(envDir, [file], {
      pathOnly: true,
      rootDir: envDir
    });
    if (path) {
      const parsed = main$1.exports.parse(fs$l.readFileSync(path), {
        debug: process.env.DEBUG?.includes('vite:dotenv') || undefined
      });
      // let environment variables use each other
      main({
        parsed,
        // prevent process.env mutation
        ignoreProcessEnv: true
      });
      // only keys that start with prefix are exposed to client
      for (const [key, value] of Object.entries(parsed)) {
        if (
          prefixes.some(prefix => key.startsWith(prefix)) &&
          env[key] === undefined
        ) {
          env[key] = value;
        } else if (
          key === 'NODE_ENV' &&
          process.env.VITE_USER_NODE_ENV === undefined
        ) {
          // NODE_ENV override in .env file
          process.env.VITE_USER_NODE_ENV = value;
        }
      }
    }
  }
  return env;
}

The final parsed env is used as userEnv. Users can finally access it through config.env.

js
async function resolveConfig(
  inlineConfig,
  command,
  defaultMode = 'development'
) {
  // ...
  const resolved = {
    // ...
    env: {
      ...userEnv,
      BASE_URL,
      MODE: mode,
      DEV: !isProduction,
      PROD: isProduction
    }
    // ...
  };
  //...
  return resolved;
}

Differences Between Development and Production Environments

Both development and production environments will execute the resolveConfig process

js
async function doBuild(inlineConfig = {}) {
  const config = await resolveConfig(inlineConfig, 'build', 'production');
  // ...
}

async function createServer(inlineConfig = {}) {
  const config = await resolveConfig(inlineConfig, 'serve', 'development');
  // ...
}

From the parameters passed in, we can clearly see that the second and third parameters are different. In the resolveConfig function, there is no additional logic processing for different modes, only some logic to confirm the mode.

js
async function loadConfigFromFile(
  configEnv,
  configFile,
  configRoot = process.cwd(),
  logLevel
) {
  // ...
  const config = await (typeof userConfig === 'function'
    ? userConfig(configEnv)
    : userConfig);
  // ...
  return {
    path: normalizePath$3(resolvedPath),
    config,
    dependencies
  };
}
async function resolveConfig(
  inlineConfig,
  command,
  defaultMode = 'development'
) {
  // Usually fallback
  let mode = inlineConfig.mode || defaultMode;
  if (mode === 'production') {
    process.env.NODE_ENV = 'production';
  }
  const configEnv = {
    mode,
    command
  };
  if (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;
    }
  }
  mode = inlineConfig.mode || config.mode || mode;
  configEnv.mode = mode;

  // Plugin resolution
  const rawUserPlugins = (await asyncFlatten(config.plugins || [])).filter(
    p => {
      if (!p) {
        return false;
      } else if (!p.apply) {
        return true;
      } else if (typeof p.apply === 'function') {
        return p.apply({ ...config, mode }, configEnv);
      } else {
        // Plugin execution is determined by mode
        return p.apply === command;
      }
    }
  );
  for (const p of userPlugins) {
    if (p.config) {
      const res = await p.config(config, configEnv);
      if (res) {
        config = mergeConfig(config, res);
      }
    }
  }

  config = mergeConfig(config, externalConfigCompat(config, configEnv));
}
/**
 *  When legacy.buildRollupPluginCommonjs configuration is disabled, support rollupOptions.external. This function provides additional configuration support for config?.build?.rollupOptions?.external.
 *  */
function externalConfigCompat(config, { command }) {
  // Only affects the build command
  if (command !== 'build') {
    return {};
  }
  const external = config?.build?.rollupOptions?.external;
  // Skip if not configured
  if (!external) {
    return {};
  }
  let normalizedExternal = external;
  if (typeof external === 'string') {
    normalizedExternal = [external];
  }
  const additionalConfig = {
    optimizeDeps: {
      exclude: normalizedExternal,
      esbuildOptions: {
        plugins: [esbuildCjsExternalPlugin(normalizedExternal)]
      }
    }
  };
  return additionalConfig;
}

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (dbcbf17)