vite 5.0 源码分析: 创建开发服务器和整合配置项

1,262 阅读23分钟

本文使用vite 5.1.0-beta.3版本

在上文中,我们了解了创建本地服务器之前以及之后的操作,以及文件预热的实现原理

而本文你会学到

  • Vite是如何创建一个开发服务器的
  • Vite如何解析配置项的
  • Vite针对不同来源的配置项,使用的优先级是什么,以及为什么
  • Vite如何建立插件流水线
  • Vite为了适配不同构建工具做了哪些工作

createServer

在上文中,我们了解到,vite在创建服务器的时候,会将rootbasemodeconfigFileconfig位置)、logLevelclearScreenoptimizeDepsserver(cli中剩下的选项)做为inlineConfig传入createServer

createServer是在vite/packages/vite/src/node/server/index.ts定义的,并增加第二个参数{ hotListen: true }调用了_createServer

也就是说_createServer才是真正的创建服务逻辑。

我们看一下_createServer的实现逻辑,它的逻辑会很长,因此我会省略一些本文不会被涉及的代码,以注释代替。

export async function _createServer(
  inlineConfig: InlineConfig = {},
  options: { hotListen: boolean }
): Promise<ViteDevServer> {
  // 解析配置,获取ViteDevServer配置对象
  const config = await resolveConfig(inlineConfig, "serve")

  // 初始化公共文件
  const initPublicFilesPromise = initPublicFiles(config)

  // 获取根目录和服务器配置
  const { root, server: serverConfig } = config
  // 解析HTTPS配置选项
  const httpsOptions = await resolveHttpsConfig(config.server.https)
  // 获取中间件模式
  const { middlewareMode } = serverConfig

  // 解析并设置Chokidar的选项
  const resolvedWatchOptions = resolveChokidarOptions(config, {
    disableGlobbing: true,
    ...serverConfig.watch,
  })

  // 创建Connect中间件
  const middlewares = connect() as Connect.Server
  // 如果是中间件模式,Http服务器为null
  const httpServer = middlewareMode
    ? null
    : await resolveHttpServer(serverConfig, middlewares, httpsOptions)

  // 省略... 创建WebSocket服务器,并添加到热更新广播器中
  // 如果配置中定义了其他热更新通道,也添加到广播器中

  // 省略...如果存在Http服务器,设置客户端错误处理

  // 检查是否启用了文件监视
  const watchEnabled = serverConfig.watch !== null

  // 如果watchEnabled为true。创建Chokidar的文件监视器,否则创建一个FSWatcher类型空对象
  const watcher = // 省略...

  // 初始化模块依赖图
  const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>
    container.resolveId(url, undefined, { ssr })
  )
  // 创建插件容器
  const container = await createPluginContainer(config, moduleGraph, watcher)
  // 创建Http服务器关闭函数
  const closeHttpServer = createServerCloseFn(httpServer)

  // 定义退出进程函数
  let exitProcess: () => void

  // 创建开发服务器对象
  const devHtmlTransformFn = createDevHtmlTransformFn(config)

  let server: ViteDevServer = {
      // 暴露ViteDevServer类型的属性
  }
  // 省略...保持与服务器实例的一致性,用于重新启动后的引用

  // 如果不是中间件模式,监听SIGTERM和stdin结束事件

  // 初始化公共文件
  const publicFiles = await initPublicFilesPromise

  // 省略...定义HMR更新事件处理函数
  const onHMRUpdate = async (file: string, configOnly: boolean) => {
  }

  // 获取公共目录
  const { publicDir } = config

  // 定义文件添加/删除事件处理函数
  const onFileAddUnlink = async (file: string, isUnlink: boolean) => {
    file = normalizePath(file)
    await container.watchChange(file, { event: isUnlink ? "delete" : "create" })

    if (publicDir && publicFiles) {
      if (file.startsWith(publicDir)) {
         // 当公共文件发生变化时
         // 优先使用公共文件而不是具有相同路径的模块提供服务。
         // 这样做是为了避免快速路径转换成模块服务,以提高服务器的效率。
      }
    }
    // 更新模块依赖
    await handleFileAddUnlink(file, server, isUnlink)
  }

  // 监听文件变化事件
  watcher.on("change", async (file) => {
    file = normalizePath(file)
    await container.watchChange(file, { event: "update" })
    // 文件变化时使模块图缓存失效
    moduleGraph.onFileChange(file)
    await onHMRUpdate(file, false)
  })

  // 初始化文件系统工具
  getFsUtils(config).initWatcher?.(watcher)

  // 监听文件添加事件
  watcher.on("add", (file) => {
    onFileAddUnlink(file, false)
  })
  // 监听文件删除事件
  watcher.on("unlink", (file) => {
    onFileAddUnlink(file, true)
  })

  // 省略..监听Vite的HMR失效事件
  // 如果不是中间件模式且Http服务器存在,监听一次'listening'事件
  if (!middlewareMode && httpServer) {
    httpServer.once("listening", () => {
      // 更新实际端口,因为这可能与初始值不同
      serverConfig.port = (httpServer.address() as net.AddressInfo).port
    })
  }

  // 应用来自插件的服务器配置钩子
  const postHooks: ((() => void) | void)[] = []
  for (const hook of config.getSortedPluginHooks("configureServer")) {
    postHooks.push(await hook(reflexServer))
  }

  // 缓存transform中间件
  middlewares.use(cachedTransformMiddleware(server))

  // 代理中间件
  const { proxy } = serverConfig
  if (proxy) {
    const middlewareServer =
      (isObject(serverConfig.middlewareMode)
        ? serverConfig.middlewareMode.server
        : null) || httpServer
    middlewares.use(proxyMiddleware(middlewareServer, proxy, config))
  }

  // 基础路径中间件
  if (config.base !== "/") {
    middlewares.use(baseMiddleware(config.rawBase, middlewareMode))
  }

  // 打开编辑器支持
  middlewares.use("/__open-in-editor", launchEditorMiddleware())

  // 省略ping请求处理器

  // 服务静态文件,位于/public目录下
  // 这应用于transform中间件之前,以便这些文件按原样提供而不进行转换。
  if (publicDir) {
    middlewares.use(servePublicMiddleware(server, publicFiles))
  }

  // transform中间件
  middlewares.use(transformMiddleware(server))

  // 服务静态文件
  middlewares.use(serveRawFsMiddleware(server))
  middlewares.use(serveStaticMiddleware(server))

  // HTML 中间件
  if (config.appType === "spa" || config.appType === "mpa") {
    // 略
  }

  // 运行postHooks
  // 这应用于html中间件之前,以便用户中间件可以提供自定义内容而不是index.html。
  postHooks.forEach((fn) => fn && fn())

  if (config.appType === "spa" || config.appType === "mpa") {
    // 转换index.html
    // 处理404
  }

  // 错误处理中间件
  middlewares.use(errorMiddleware(server, middlewareMode))

  // httpServer.listen可能会被多次调用,当端口使用下一个端口号时
  // 此代码用于避免多次调用buildStart
  let initingServer: Promise<void> | undefined
  let serverInited = false
  const initServer = async () => {
    if (serverInited) return
    if (initingServer) return initingServer

    initingServer = (async function () {
      await container.buildStart({})
      // 在所有容器插件准备就绪后启动deps优化器
      if (isDepsOptimizerEnabled(config, false)) {
        await initDepsOptimizer(config, server)
      }
      // 预热文件
      warmupFiles(server)
      initingServer = undefined
      serverInited = true
    })()
    return initingServer
  }

  // 如果不是中间件模式且Http服务器存在,覆盖listen以在服务器启动之前初始化优化器
  if (!middlewareMode && httpServer) {
    const listen = httpServer.listen.bind(httpServer)
    httpServer.listen = (async (port: number, ...args: any[]) => {
      try {
        // 确保ws服务器已启动
        hot.listen()
        await initServer()
      }
      return listen(port, ...args)
    }) as any
  } else {
    // 如果是中间件模式或者没有 HTTP 服务器,或者通过选项配置了热更新监听
    if (options.hotListen) {
      // 启动热更新监听
      hot.listen()
    }
    await initServer()
  }
  return server
}

