Vite 源码解析(1) - 配置篇

Vite 源码解析(1) - 配置篇

129 views
Vite

欢迎来到 Vite 源码解析系列. Vite 作为新时代的打包神器, 缝合了, 哦不, 集成了 ES Module, bundleless, esbulid, rollup 等优秀思想和特性. 本系列假设你已经使用过 Vite, 对 Vite 配置, 插件机制有一定了解. 并且对 esbuild, rollup 的配置有一定了解.

createServer

在讲 vite 如何处理用户配置参数时, 我们先大概总览一下 createServer 函数, 这个函数是 vite 处理整个开发

  • 获取 config 配置

  • 创建 http 服务器 httpServer

  • 创建 WebSocket 服务器 ws

  • 通过 chokidar 创建监听器 watcher

  • 创建一个兼容 rollup 钩子函数的对象 container

  • 创建模块依赖图实例 moduleGraph

  • 声明 server 对象

  • 注册 watcher 回调

  • 执行插件中的 configureServer 钩子函数(注册用户定义的前置中间件), 并收集用户定义的后置中间件

  • 注册中间件

  • 注册用户定义的后置中间件

  • 注册转换 html 文件的中间件和未找到文件的 404 中间件

  • 重写 httpServer.listen

  • 返回 server 对象

export async function createServer(inlineConfig) {
  // 获取 config 配置
  const config = await resolveConfig(inlineConfig, "serve", "development");
  // 获取项目根路径
  const root = config.root;
  // 获取本地服务器相关的配置
  const serverConfig = config.server;
  // 创建中间件实例
  const middlewares = connect() as Connect.Server;
  // 创建 http 服务器
  const httpServer = await resolveHttpServer(
    serverConfig,
    middlewares,
    httpsOptions
  );
  // 创建 WebSocket 服务器
  const ws = createWebSocketServer(httpServer, config, httpsOptions);
  // ignored: 忽略监听的文件;
  // watchOptions: 对应 server.watch 配置, 传递给 chokidar 的文件系统监视器选项
  const { ignored = [], ...watchOptions } = serverConfig.watch || {};
  // 通过 chokidar 监听文件
  const watcher = chokidar.watch(path.resolve(root), {
    ignored: [
      "**/node_modules/**",
      "**/.git/**",
      ...(Array.isArray(ignored) ? ignored : [ignored]),
    ],
    ignoreInitial: true,
    ignorePermissionErrors: true,
    disableGlobbing: true,
    ...watchOptions,
  }) as FSWatcher;
  // 获取 所有插件
  const plugins = config.plugins;
  // 创建插件容器, 是一个对象, 对象的属性是 vite 支持的 rollup 的钩子函数, 后面会介绍
  // 比如 options, resolveId, load, transform
  const container = await createPluginContainer(config, watcher);
  // 创建Vite 的 ModuleGraph 实例, 后面也会介绍
  const moduleGraph = new ModuleGraph(container);
  // 声明 server 对象
  const server: ViteDevServer = {
    config, // 包含命令行传入的配置 和 配置文件的配置
    middlewares,
    get app() {
      return middlewares;
    },
    httpServer, // http 服务器
    watcher, // 通过 chokidar 监听文件
    pluginContainer: container, // vite 支持的 rollup 的钩子函数
    ws, // WebSocket 服务器
    moduleGraph, // ModuleGraph 实例
    transformWithEsbuild,
    transformRequest(url, options) {},
    listen(port?: number, isRestart?: boolean) {},
    _optimizeDepsMetadata: null,
    _isRunningOptimizer: false,
    _registerMissingImport: null,
    _pendingReload: null,
    _pendingRequests: Object.create(null),
  };
  // 被监听文件发生变化时触发
  watcher.on("change", async (file) => {});
  // 添加文件时触发
  watcher.on("add", (file) => {});
  watcher.on("unlink", (file) => {});
  // 执行插件中的 configureServer 钩子函数
  // configureServer: https://vitejs.cn/guide/api-plugin.html#configureserver
  const postHooks: ((() => void) | void)[] = [];
  for (const plugin of plugins) {
    if (plugin.configureServer) {
      // configureServer 可以注册前置中间件, 就是在内部中间件之前执行; 也可以注册后置中间件
      // 如果configureServer 返回一个函数, 这个函数内部就是注册后置中间件, 并将这些函数收集到 postHooks 中
      postHooks.push(await plugin.configureServer(server));
    }
  }
  // 接下来就是注册中间件
  // base
  if (config.base !== "/") {
    middlewares.use(baseMiddleware(server));
  }
  // ...
  // 主要转换中间件
  middlewares.use(transformMiddleware(server));
  // ...
  // 如果请求路径是 /结尾, 则将路径修改为 /index.html
  if (!middlewareMode || middlewareMode === "html") {
    middlewares.use(spaFallbackMiddleware(root));
  }
  // 调用用户定义的后置中间件
  postHooks.forEach((fn) => fn && fn());
  if (!middlewareMode || middlewareMode === "html") {
    // 如果请求的url是 html 则调用插件中所有的 transformIndexHtml 钩子函数, 转换html, 并将转换后的 html 代码发送给客户端
    middlewares.use(indexHtmlMiddleware(server));
    // handle 404s
    middlewares.use(function vite404Middleware(_, res) {
      res.statusCode = 404;
      res.end();
    });
  }
  if (!middlewareMode && httpServer) {
    // 重写 httpServer.listen, 在服务器启动前预构建
    const listen = httpServer.listen.bind(httpServer);
    httpServer.listen = (async (port: number, ...args: any[]) => {}) as any;
  } else {
    // ...
  }
  return server;
}

