本文使用vite 5.1.0-beta.3版本
在上文中,我们了解了创建本地服务器之前以及之后的操作,以及文件预热的实现原理。
而本文你会学到
Vite是如何创建一个开发服务器的Vite如何解析配置项的Vite针对不同来源的配置项,使用的优先级是什么,以及为什么Vite如何建立插件流水线Vite为了适配不同构建工具做了哪些工作
createServer
在上文中,我们了解到,vite在创建服务器的时候,会将root、base、mode、configFile(config位置)、logLevel、clearScreen、optimizeDeps、server(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实例。
因此我们可以梳理出以下大概的流程:
解析配置
初始化
HTTP服务器和WebSocket服务器初始化模块依赖图
创建插件容器,触发
options钩子创建
server,也就是createServer的返回值应用中间件,在应用
indexHtmlMiddleware之前触发configureServer钩子创建
listen函数返回
server
其中listen函数执行会触发以下流程
WebSocket开始监听触发
buildStart钩子,触发options钩子如果可以,进行依赖预构建
文件预热
解析配置
解析配置主要靠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 // 更新配置环境中的模式
// 略
}首先开始这一段逻辑,尝试获取inlineConfig的mode,如果没有那么使用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.json的type字段。
isFilePathESM函数就是如此通过上面的逻辑来判断传入的文件是否是ESM。
如果后缀是
mts或者mjs那么就是ESM。如果后缀是
cts或者cjs那么就不是ESM。通过
findNearestPackageData读取package.json,如果type是module那么就是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/d、fnpd_/a/b/c、fnpd_/a/b、fnpd_/a这四个,value是Vite基于package.json封装的数据结构。
总而言之,我们通过isFilePathESM得到了配置文件的模块类型。
它会使用bundleConfigFile通过esbuild把配置文件进行代码转换为cjs,接着使用loadConfigFromBundledFile获取文件中的配置数据。
一旦有了配置数据,那么就使用mergeConfig,一个根据不同字段执行不同合并策略的函数,把配置数据和inlineConfig进行合并,并使用合并结果更新config,同时更新configFile和configFileDependencies
然后更新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.config中plugins可以是一个多维数组,因此这里使用asyncFlatten依靠Promise.all来执行这些插件,并使用flat(Infinity),对结果进行扁平化。
然后执行了数组的filter方法,如果插件没有apply,或者存在apply且是个函数,那么会执行这个函数,如果返回值true,或者apply等于当前command,就会保留。
这与文档上的行文是对应的:
默认情况下插件在开发 (serve) 和生产 (build) 模式中都会调用。如果插件在服务或构建期间按需使用,请使用
apply属性指明它们仅在'build'或'serve'模式时调用
之后,对需要执行的插件,会使用sortUserPlugins进行排序,其中的逻辑也很简单。
enforce为pre的归为prePlugins数组。enforce为post的归为postPlugins数组。其他的归为
normalPlugins数组。
然后按照[prePlugins, normalPlugins, postPlugins]顺序,赋值给userPlugins。
config是Vite独有的钩子,它在解析 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,它们是注入到项目文件的脚本。
然后使用normalizeAlias和mergeAlias,将配置文件中的alias,从key: value形式,转换为{find: key, replacement: value}的形式,推入resolvedAlias之中。
然后把配置中的resolve包装成resolveOptions——如果没有值,则以默认值代替,形成一个全新的resolve,在最后返回的时候,则以包装后的resolve返回,
然后是envDir与userEnv。在上文中我们已经知道了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.entries对parsed进行过滤,只保留prefixes定义的开头的环境变量。
最后会在process.env寻找prefixes定义的开头的环境变量,也放入env也就是返回值中。
从这里可以看出来loadEnv都会返回一个键值对对象,而它的来源不仅仅是env文件,还可能是process.env,并且process.env中带有指定前缀的具有较高优先级。
同时,userEnv也可能是false(inlineConfig.envFile是false的时候会被处理为false)。
base
针对base,Vite的在开发阶段和生产阶段进行了不同的处理。
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做了什么呢?
如果以
'.'开头,那么给出警告,指示其无效,然后将其设为'/'。如果
base不是以'/'开头,给出警告,建议以斜杠开头。如果是其他情况(大部分情况,比如
/app),会使用一个技巧:base = new URL(base, 'http://vitejs.dev').pathname,使用这种方式,可以确保base会以'/'开头。如果
'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。
如果cssTarget是false,那么会被赋值为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的函数。
反之,会进入createFilter,createFilter是@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的默认值被设置为iife。rollupOptions也由配置项直接传入。但是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
最后,之前解析出来的配置项,以及两个工具函数getSortedPlugins、getSortedPluginHooks,一起都被整合为resolved对象,作为resolveConfig的返回值。
getSortedPlugins的作用是根据传入的钩子名称,从插件流水线中,获取排好序的插件数组,而排序规则跟插件的规则相同:pre靠前,post靠后,其他的放在中间。
可能到这里大家不太理解为什么这里又有一个排序,这里解释一下,不光插件有排序,插件中的钩子也是有排序规则的,不同于插件使用enforce定义插件顺序,钩子的顺序使用order来定义。
getSortedPluginHooks是getSortedPlugins更进一步的封装,它会将钩子对应的执行逻辑收集起来,根据上面的排序规则排列成一个数组,然后返回。
结束
我们这里大概了解了Vite如何创建本地服务器,以及如何合并配置项的,接下来,我们顺着createServer的脚步,了解整合了配置项之后,createServer又具体做了什么。