在开始阶段,通过解析配置、初始化公共文件以及获取根目录和服务器配置,给服务器创建提供了上下文。

接着,进行了文件监视的处理,使用Chokidar解析选项,并创建了Connect中间件。

然后,根据上面的配置,创建了HTTP服务器和WebSocket服务器。

然后,初始化了模块依赖图,需要注意,这里并没有开始创建模块依赖图,而是做了初始化。

接着,创建了插件容器,我们可以看到初始化模块依赖图实际就是将插件容器的resolveId逻辑传进去。

在初始化插件容器的逻辑里面,会触发options钩子。

在后面,处理了HMR更新事件、文件添加和删除事件,这些事件都会操作模块依赖图。

然后定义了缓存transform中间件、代理中间件、基础路径中间件。

还应用了编辑器支持中间件、服务静态文件中间件、transform中间件(在这一步创建的模块依赖图)等。

之后,触发了configureServer钩子。

然后定义了listen函数,执行listen函数会执行buildStart钩子(buildStart再次触发options钩子),进行依赖预构建以及预热文件,并使用serverInited变量确保只执行一次。

最后,返回创建的server实例。

因此我们可以梳理出以下大概的流程:

  1. 解析配置
  2. 初始化HTTP服务器和WebSocket服务器
  3. 初始化模块依赖图
  4. 创建插件容器,触发options钩子
  5. 创建server,也就是createServer的返回值
  6. 应用中间件,在应用indexHtmlMiddleware之前触发configureServer钩子
  7. 创建listen函数
  8. 返回server