resolveConfig

createServer 中调用的第一个函数是 resolveConfig, 也就是解析用户配置. 和其他的构建工具一样, vite 也提供 vite.config.js 来作为它的配置文件, 因此 vite 首要目标是要读取和解析配置文件. vite 的配置分为共享配置, 开发服务器配置, 构建选项配置, 依赖优化选项配置, ssr 选项配置和 worker 配置.
该函数第一个参数是 inlineConfig, 也就是我们在命令行中指定的配置; 第二个参数是指定的命令模式, 有 servebuild 两种, 开发环境显然是前者; 第三个参数是指定的环境.

export async function resolveConfig(
  inlineConfig: InlineConfig,
  command: "build" | "serve", // 命令
  defaultMode = "development" // 环境
): Promise<ResolvedConfig> {
  let config = inlineConfig; // 命令行中的配置项
  let configFileDependencies: string[] = [];
  let mode = inlineConfig.mode || defaultMode;
  const configEnv = {
    mode, // 环境, 开发环境下是 development
    command, // 命令, 开发环境下是 serve
  };
  let { configFile } = config; // 配置文件路径
  // ...
}

loadConfigFromFile

resolveConfig 函数中, 首先执行的函数是 loadConfigFromFile, 这个函数是用来加载配置文件的. 可以看到, 如果你不显式的将 configFile 设为 false, 它会执行 loadConfigFromFile 函数, 配置文件可能来自用户指定的文件, 也可能是缺省配置文件, 比如 vite.config.js.

const loadResult = await loadConfigFromFile(
  configEnv,
  configFile,
  config.root,
  config.logLevel
);

判断是否为 ESM

loadConfigFromFile 函数中, 它首先去寻找你工程根目录下的 package.json 文件, 如果配置了 module 属性, 说明你的项目支持 ESM 规范, 故把内部变量 isESM 设为 true.

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

解析配置文件路径

接下来, 在 loadConfigFromFile 函数中, 如果你手动提供了配置文件的(相对)路径, 则直接获取这个配置文件的绝对路径, 否则, 它会读取是否有缺省的配置文件路径, 比如 vite.config.js, vite.config.mjs, vite.config.ts, vite.config.cjs.

if (configFile) {
  // explicit config path is always resolved from cwd
  resolvedPath = path.resolve(configFile);
  isTS = configFile.endsWith(".ts");
  if (configFile.endsWith(".mjs")) {
    isESM = true;
  }
} else {
  // implicit config file loaded from inline root (if present)
  // otherwise from cwd
  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, 抑或着普通的 js 文件. 因此如果明确了 isESM, 就可以使用 dynamicImport 函数进行加载(当然后面 vite 应该会换成原生的 import()). 而对于 isTS, 稍微 hack 了一下, 先打包编译成 js, 然后再将这个 js 代码写入磁盘, 再加载这个被编译后的 js 文件, 完成后删除这个临时 js 文件.

try {
  let userConfig: UserConfigExport | undefined;
  if (isESM) {
    const fileUrl = pathToFileURL(resolvedPath); // 拿到 file:/// 的路径
    const bundled = await bundleConfigFile(resolvedPath, true); // 用 esbuild 打包配置文件, 如 vite.config.js
    // bundleConfigFile 内调用的 esbuild 的配置, 然后设置了 metafile: true 用于生成依赖关系
    // 并且使用插件 externalize-dep 来保证不会将第三方库打包在 bundle 中, 即生成的依赖也不会包含第三方库
    // 所以这里的 dependencies 内容, 只包含用户自己写的文件
    // 具体 bundleConfigFile 函数下面会讲到
    dependencies = bundled.dependencies;
    if (isTS) {
      // before we can register loaders without requiring users to run node
      // with --experimental-loader themselves, we have to do a hack here:
      // bundle the config file w/ ts transforms first, write it to disk,
      // load it with native Node ESM, then delete the file.
      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);
    } else {
      // 使用 Function 来避免被 TS/Rollup 编译掉附加一个查询,
      // 以便我们在服务器重启的情况下强制重新加载新配置
      // using Function to avoid this from being compiled away by TS/Rollup
      // append a query so that we force reload fresh config in case of
      // server restart
      userConfig = (await dynamicImport(`${fileUrl}?t=${Date.now()}`)).default;
      debug(`native esm config loaded in ${getTime()}`, fileUrl);
    }
  }
  if (!userConfig) {
    // Bundle config file and transpile it to cjs using esbuild.
    const bundled = await bundleConfigFile(resolvedPath);
    dependencies = bundled.dependencies;
    // 对于普通的 cjs, 就使用 module 下的模块加载
    userConfig = await loadConfigFromBundledFile(resolvedPath, bundled.code);
    debug(`bundled config file loaded in ${getTime()}`);
  }
  // 我们知道 vite 的配置文件可以是 defineConfig({}) 的形式
  // 也可以是普通的对象, 不管怎样, 都应该返回一个对象才对
  const config = await(
    typeof userConfig === "function" ? userConfig(configEnv) : userConfig
  );
  if (!isObject(config)) {
    throw new Error(`config must export or return an object.`);
  }
  return {
    // 对路径进行了规范化处理, 在解析路径时使用 POSIX 分隔符, 兼容 win32 分割符
    path: normalizePath(resolvedPath),
    config,
    dependencies,
  };
} catch (e) {
  createLogger(logLevel).error(
    colors.red(`failed to load config from ${resolvedPath}`),
    { error: e }
  );
  throw e;
}

