本文使用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
又具体做了什么。