其中listen函数执行会触发以下流程

  1. WebSocket开始监听
  2. 触发buildStart钩子,触发options钩子
  3. 如果可以,进行依赖预构建
  4. 文件预热

解析配置

解析配置主要靠resolveConfig,我们可以在vite/packages/vite/src/node/config.ts找到它的源码。

在调用resolveConfig的时候,我们会将inlineConfig以及serve作为入参传入其中。

我们分步骤看下

vite.config

export async function resolveConfig(
  inlineConfig: InlineConfig,
  command: 'build' | 'serve',
  defaultMode = 'development',
  defaultNodeEnv = 'development',
  isPreview = false,
): Promise<ResolvedConfig> {
  let config = inlineConfig 
  let configFileDependencies: string[] = []
  let mode = inlineConfig.mode || defaultMode // mode,使用defaultMode进行兜底
  const isNodeEnvSet = !!process.env.NODE_ENV // 判断是否已经设置了 NODE_ENV

  // 一些依赖项(例如 @vue/compiler-*)依赖 NODE_ENV 来获取生产环境特定的行为,因此在此处设置
  if (!isNodeEnvSet) {
    process.env.NODE_ENV = defaultNodeEnv // 如果未设置 NODE_ENV,则设置为默认 Node 环境
  }

  // 定义配置环境
  const configEnv: ConfigEnv = {
    mode,
    command,
    isSsrBuild: command === 'build' && !!config.build?.ssr,
    isPreview,
  }

  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 // 更新配置文件的依赖项
    }
  }
  // 用户配置可能提供替代模式,但 --mode 具有更高的优先级
  mode = inlineConfig.mode || config.mode || mode
  configEnv.mode = mode // 更新配置环境中的模式
  // 略
}

首先开始这一段逻辑,尝试获取inlineConfigmode,如果没有那么使用defaultMode进行兜底,同时针对process.env.NODE_ENV做了兼容处理。

然后获取configFile,也就是我们熟知的vite.config.ts的路径,如果configFile没有明确设置为false,那么都会执行loadConfigFromFile

loadConfigFromFile会根据传入的路径获取配置文件,如果传入路径为空,那么就去项目根目录,通过一个数组循环获取固定的配置文件,如果找到一个就会跳出循环,返回文件。

因此这里实际上是存在隐藏优先级的。

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

从上文看,如果存在多个vite.config且没有明确指定配置文件路径,vite.config.js的优先级是最高的。

如果成功获取配置文件,这个函数的逻辑还没有结束,它会判断当前文件是否是ESM,我们都知道,在不读取文件的情况下,如果文件后缀没有显式指定模块类型,的确不能判断文件是否是是ESM,因此需要观察package.jsontype字段。

isFilePathESM函数就是如此通过上面的逻辑来判断传入的文件是否是ESM

  1. 如果后缀是mts或者mjs那么就是ESM
  2. 如果后缀是cts或者cjs那么就不是ESM
  3. 通过findNearestPackageData读取package.json,如果typemodule那么就是ESM,反之不是。

findNearestPackageData做了什么?这个函数我们之后还会见到,这里我们分析一下。 这个函数同isFilePathESM一样,接收两个参数,一个是寻找路径,一个是package.json的缓存,在这里只使用了第一个参数。

如果传入缓存的话,它会将传入的路径作为key从缓存中寻找package.json

如果没有传入缓存或者当前路径不存在package.json,那么就把上层目录当做当前路径。然后再从缓存寻找,然后再次从当前路径寻找。

如果找到,那么存入缓存。如果始终寻找不到,返回null

而存入缓存的时候,key在绝对路径的基础上,前面会拼上fnpd_字符串,并且,如果非当前目录寻找到package.json,那么会将寻找到的目录到当前目录的所有目录都会存入缓存

比如从/a/b/c/d目录寻找,最后在/a找到了package.json,那么缓存的key就是fnpd_/a/b/c/dfnpd_/a/b/cfnpd_/a/bfnpd_/a这四个,valueVite基于package.json封装的数据结构。

总而言之,我们通过isFilePathESM得到了配置文件的模块类型。

它会使用bundleConfigFile通过esbuild把配置文件进行代码转换为cjs,接着使用loadConfigFromBundledFile获取文件中的配置数据。

一旦有了配置数据,那么就使用mergeConfig,一个根据不同字段执行不同合并策略的函数,把配置数据和inlineConfig进行合并,并使用合并结果更新config,同时更新configFileconfigFileDependencies