讲一讲 pathToFileURL

首先看 pathToFileURL, 它来自 node:url 中的原生函数, 用来转换路径为 file: 协议, 并且可以对路径进行 encode. 我们看下面这个例子:

import { pathToFileURL } from "url";
pathToFileURL("/hello/おはよう/🦙.txt");
// URL {
//   href: 'file:///hello/%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86/%F0%9F%A6%99.txt',
//   origin: 'null',
//   protocol: 'file:',
//   username: '',
//   password: '',
//   host: '',
//   hostname: '',
//   port: '',
//   pathname: '/hello/%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86/%F0%9F%A6%99.txt',
//   search: '',
//   searchParams: URLSearchParams {},
//   hash: ''
// }

可以看到, 它返回的是 URL 类型. 没错, 就是我们在浏览器中看到的那个 URL.

URL


当然 node:url 也有个 URL 构造函数, 但比起 pathToFileURL, 它无法对路径进行 encode, 这也大抵是 core team 不选择 URL 的原因. 我们看一下官方的例子:

import { pathToFileURL } from "node:url";
new URL("/foo#1", "file:"); // Incorrect: file:///foo#1
pathToFileURL("/foo#1"); // Correct:   file:///foo%231 (POSIX)
new URL("/some/path%.c", "file:"); // Incorrect: file:///some/path%.c
pathToFileURL("/some/path%.c"); // Correct:   file:///some/path%25.c (POSIX)

最后再说一点, pathToFileURL 的返回值是 URL 类型, 可以对它使用 toString 函数, 就能获得 URL 中的 href 属性. 因此, vite 源码中直接使用模板字面量, 也就是 ${fileUrl}.js?t=${Date.now()}, 就把 fileUrl 转换成了字符串了.

用 esbuild 解析配置文件

bundleConfigFile 使用 esbuild 来编译配置文件, 这需要你有一定的 esbuild 知识储备才行. 这块代码难度不大, 直接写在注释里, 具体代码如下:

async function bundleConfigFile(
  fileName: string,
  isESM = false
): Promise<{ code: string; dependencies: string[] }> {
  const importMetaUrlVarName = "__vite_injected_original_import_meta_url";
  const result = await build({
    absWorkingDir: process.cwd(), // 绝对根路径
    entryPoints: [fileName], // 配置文件的绝对路径
    outfile: "out.js", // 输出的文件名
    write: false, // 不写入到磁盘, 仅仅能拿到编译结果即可
    platform: "node", // 编译到的目标平台, 'browser' | 'node' | 'neutral', 这里显然是 node
    bundle: true, // 是否打包
    format: isESM ? "esm" : "cjs", // 通过上一步拿到的 isESM 判断打包成的形式
    sourcemap: "inline", // 内联 sourcemap
    metafile: true, // 元信息, 一般这个用做打包分析, 类似于 webpack 中的 `stats.json`
    define: {
      // import.meta 是原生 ESM 中的 API, 下面是一个例子
      // <script type="module" src="my-module.mjs"></script>
      // console.log(import.meta.url); // "file:///home/user/my-module.mjs"
      "import.meta.url": importMetaUrlVarName,
    },
    plugins: [
      {
        // 在解析时查找哪些是三方依赖, 标记上 external, 不把它们打包进来
        // 比如下面这段配置:
        /* 
        import { defineConfig } from 'vite'
        import react from '@vitejs/plugin-react'
        // https://vitejs.dev/config/
        export default defineConfig({
          plugins: [react()],
        })
        */
        // 就可以拿到以下三个依赖, 显然应该被打上 external 的是后两个
        // ./vite.config.js
        // vite
        // @vitejs/plugin-react
        name: "externalize-deps",
        setup(build) {
          build.onResolve({ filter: /.*/ }, (args) => {
            const id = args.path;
            if (id[0] !== "." && !path.isAbsolute(id)) {
              return {
                external: true,
              };
            }
          });
        },
      },
      {
        // 在加载时注入一些额外代码
        // 需要注意的是, 由于 __filename, __dirname 以及 import.meta.url 被这个 plugin 替换了.
        // 如果使用这些名称作为变量名可能会导致代码报错.
        name: "inject-file-scope-variables",
        setup(build) {
          build.onLoad({ filter: /\.[jt]s$/ }, async (args) => {
            const contents = await fs.promises.readFile(args.path, "utf8");
            const injectValues =
              `const __dirname = ${JSON.stringify(path.dirname(args.path))};` +
              `const __filename = ${JSON.stringify(args.path)};` +
              `const ${importMetaUrlVarName} = ${JSON.stringify(
                pathToFileURL(args.path).href
              )};`;
            return {
              loader: args.path.endsWith(".ts") ? "ts" : "js",
              contents: injectValues + contents,
            };
          });
        },
      },
    ],
  });
  const { text } = result.outputFiles[0];
  return {
    code: text, // 被编译后的代码的文本
    dependencies: result.metafile ? Object.keys(result.metafile.inputs) : [], //  这里用来获取 vite.config.js 中导入的非第三方文件列表(比如自定义插件, 方法文件等)
  };
}

