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

本文使用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的实现逻辑,它的逻辑会很长,因此我会省略一些本文不会被涉及的代码,以注释代替。

typescript 复制代码
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

typescript 复制代码
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会根据传入的路径获取配置文件,如果传入路径为空,那么就去项目根目录,通过一个数组循环获取固定的配置文件,如果找到一个就会跳出循环,返回文件。

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

typescript 复制代码
 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的处理逻辑

typescript 复制代码
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进行进一步封装。

typescript 复制代码
;(resolved.plugins as Plugin[]) = await resolvePlugins(
    resolved,
    prePlugins,
    normalPlugins,
    postPlugins,
  )

resolvePlugins是什么?

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

我们直接看看它的代码。

typescript 复制代码
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

我们接着看看做了什么。

typescript 复制代码
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

typescript 复制代码
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文件。

typescript 复制代码
[
    /** 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的在开发阶段和生产阶段进行了不同的处理。

typescript 复制代码
  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

typescript 复制代码
  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

typescript 复制代码
  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

接下来是静态资源的处理

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

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

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

publicDir就简单多了

typescript 复制代码
  // 解析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一样,这两个配置项使用的并非一个泛用函数。

typescript 复制代码
const server = resolveServerOptions(resolvedRoot, config.server, logger)
const ssr = resolveSSROptions(config.ssr, resolveOptions.preserveSymlinks)

我们看看resolveServerOptions

typescript 复制代码
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的处理就简单的多。

typescript 复制代码
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

typescript 复制代码
  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

typescript 复制代码
   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又具体做了什么。

相关推荐
再学一点就睡2 分钟前
双 Token 认证机制:从原理到实践的完整实现
前端·javascript·后端
wallflower20204 分钟前
滑动窗口算法在前端开发中的探索与应用
前端·算法
蚂蚁绊大象5 分钟前
flutter第二话题-布局约束
前端
龙在天7 分钟前
我是前端,scss颜色函数你用过吗?
前端
Mapmost14 分钟前
单体化解锁3DGS场景深层交互价值,让3DGS模型真正被用起来!
前端
幻灵尔依41 分钟前
前端编码统一规范
javascript·vue.js·代码规范
欢脱的小猴子41 分钟前
VUE3加载cesium,导入czml的星座后页面卡死BUG 修复
前端·vue.js·bug
高级测试工程师欧阳43 分钟前
CSS 基础概念
前端·css·css3
前端小巷子43 分钟前
JS 实现图片瀑布流布局
前端·javascript·面试
Juchecar1 小时前
AI教你常识之 npm / pnpm / package.json
前端