然后更新mode参数,因为mode的来源也有很多,因此也存在优先级,--mode形式优先级最高,然后是配置文件中的mode选项。最后以defaultMode进行兜底。

plugins

我们接着看plugins的处理逻辑

export async function resolveConfig(
  inlineConfig: InlineConfig,
  command: 'build' | 'serve',
  defaultMode = 'development',
  defaultNodeEnv = 'development',
  isPreview = false,
): Promise<ResolvedConfig> {
  // 略
  
  const filterPlugin = (p: Plugin) => {
    if (!p) {
      return false
    } else if (!p.apply) {
      return true
    } else if (typeof p.apply === 'function') {
      return p.apply({ ...config, mode }, configEnv)
    } else {
      return p.apply === command
    }
  }
  // 扁平化并过滤插件,没有apply,或者apply是个函数返回true,或者apply为当前的command就保留
  const rawUserPlugins = (
    (await asyncFlatten(config.plugins || [])) as Plugin[]
  ).filter(filterPlugin)
  

  //给插件排序
  const [prePlugins, normalPlugins, postPlugins] =
    sortUserPlugins(rawUserPlugins)

  // 运行config钩子
  const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
  config = await runConfigHook(config, userPlugins, configEnv)
  // 略
}

我们知道,vite.configplugins可以是一个多维数组,因此这里使用asyncFlatten依靠Promise.all来执行这些插件,并使用flat(Infinity),对结果进行扁平化。

然后执行了数组的filter方法,如果插件没有apply,或者存在apply且是个函数,那么会执行这个函数,如果返回值true,或者apply等于当前command,就会保留。

这与文档上的行文是对应的:

默认情况下插件在开发 (serve) 和生产 (build) 模式中都会调用。如果插件在服务或构建期间按需使用,请使用 apply 属性指明它们仅在 'build' 或 'serve' 模式时调用

之后,对需要执行的插件,会使用sortUserPlugins进行排序,其中的逻辑也很简单。

  • enforcepre的归为prePlugins数组。
  • enforcepost的归为postPlugins数组。
  • 其他的归为normalPlugins数组。

然后按照[prePlugins, normalPlugins, postPlugins]顺序,赋值给userPlugins

configVite独有的钩子,它在解析 Vite 配置前调用。可以返回部分配置项,使用上文提到的mergeConfig,对config进行合并。

因此,既然插件已经排好序了,目前又是解析配置阶段,所以直接触发config钩子,来获取插件针对配置项的修改。

这里需要注意的是,传入插件的配置项并没有深克隆,所以直接修改也是可以的,并且官方也支持这种做法:

将被深度合并到现有配置中的部分配置对象,或者直接改变配置(如果默认的合并不能达到预期的结果)

但这种做法并非第一选择,如果可以,还是使用返回部分配置项,让Vite自主合并比较好。

在这里,虽然他们被整合为userPlugins,暂时赋值给resolved.plugins,但返回最终配置项的时候,实际会使用resolvePlugins进行进一步封装。

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

resolvePlugins是什么?

这个其实就是Vite的插件流水线,它会收集所有的插件——包括Vite自己的,以及用户传入的插件,然后返回一个排好序的插件数组。

我们直接看看它的代码。