dynamicImport

接下来, 我们看到有一个 dynamicImport 函数, 比较有意思. 它的作用是动态加载代码, 并且返回一个 Promise, 这个 Promise 就是模块的加载结果.

export const dynamicImport = usingDynamicImport
  ? new Function("file", "return import(file)")
  : _require;

它有两种方式, 一个是原生 node:module 下的 createRequire 函数, 我写个例子如下. NodeJS 官网写 Must be a file URL object, file URL string, or absolute path string., 不过我看着写相对路径也 OJBK.

import { createRequire } from "module";
const _require = createRequire(import.meta.url);
const siblingModule = _require("./hello.ts");
console.log(siblingModule); // 打印出 hello.ts 中的代码

第二种方式你可以看到在 new Function 的参数里面注入了代码字符串, 如果你不熟悉这种语法的话, 想必也能联想的 eval, 没错, new Function 执行代码字符串性能, 安全性要比 eval 高的高, 具体可以看 never_use_eval 这篇文章.
其实 vite 早期代码, 尤大也是用 eval 写的, 后来才改成了 Function. 具体 MR 可以看 fix: use Function instead of eval to dynamically import config files (#5213)

async function dynamicImport() {
  const _require = new Function("file", "return import(file)");
  await _require("./hello.js");
}
dynamicImport();

loadConfigFromBundledFile

而对于普通的 cjs 配置文件, 它的解析就类似于上面所说的 createRequire 方式.
这里开始使用 fs.realpathSync 是为了打平万恶的 Windows 路径格式, 具体可以看 Vite cannot load configuration files in the link directory (#4180) 这个 issue.
下面就是一些骚操作了. 使用了 _require.extensions[".js"], 也就是以 js 的方式处理模块, 不过最新版的 NodeJS 已经要把 extensions 废弃了(看不懂的借口), 总之这玩意儿就是解析 vite.config.js.

const _require = createRequire(import.meta.url);
async function loadConfigFromBundledFile(
  fileName: string,
  bundledCode: string
): Promise<UserConfig> {
  const realFileName = fs.realpathSync(fileName);
  const defaultLoader = _require.extensions[".js"];
  _require.extensions[".js"] = (module: NodeModule, filename: string) => {
    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)];
  const raw = _require(fileName);
  _require.extensions[".js"] = defaultLoader;
  return raw.__esModule ? raw.default : raw;
}

mergeConfig

当配置解析完成后, 会通过 mergeConfig 函数将配置文件中的配置和 inlineConfig 合并, 最终为 vite 提供运行时的框架配置. 该函数会递归地调用 mergeConfigRecursively 函数.

if (loadResult) {
  config = mergeConfig(loadResult.config, config); // 合并配置
  configFile = loadResult.path; // 获取配置文件绝对路径
  configFileDependencies = loadResult.dependencies; // 获取 vite.config.js 中导入的非第三方文件列表(比如自定义插件, 方法文件等)
}

关于 mergeConfigRecursively, 大抵来讲就是把内联的配置跟配置文件合并到一起, 看到递归就头大, 直接看注释吧.

function mergeConfigRecursively(
  defaults: Record<string, any>, // 配置文件
  overrides: Record<string, any>, // 内联配置
  rootPath: string
) {
  const merged: Record<string, any> = { ...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;
    }
    // alias 是给 `@rollup/plugin-alias` 传参, vite 需要 format 一下
    // 这是 aliasa 的签名: Record<string, string> | Array<{ find: string | RegExp, replacement: string, customResolver?: ResolverFunction | ResolverObject }>
    //
    // 下面是一个例子:
    //  alias({
    //   entries: [
    //     { find: 'utils', replacement: '../../../utils' },
    //     { find: 'batman-1.0.0', replacement: './joker-1.5.0' }
    //   ]
    // })
    if (key === "alias" && (rootPath === "resolve" || rootPath === "")) {
      merged[key] = mergeAlias(existing, value);
      continue;
      // merge 资源类型, 常见的像 jpg, webp, mp3 等等
    } 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;
    }
    // 数组的直接 merge 过来
    if (Array.isArray(existing) || Array.isArray(value)) {
      merged[key] = [...arraify(existing ?? []), ...arraify(value ?? [])];
      continue;
    }
    // 对象继续递归下去
    if (isObject(existing) && isObject(value)) {
      merged[key] = mergeConfigRecursively(
        existing,
        value,
        rootPath ? `${rootPath}.${key}` : key
      );
      continue;
    }
    merged[key] = value;
  }
  return merged;
}