export async function resolvePlugins(
  config: ResolvedConfig,
  prePlugins: Plugin[],
  normalPlugins: Plugin[],
  postPlugins: Plugin[]
): Promise<Plugin[]> {
  const isBuild = config.command === "build" // 是否是build
  const isWorker = config.isWorker // 是否是 Worker,worker配置项会讲
  const buildPlugins = isBuild
    ? await (await import("../build")).resolveBuildPlugins(config) // 如果是构建命令,动态导入并build相关插件
    : { pre: [], post: [] }
  const { modulePreload } = config.build
  const depsOptimizerEnabled =
    !isBuild &&
    (isDepsOptimizerEnabled(config, false) ||
      isDepsOptimizerEnabled(config, true)) // 是否启用 依赖预构建
  return [
    depsOptimizerEnabled ? optimizedDepsPlugin(config) : null, // 如果启用依赖预构建,添加依赖预构建插件
    isBuild ? metadataPlugin() : null, // 如果是build,添加metadata插件
    !isWorker ? watchPackageDataPlugin(config.packageCache) : null, // 如果不是 Worker 模式,添加watch package data插件
    preAliasPlugin(config), // alias插件
    aliasPlugin({
      entries: config.resolve.alias,
      customResolver: viteAliasCustomResolver,
    }),
    ...prePlugins, // 传入的 pre 的插件
    modulePreload !== false && modulePreload.polyfill
      ? modulePreloadPolyfillPlugin(config)
      : null, // 如果启用modulePreload并配置了 polyfill,添加module preload polyfill 插件
    resolvePlugin(),
    // 略 // 解析路径插件
    htmlInlineProxyPlugin(config), // HTML 内联代理插件
    cssPlugin(config), // CSS 插件
    config.esbuild !== false ? esbuildPlugin(config) : null, // 如果启用 esbuild,添加 esbuild 插件
    jsonPlugin(
      {
        namedExports: true,
        ...config.json,
      },
      isBuild
    ), // JSON 插件
    wasmHelperPlugin(config), // wasm插件
    webWorkerPlugin(config), // Web Worker 插件
    assetPlugin(config), // 静态资源插件
    ...normalPlugins, // 传入 normal 插件
    wasmFallbackPlugin(), // wasm fallback插件
    definePlugin(config), // define 插件
    cssPostPlugin(config), // css post 处理插件
    isBuild && buildHtmlPlugin(config), // 如果是build令,添加build HTML 插件
    workerImportMetaUrlPlugin(config), // Worker 的 import.meta.url 插件
    assetImportMetaUrlPlugin(config), // asset的 import.meta.url 插件
    ...buildPlugins.pre, // 添加 buildPlugins 中的 pre 插件
    dynamicImportVarsPlugin(config), // 动态导入插件
    importGlobPlugin(config), // Glob 插件
    ...postPlugins, // 添加传入的 post 处理插件
    ...buildPlugins.post, // 添加 buildPlugins 中的 post 插件
    // 开发服务器使用的插件始终在所有其他插件之后应用
    ...(isBuild
      ? []
      : [
          clientInjectionsPlugin(config), // 客户端注入插件
          cssAnalysisPlugin(config), // CSS分析及重写插件
          importAnalysisPlugin(config), //  import分析及重写插件
        ]),
  ].filter(Boolean) as Plugin[] // 过滤掉数组中的空值
}

也就是说,这个函数将我们传入的插件,根据当前环境,放入一个插件流水线中,最终返回一个Vite所需要的完整的、有顺序的插件数组。

root、resolve、envDir

我们接着看看做了什么。

export async function resolveConfig(
  inlineConfig: InlineConfig,
  command: 'build' | 'serve',
  defaultMode = 'development',
  defaultNodeEnv = 'development',
  isPreview = false,
): Promise<ResolvedConfig> {
  // 略
  // 解析根路径
  const resolvedRoot = normalizePath(
    config.root ? path.resolve(config.root) : process.cwd(),
  )
  const clientAlias = [
    {
      find: /^\/?@vite\/env/,
      replacement: path.posix.join(FS_PREFIX, normalizePath(ENV_ENTRY)),
    },
    {
      find: /^\/?@vite\/client/,
      replacement: path.posix.join(FS_PREFIX, normalizePath(CLIENT_ENTRY)),
    },
  ]

   // 定义以及解析别名
  const resolvedAlias = normalizeAlias(
    mergeAlias(clientAlias, config.resolve?.alias || []),
  )

  const resolveOptions: ResolvedConfig['resolve'] = {
    mainFields: config.resolve?.mainFields ?? DEFAULT_MAIN_FIELDS,
    conditions: config.resolve?.conditions ?? [],
    extensions: config.resolve?.extensions ?? DEFAULT_EXTENSIONS,
    dedupe: config.resolve?.dedupe ?? [],
    preserveSymlinks: config.resolve?.preserveSymlinks ?? false,
    alias: resolvedAlias,
  }
  // 加载 .env 文件
  const envDir = config.envDir
    ? normalizePath(path.resolve(resolvedRoot, config.envDir))
    : resolvedRoot
  const userEnv =
    inlineConfig.envFile !== false &&
    loadEnv(mode, envDir, resolveEnvPrefix(config))

  // 设置userNodeEnv
  const userNodeEnv = process.env.VITE_USER_NODE_ENV
    if (!isNodeEnvSet && userNodeEnv) {
    if (userNodeEnv === 'development') {
      process.env.NODE_ENV = 'development'
    } 
  }
  // 略
}

首先,会获取root作为resolvedRoot,如果root不存在,那么就使用process.cwd()

这里还使用了normalizePath,这个函数我们会经常看到,它做的就是处理不同平台的文件路径。

接着定义了clientAlias,它们是注入到项目文件的脚本。

然后使用normalizeAliasmergeAlias,将配置文件中的alias,从key: value形式,转换为{find: key, replacement: value}的形式,推入resolvedAlias之中。

然后把配置中的resolve包装成resolveOptions——如果没有值,则以默认值代替,形成一个全新的resolve,在最后返回的时候,则以包装后的resolve返回,

然后是envDiruserEnv。在上文中我们已经知道了resolvedRoot,此时如果从配置中找不到envDir,则默认为resolvedRoot

接着,如果没有在inlineConfig中禁止envFile,那么就会使用loadEnv加载环境文件,也就是.env 文件。

这里需要注意一点,envFile并非配置文件中的配置项,而是inlineConfig中的!

也就是说从命令行或者函数调用才有这个配置。

loadEnv所需要的入参我们在上文已经得到了——除了resolveEnvPrefix

resolveEnvPrefix并没有在当前文件中被定义,但它的逻辑比较简单。

就是读取配置中的envPrefix,如果没有那么就给它一个VITE_默认值,并且,envPrefix最后都会被转为字符串数组。

也就是说,envPrefix默认值是['VITE_']

我们接着看loadEnv

export function loadEnv(
  mode: string, 
  envDir: string, 
  prefixes: string | string[] = 'VITE_', 
): Record<string, string> {
  // 检查是否使用了名为 "local" 的模式,因为它与 .local 后缀的 .env 文件冲突
  if (mode === 'local') {}
  
  prefixes = arraify(prefixes)   // 将前缀转换为数组形式

  const env: Record<string, string> = {}   // 存储解析后的环境变量对象
  const envFiles = getEnvFilesForMode(mode, envDir)   // 获取特定模式下的环境文件列表

  const parsed = Object.fromEntries(
    envFiles.flatMap((filePath) => {
      if (!tryStatSync(filePath)?.isFile()) return []

      return Object.entries(parse(fs.readFileSync(filePath)))
    }),
  )   // 读取环境文件内容并解析成键值对形式

  // 检查是否存在 NODE_ENV,并在没有手动设置 VITE_USER_NODE_ENV 的情况下进行覆盖
  if (parsed.NODE_ENV && process.env.VITE_USER_NODE_ENV === undefined) {
    process.env.VITE_USER_NODE_ENV = parsed.NODE_ENV
  }
  
  // 支持 BROWSER 和 BROWSER_ARGS 环境变量
  if (parsed.BROWSER && process.env.BROWSER === undefined) {
    process.env.BROWSER = parsed.BROWSER
  }
  if (parsed.BROWSER_ARGS && process.env.BROWSER_ARGS === undefined) {
    process.env.BROWSER_ARGS = parsed.BROWSER_ARGS
  }

  // 允许环境变量之间互相引用
  expand({ parsed })

  // 仅将以指定前缀开头的键暴露给client
  for (const [key, value] of Object.entries(parsed)) {
    if (prefixes.some((prefix) => key.startsWith(prefix))) {
      env[key] = value
    }
  }

  // 检查是否有真实的环境变量以 prefixes 定义的开头
  // 这些通常是内联提供的,并应该具有优先级
  for (const key in process.env) {
    if (prefixes.some((prefix) => key.startsWith(prefix))) {
      env[key] = process.env[key] as string
    }
  }
  return env   // 返回解析后的环境变量对象
}

loadEnv会根据getEnvFilesForMode给予的列表读取env文件。

[
    /** default file */ `.env`,
    /** local file */ `.env.local`,
    /** mode file */ `.env.${mode}`,
    /** mode local file */ `.env.${mode}.local`,
  ]

需要注意的是,这里的排序并非越往前优先级越高,而是越往后优先级越高。

因为这里使用了Object.fromEntries,前面的值会被后面的值覆盖。

然后通过parsed设置了process.env

parsed的环境变量不会都注入userEnv,后面再次使用Object.entriesparsed进行过滤,只保留prefixes定义的开头的环境变量。

最后会在process.env寻找prefixes定义的开头的环境变量,也放入env也就是返回值中。

从这里可以看出来loadEnv都会返回一个键值对对象,而它的来源不仅仅是env文件,还可能是process.env,并且process.env中带有指定前缀的具有较高优先级。

同时,userEnv也可能是false(inlineConfig.envFilefalse的时候会被处理为false)。

base

针对baseVite的在开发阶段和生产阶段进行了不同的处理。

  const relativeBaseShortcut = config.base === '' || config.base === './'
  const resolvedBase = relativeBaseShortcut
    ? !isBuild || config.build?.ssr
      ? '/'
      : './'
    : resolveBaseUrl(config.base, isBuild, logger) ?? '/'

如果base是空字符或者'./'的情况,那么在开发阶段或者SSR构建会被重写为'/'

也就是说开发阶段会忽略相对路径并回退到 '/',而SSR的情况下,也无法使用import.meta.url来实现相对路径,因此都重写为'/'

而在非SSR的生产阶段,base是空字符或者'./'的情况会被重写为'./'

如果不是上述两种情况,那么就进入resolveBaseUrl函数,如果resolveBaseUrl有返回值那么使用它的返回值,否则使用'/'兜底。

那么resolveBaseUrl做了什么呢?

  1. 如果以'.'开头,那么给出警告,指示其无效,然后将其设为 '/'
  2. 如果 base 不是以 '/' 开头,给出警告,建议以斜杠开头。
  3. 如果是其他情况(大部分情况,比如/app),会使用一个技巧:base = new URL(base, 'http://vitejs.dev').pathname,使用这种方式,可以确保base会以'/'开头。
  4. 如果'http://''https://' 开头,那么原路返回base,这种情况多见于CDN的方式。