对插件的前置处理

接下来我们看一下对 plugin 的处理, 这也是本篇比较重点的地方. 由于 vite 的 plugins 的签名是 (Plugin | Plugin[])[], 因此需要先打平成一层操作. 然后过滤掉那些无效的插件, 接下来, 由于插件存在 apply 属性(签名如下):

apply?: 'serve' | 'build' | ((config: UserConfig, env: ConfigEnv) => boolean)
  • 如果没提供该属性, 说明开发/生产环境都可用该插件

  • 如果是个函数, 那就看看它在当前环境('serve' | 'build')是否应该存在

  • 否则给它赋上当前的环境('serve' | 'build')
    接下来是 sortUserPlugins 函数, 由于 vite plugin 提供了 enforce 参数, 该参数的值可以是 prepost, 这个函数就是用来给插件排排坐.
    最后就好说了, 就是遍历所有合法的, 已排序的好插件数组, 逐一执行每个插件的 config 函数, 并注入 configEnv, 因为 config 钩子函数可以修改配置项, 并返回新的配置项, 拿到新的配置项之后, 让新的配置项和老的配置项合并.
    需要注意的是, config 函数在解析 vite 配置前调用. 钩子接收原始用户配置(命令行选项指定的会与配置文件合并)和一个描述配置环境的变量.
    走一遍 config 函数之后, 再合并到配置中.

// resolve plugins
const rawUserPlugins = (config.plugins || []).flat(Infinity).filter((p) => {
  if (!p) {
    return false;
  } else if (!p.apply) {
    // 如果没提供 apply, 说明开发/生产环境都可用该插件
    return true;
  } else if (typeof p.apply === "function") {
    return p.apply({ ...config, mode }, configEnv); // 如果是个函数, 那就看看它在当前环境(`'serve' | 'build'`)是否应该存在
  } else {
    return p.apply === command; // 否则给它赋上当前的环境(`'serve' | 'build'`)
  }
}) as Plugin[];
const [prePlugins, normalPlugins, postPlugins] =
  sortUserPlugins(rawUserPlugins); // 排排坐
// run config hooks
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins];
for (const p of userPlugins) {
  if (p.config) {
    const res = await p.config(config, configEnv); // 执行插件的 config 钩子, 它执行于在解析 vite 配置前
    if (res) {
      config = mergeConfig(config, res); // 合并到配置中
    }
  }
}

接下来, 我们看到一个内置的 resolver, 也就是 createResolver, 它主要调用了:

  • createPluginContainer, 这个函数用于创建插件容器, 是一个对象, 对象的属性是 vite 支持的 rollup 的钩子函数, 比如 options, resolveId, load, transform, 简单说就是 vite 打通 rollup 插件系统的桥梁, 这个我们下一篇重点说.

  • aliasPlugin: 不多说, 这是 rollup 体系下的一个插件, 我们上面说到了用户可以传递 alias 到 vite, 而这个插件就是把上面解析的 alias 透传给 rollup

// 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) {
      container =
        aliasContainer ||
        (aliasContainer = await createPluginContainer({
          ...resolved,
          plugins: [aliasPlugin({ entries: resolved.resolve.alias })],
        }));
    } else {
      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,
            }),
          ],
        }));
    }
    return (await container.resolveId(id, importer, { ssr }))?.id;
  };
};

对环境变量的处理

由于 vite 使用了 ESM, 因此它使用 import.meta.env, 来注入内置环境变量:

  • import.meta.env.MODE: {string} 应用运行的模式.

  • import.meta.env.BASE_URL: {string} 部署应用时的基本 URL. 他由 base 配置项决定.

  • import.meta.env.PROD: {boolean} 应用是否运行在生产环境.

  • import.meta.env.DEV: {boolean} 应用是否运行在开发环境 (永远与 import.meta.env.PROD 相反).
    和 create-react-app 一样, vite 也支持用户自建的环境变量文件, 与内置的进行合并或者替换. 其中优先级也是 .env.xxx.local > .env.xxx > .env.local > .env, 并且为了防止意外地将一些环境变量泄漏到客户端, 只有以 VITE_ 为前缀的变量才会暴露给经过 vite 处理的代码. 关于环境变量的文档可以参考官网 env-variables-and-modes.

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