build

  const resolvedBuildOptions = resolveBuildOptions(
    config.build,
    logger,
    resolvedRoot,
  )

Vite使用了一个专门的函数处理build配置,这个函数并非一个泛用函数,因此这里就不逐行解析,而是概况一下这个函数做了什么。

resolveBuildOptions 首先检查polyfillModulePreload是否存在,如果存在则发出警告提示用户使用新的选项 modulePreload.polyfill

然后,定义了默认的构建选项,包括输出目录、资源目录、CSS 代码拆分等。使用上文提到的mergeConfig合并传入的build,这样对于build没有填入的配置也有默认值,从而得到 userBuildOptions

在构建resolved 返回值的时候,使用上文的得到的userBuildOptions进行进行填充,并且处理了modulePreload 将其规范化为一个对象。

在对于target'modules'的情况使用ESBUILD_MODULES_TARGET进行了覆盖,以确保与esbuild兼容。

而对于target'esnext'且使用minify指定为terser,会检查terser版本,如果小于5.16会使用'es2021'覆盖target

如果cssTargetfalse,那么会被赋值为target的值。

对于 minify,如果传入的对应配置是字符串'false',会转为布尔值,同样,cssMinify 如果为null, 那么会被赋值为minify的值。

最后,返回了解析后的构建选项对象 resolved

pkgDir、cacheDir

我们注意到pkgDir使用了我们上文讲到的函数findNearestPackageData获取,这一次,传入了缓存packageCache

  const packageCache: PackageCache = new Map()
  const pkgDir = findNearestPackageData(resolvedRoot, packageCache)?.dir
  const cacheDir = normalizePath(
    config.cacheDir
      ? path.resolve(resolvedRoot, config.cacheDir)
      : pkgDir
        ? path.join(pkgDir, `node_modules/.vite`)
        : path.join(resolvedRoot, `.vite`),
  )

在获取了pkgDir之后,Vite开始获取预构建产物的目录,首先它会查看cacheDir是否被赋值,如果被赋值的话,那么就使用cacheDir,否则就使用跟pkgDir同级的node_modules/.vite

如果没有pkgDir,那么使用项目根目录下的.vite目录。

assetsInclude、publicDir

接下来是静态资源的处理

  // 静态资源处理
  const assetsFilter =
    config.assetsInclude &&
    (!Array.isArray(config.assetsInclude) || config.assetsInclude.length)
      ? createFilter(config.assetsInclude)
      : () => false

我们注意到,如果assetsInclude不是一个有长度的数组,最后都会被定义为返回false的函数。

反之,会进入createFiltercreateFilter@rollup/pluginutils定义的一个方法,在这里就是返回一函数——如果传入函数的路径符合assetsInclude,那么返回true,否则返回false

publicDir就简单多了

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

如果publicDir是一个有效值——非false且非空字符串,那么会尝试跟项目路径一起拼接起来,这里还会检查publicDir是否是一个字符串,如果非字符串,则以'public'作为默认值。

反之resolvedPublicDir就是空字符串。

serve、ssr

build一样,这两个配置项使用的并非一个泛用函数。

const server = resolveServerOptions(resolvedRoot, config.server, logger)
const ssr = resolveSSROptions(config.ssr, resolveOptions.preserveSymlinks)

我们看看resolveServerOptions

export function resolveServerOptions(
  root: string,
  raw: ServerOptions | undefined,
  logger: Logger,
): ResolvedServerOptions {
  const server: ResolvedServerOptions = {
    preTransformRequests: true,
    ...(raw as Omit<ResolvedServerOptions, 'sourcemapIgnoreList'>),
    sourcemapIgnoreList:
      raw?.sourcemapIgnoreList === false
        ? () => false
        : raw?.sourcemapIgnoreList || isInNodeModules,
    middlewareMode: !!raw?.middlewareMode,
  }
  let allowDirs = server.fs?.allow
  const deny = server.fs?.deny || ['.env', '.env.*', '*.{crt,pem}']
  allowDirs = // 略
  const resolvedClientDir = // 略
  server.fs = {
    strict: server.fs?.strict ?? true,
    allow: allowDirs,
    deny,
    cachedChecks:
      server.fs?.cachedChecks ?? !!process.env.VITE_SERVER_FS_CACHED_CHECKS,
  }

  if (server.origin?.endsWith('/')) {
    server.origin = server.origin.slice(0, -1)
  }
  return server
}