我们详细看一下 loadEnv 这个函数.
首先看下 mode, 官网写的很清楚, 其实就是环境变量的名称, 不限于我们常用的 development, production. 你可以根据需要起名叫 testing, staging. 但是它不能叫 local, 要不 .env.xxx(local).env.local 就重复了.
然后和 CRA 一样, 只取环境变量中 VITE_ 开头或者用户自定义前缀的, 暴露到客户端, 这样保证隐私字段(非 VITE_ 开头的)不会被注入.
vite 用到了 dotenvExpand 这个三方库, 它负责将 env 文件中的环境变量注入到 process.env 或者合并到某个指定对象中. 注意 vite 设置了 ignoreProcessEnv: true, 我们看下面一个例子就知道它是做什么的了:

const dotenv = {
  ignoreProcessEnv: true,
  parsed: {
    SHOULD_NOT_EXIST: "testing",
  },
};
const obj = dotenvExpand.expand(dotenv).parsed;
console.log(obj.SHOULD_NOT_EXIST); // testing
console.log(process.env.SHOULD_NOT_EXIST); // undefined

下面我们过一遍源码:

import fs from "fs";
import dotenv from "dotenv";
import dotenvExpand from "dotenv-expand";
import { arraify, lookupFile } from "./utils";
import type { UserConfig } from "./config";
export function loadEnv(
  mode: string,
  envDir: string,
  prefixes: string | string[] = "VITE_"
): Record<string, string> {
  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: Record<string, string> = {};
  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
  // 先把 process.env 中 VITE_ 开头的收集一波
  for (const key in process.env) {
    if (
      prefixes.some((prefix) => key.startsWith(prefix)) &&
      env[key] === undefined
    ) {
      env[key] = process.env[key] as string;
    }
  }
  for (const file of envFiles) {
    const path = lookupFile(envDir, [file], {
      pathOnly: true,
      rootDir: envDir,
    });
    if (path) {
      // 用 dotenv 解析 env 文件, 这个库太常见了, 不多说.
      const parsed = dotenv.parse(fs.readFileSync(path), {
        debug: process.env.DEBUG?.includes("vite:dotenv") || undefined,
      });
      // let environment variables use each other
      // 合并环境变量配置, 但不把环境变量注入到 process.env 中
      dotenvExpand({
        parsed,
        // prevent process.env mutation
        ignoreProcessEnv: true,
      } as any);
      // only keys that start with prefix are exposed to client
      // 再过滤掉非 VITE_ 开头的
      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;
}

一些杂项的处理

接下来有一些杂项需要处理, 虽说是杂项, 就是代码不是特别复杂, 所以没必要分开写了, 直接看注释.

// 创建一个 Logger 的实例
const logger = createLogger(config.logLevel, {
  allowClearScreen: config.clearScreen,
  customLogger: config.customLogger,
});
// 由于 vite 支持 worker, 所以处理一些 worker 的配置
// https://vitejs.dev/config/#worker-options
const resolvedWorkerOptions: ResolveWorkerOptions = {
  format: config.worker?.format || "iife", // worker 文件默认打包成立即执行函数的形式
  plugins: [],
  rollupOptions: config.worker?.rollupOptions || {},
};
// resolve root
// 拿到根目录路径, 默认是 process.cwd(), 也就是项目路径
// normalizePath 上面讲了, 主要是为了兼容 Windows
const resolvedRoot = normalizePath(
  config.root ? path.resolve(config.root) : process.cwd()
);
// 上面我们讲到了 alias, 这里是给内置的两个包加上 alias
const clientAlias = [
  { find: /^[\/]?@vite\/env/, replacement: () => ENV_ENTRY }, // ENV_ENTRY => dist/client/env.mjs
  { find: /^[\/]?@vite\/client/, replacement: () => CLIENT_ENTRY }, // CLIENT_ENTRY => dist/client/client.mjs
];
// resolve alias with internal client alias
// 然后将用户传的, 和内置的 alias 合并到一起
const resolvedAlias = normalizeAlias(
  mergeAlias(
    // @ts-ignore because @rollup/plugin-alias' type doesn't allow function
    // replacement, but its implementation does work with function values.
    clientAlias,
    config.resolve?.alias || []
  )
);
const resolveOptions: ResolvedConfig["resolve"] = {
  ...config.resolve,
  alias: resolvedAlias,
};
// Note it is possible for user to have a custom mode, e.g. `staging` where
// production-like behavior is expected. This is indicated by NODE_ENV=production
// loaded from `.staging.env` and set by us as VITE_USER_NODE_ENV
// 不多说, 统一下 NODE_ENV 环境变量
const isProduction =
  (process.env.NODE_ENV || process.env.VITE_USER_NODE_ENV || mode) ===
  "production";
if (isProduction) {
  // in case default mode was not production and is overwritten
  process.env.NODE_ENV = "production";
}
// 处理 build options
// 具体字段可以看 https://vitejs.dev/config/#build-target
const resolvedBuildOptions = resolveBuildOptions(config.build);
// resolve cache directory
// ok, 讲到这里我们终于看到了 node_modules/.vite 这个文件夹
// 没错, 这就是 vite 的一大杀器: 预构建
// 具体原理我们后面说, 这里只是讲下配置
const pkgPath = lookupFile(resolvedRoot, [`package.json`], { pathOnly: true });
const cacheDir = config.cacheDir
  ? path.resolve(resolvedRoot, config.cacheDir)
  : pkgPath
  ? path.join(path.dirname(pkgPath), `node_modules/.vite`)
  : path.join(resolvedRoot, `.vite`);
const { publicDir } = config;
// 解析静态文件 dir, 默认是根目录下的 public 文件夹
const resolvedPublicDir =
  publicDir !== false && publicDir !== ""
    ? path.resolve(
        resolvedRoot,
        typeof publicDir === "string" ? publicDir : "public"
      )
    : "";
// 解析 server 配置, 也就是 开发服务, hmr, ws 那一套, 下一篇我们着重来讲
// 具体配置可以看 https://vitejs.dev/config/#server-options
const server = resolveServerOptions(resolvedRoot, config.server, logger);
// 解析 ssr 相关, 因为截止到当前版本 ssr 还不够稳定, 本系列可能讲的会比较少, 看情况吧
// 具体配置可以看 https://vitejs.dev/config/#ssr-options
const ssr = resolveSSROptions(config.ssr);
// 拿到优化配置, 有 vite 本身的优化配置, 也有 esbuild 的.
const optimizeDeps = config.optimizeDeps || {};

对插件的后置处理

在一系列操作之后, 其他配置解析的差不多了, 于是 vite 会对插件进行后置处理. 首先 vite 会对 worker 的插件进行处理, 和普通用户插件一样, worker 的插件也需要经过 sortUserPlugins 分治的过程.

// Some plugins that aren't intended to work in the bundling of workers (doing post-processing at build time for example).
// And Plugins may also have cached that could be corrupted by being used in these extra rollup calls.
// So we need to separate the worker plugin from the plugin that vite needs to run.
const [workerPrePlugins, workerNormalPlugins, workerPostPlugins] =
  sortUserPlugins(config.worker?.plugins as Plugin[]);
const workerResolved: ResolvedConfig = {
  ...resolved,
  isWorker: true,
  mainConfig: resolved,
};

然后分别对 worker, user 两组插件进行 resolve, 使用的是 resolvePlugins 方法. 该方法根据 plugin 的执行顺序, 和内置通用插件进行融合.
我们知道 vite 插件有个 configResolved 钩子, 它在解析 vite 配置后调用. 使用这个钩子读取和存储最终解析的配置. 当插件需要根据运行的命令做一些不同的事情时, 它很有用. 所以在所有配置 resolve 之后, 会统一执行这个函数.

resolved.worker.plugins = await resolvePlugins(
  workerResolved,
  workerPrePlugins,
  workerNormalPlugins,
  workerPostPlugins
);
// call configResolved worker plugins hooks
// 在解析 vite 配置后调用. 使用这个钩子读取和存储最终解析的配置
await Promise.all(
  resolved.worker.plugins.map((p) => p.configResolved?.(workerResolved))
);
(resolved.plugins as Plugin[]) = await resolvePlugins(
  resolved,
  prePlugins,
  normalPlugins,
  postPlugins
);
// call configResolved hooks
await Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)));

最后放一下 resolvePlugins 函数, 很明显 vite 运行依赖一些内置插件, 而用户插件可能有顺序要求(enforce), 于是就把用户插件插到适当的位置. 关于详细的插件系统, 我们放到下一篇文章讲解.

export async function resolvePlugins(
  config: ResolvedConfig,
  prePlugins: Plugin[],
  normalPlugins: Plugin[],
  postPlugins: Plugin[]
): Promise<Plugin[]> {
  const isBuild = config.command === "build";
  const isWatch = isBuild && !!config.build.watch;
  const buildPlugins = isBuild
    ? (await import("../build")).resolveBuildPlugins(config)
    : { pre: [], post: [] };
  return [
    isWatch ? ensureWatchPlugin() : null,
    isBuild ? metadataPlugin() : null,
    isBuild ? null : preAliasPlugin(config),
    aliasPlugin({ entries: config.resolve.alias }),
    ...prePlugins, // 前置插件
    config.build.polyfillModulePreload
      ? modulePreloadPolyfillPlugin(config)
      : null,
    ...(isDepsOptimizerEnabled(config)
      ? [
          isBuild
            ? optimizedDepsBuildPlugin(config)
            : optimizedDepsPlugin(config),
        ]
      : []),
    resolvePlugin({
      ...config.resolve,
      root: config.root,
      isProduction: config.isProduction,
      isBuild,
      packageCache: config.packageCache,
      ssrConfig: config.ssr,
      asSrc: true,
      getDepsOptimizer: () => getDepsOptimizer(config),
      shouldExternalize:
        isBuild && config.build.ssr && config.ssr?.format !== "cjs"
          ? (id) => shouldExternalizeForSSR(id, config)
          : undefined,
    }),
    htmlInlineProxyPlugin(config),
    cssPlugin(config),
    config.esbuild !== false ? esbuildPlugin(config.esbuild) : null,
    jsonPlugin(
      {
        namedExports: true,
        ...config.json,
      },
      isBuild
    ),
    wasmHelperPlugin(config),
    webWorkerPlugin(config),
    assetPlugin(config),
    ...normalPlugins, // 普通插件
    wasmFallbackPlugin(),
    definePlugin(config),
    cssPostPlugin(config),
    config.build.ssr ? ssrRequireHookPlugin(config) : null,
    isBuild && buildHtmlPlugin(config),
    workerImportMetaUrlPlugin(config),
    ...buildPlugins.pre,
    dynamicImportVarsPlugin(config),
    importGlobPlugin(config),
    ...postPlugins, // 后置插件
    ...buildPlugins.post,
    // internal server-only plugins are always applied after everything else
    ...(isBuild
      ? []
      : [clientInjectionsPlugin(config), importAnalysisPlugin(config)]),
  ].filter(Boolean) as Plugin[];
}