它会在server补充preTransformRequests:true默认项,并规范化middlewareMode,把它转为布尔值,对于sourcemapIgnoreList,如果是false,那么包装为一个函数返回,如果为空那么给予一个默认函数(如果路径包含'node_modules'返回true)。

然后处理fs配置项

  • 设置 fs.strict 属性,默认为 true
  • 处理 fs.allow 属性,将其转换为数组并处理每个元素,确保每个路径都是绝对路径。
  • 设置 fs.deny 属性的默认值为 ['.env', '.env. *', '* .{crt,pem}']
  • 设置 fs.cachedChecks 属性的默认值为!!process.env.VITE_SERVER_FS_CACHED_CHECKS

origin 以斜杠结尾,则去掉斜杠。

最后返回处理后的server

resolveSSROptions的处理就简单的多。

export function resolveSSROptions(
  ssr: SSROptions | undefined,
  preserveSymlinks: boolean,
): ResolvedSSROptions {
  ssr ??= {}
  const optimizeDeps = ssr.optimizeDeps ?? {}
  const target: SSRTarget = 'node'
  return {
    target,
    ...ssr,
    optimizeDeps: {
      ...optimizeDeps,
      noDiscovery: true, 
      esbuildOptions: {
        preserveSymlinks,
        ...optimizeDeps.esbuildOptions,
      },
    },
  }
}

它会确保ssr.target的默认值是node

对于ssr.optimizeDeps,它会优先使用传入的optimizeDeps属性,不过对于optimizeDeps.noDiscovery,会被固定为true

对于optimizeDeps.esbuildOptions.preserveSymlinks,会优先使用resolveOptions.preserveSymlinks的值。(我们在上文包装了resolveOptions)

但若在ssr.optimizeDeps.esbuildOptions.preserveSymlinks指定了值,它优先级大于resolveOptions.preserveSymlinks

最后返回包装后的对象。

worker

  let createUserWorkerPlugins = config.worker?.plugins
  if (Array.isArray(createUserWorkerPlugins)) {
    createUserWorkerPlugins = () => config.worker?.plugins
  }
  const createWorkerPlugins = async function () {
      //略
  }
  const resolvedWorkerOptions: ResolvedWorkerOptions = {
    format: config.worker?.format || 'iife',
    plugins: createWorkerPlugins,
    rollupOptions: config.worker?.rollupOptions || {},
  }

可以看到,worker.format的默认值被设置为iiferollupOptions也由配置项直接传入。但是plugins却由createWorkerPlugins进行包装。

如果config.worker?.plugins是一个数组,那么最终会被包装成一个函数,然后交给createWorkerPlugins处理。

createWorkerPlugins只是定义了函数,它并没有执行。

在它的逻辑中,同plugins一样使用asyncFlatten来扁平化插件,然后根据apply进行过滤。然后使用sortUserPlugins进行排序,同样地,使用runConfigHook触发config钩子,来获取并整合这些插件返回的配置项workerConfig

   const workerResolved: ResolvedConfig = {
      ...workerConfig,
      ...resolved,
      isWorker: true,
      mainConfig: resolved,
    }
    const resolvedWorkerPlugins = await resolvePlugins(
      workerResolved,
      workerPrePlugins,
      workerNormalPlugins,
      workerPostPlugins,
    )

workerConfig是根据config.worker?.plugins得出的配置项,虽然它是在覆盖inlineConfig配置项基础上得来的,但这些配置项的优先级并不高,又被resolved覆盖了,resolved就是我们之前逐步解析的配置项的整合的对象,也是resolveConfig最终的返回值。

我们注意到,之后被resolvePlugins这个函数处理了。

这个函数我们之前已经讲过,与之前不同的是,这里将isWorker置为true,意味着不会增加watchPackageDataPlugin插件。

最后这些插件会被触发configResolved钩子。

当然,以上是createWorkerPlugins执行后的逻辑,目前它仅仅是在这里定义,并没有执行。

resolved

最后,之前解析出来的配置项,以及两个工具函数getSortedPluginsgetSortedPluginHooks,一起都被整合为resolved对象,作为resolveConfig的返回值。

getSortedPlugins的作用是根据传入的钩子名称,从插件流水线中,获取排好序的插件数组,而排序规则跟插件的规则相同:pre靠前,post靠后,其他的放在中间。

可能到这里大家不太理解为什么这里又有一个排序,这里解释一下,不光插件有排序,插件中的钩子也是有排序规则的,不同于插件使用enforce定义插件顺序,钩子的顺序使用order来定义。

getSortedPluginHooksgetSortedPlugins更进一步的封装,它会将钩子对应的执行逻辑收集起来,根据上面的排序规则排列成一个数组,然后返回。

结束

我们这里大概了解了Vite如何创建本地服务器,以及如何合并配置项的,接下来,我们顺着createServer的脚步,了解整合了配置项之后,createServer又具体做了什么。