总结

最后我们贴一下最终解析的配置, 我们做一个复习:

const resolved: ResolvedConfig = {
  ...config,
  configFile: configFile ? normalizePath(configFile) : undefined, // 配置文件路径
  configFileDependencies: configFileDependencies.map((name) =>
    normalizePath(path.resolve(name))
  ), // vite.config.js 中非第三方包的导入, 比如自定义插件
  inlineConfig, // 命令行中的配置
  root: resolvedRoot, // 项目根目录
  base: BASE_URL, // 公共基础路径,  /my-app/index.html
  resolve: resolveOptions, // 文件解析时的相关配置
  publicDir: resolvedPublicDir, // 静态资源服务的文件夹
  cacheDir, // 缓存目录, 默认 node_modules/.vite
  command, // serve | build
  mode, // development | production
  ssr,
  isWorker: false,
  mainConfig: null,
  isProduction, // 是否是生产环境
  plugins: userPlugins, // 自定义 plugins
  server,
  build: resolvedBuildOptions,
  preview: resolvePreviewOptions(config.preview, server),
  env: {
    ...userEnv,
    BASE_URL,
    MODE: mode,
    DEV: !isProduction,
    PROD: isProduction,
  },
  assetsInclude(file: string) {
    return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file);
  },
  logger,
  packageCache: new Map(),
  createResolver, // 特殊场景中使用的内部解析器, 预构建文件中会说到
  optimizeDeps: {
    // 由于优化依赖(也就是依赖预构建)使用了 esbuild, 这里基本就是透传 esbuild 的配置, 当然也有一些 vite 自己的配置: https://vitejs.dev/config/#dep-optimization-options
    ...optimizeDeps,
    esbuildOptions: {
      preserveSymlinks: config.resolve?.preserveSymlinks,
      ...optimizeDeps.esbuildOptions,
    },
  },
  worker: resolvedWorkerOptions,
  spa: config.spa ?? true,
};
  1. 使用 modecommand 构造出核心的环境对象 configEnv

  2. 使用 loadConfigFromFile 读取配置文件, 并使用 mergeConfiginlineConfig 进行递归合并

  3. 将插打平, 通过 apply 过滤出当前环境(serve|build) 需要的插件

  4. 使用 sortUserPlugins, 根据 enforce 字段将所有插件排排队, 存放在 prePlugins, normalPluginspostPlugins 三个数组中

  5. 然后将排好队的插件执行插件中的 config 方法, config 方法是 vite 独有的钩子方法, 它是在解析 vite 配置前调用. 执行完毕后, 再使用 mergeConfig 和当前配置进行递归合并

  6. 创建 createResolver, 特殊场景中使用的内部解析器, 预构建文件中会说到. 这个方法主要使用了 createPluginContainer 函数来连接 rollup 插件系统, 这个下一篇会说

  7. 处理 rootPath; 合并内置和用户自定义的 alias; 处理 env 配置; 处理 baseUrl; 处理 public 文件夹; 处理预解析缓存(node_modules/.vite)目录; 解析 assetsInclude 指定文件类型作为静态资源处理; 解析 server 配置ssr 配置;

  8. 创建 createResolver 方法用于内部指定场景的解析器, 比如 optimizer 或处理 css @import

  9. 执行 resolvePlugins, 合并用户配置的 plugin 和内部已有的 plugin

  10. 依次执行排好序的插件的 configResolved 函数(该函数在解析 vite 配置后调用. 使用这个钩子读取和存储最终解析的配置)

  11. 返回最终 resolved