本文使用vite 5.0.10版本
Vite
已经发布了5.0版本。虽然之前对2.x版本有所了解,但一直没有深入研究。现在,尝试浅显着眼其源码层面,了解Vite
采用的双引擎架构。
未来,Vite
将使用Rolldown
------ 一个锈化的Rollup
取代esbuild
和Rollup
。同时,Rolldown
将与Rspack
共享一些底层工具和功能,所有为即将涉及的Rolldown
和Rspack
技术做好技术储备。
本文你会学到
- 键入
vite
到服务器开启发生了什么,以及如何做的 - 服务器快捷键的注册和实现
- 自定义打开浏览器实现逻辑
preTransformRequests
做了什么优化server.warmup
是怎么做的
项目结构
Vite
的monorepo
结构包含三个关键包:
create-vite
:用于创建Vite项目的工具。plugin-legacy
:生成兼容旧版浏览器的代码,确保应用在这些浏览器中正常运行。vite
:存放Vite核心代码,我们重点关注的库。
建议在学习Vite
的时候,clone下Vite
的源码,边调试边学习,收获会更多。
Vite
使用pnpm作为包管理器。所以我们需要使用以下命令来构建并调试Vite
shell
$ pnpm i # 安装依赖
$ cd packages/vite # 进入vite包
$ pnpm run dev # 修改代码后会自动重新构建 Vite
Vite
在根目录下的playground
提供了丰富的场景,可以方便我们调试。
这里需要注意的是,Vite
本身是通过 Rollup
来构建的,并且是通过-w
参数来判断构建环境,同时是否生成sourcemap
。
Rollup
本身会通过yargs-parser
解析到-w
,并通过commandAliases
附加上watch
注入到rollup.config.ts
的入口函数。(Vite
使用的是cac
,下面会讲)
typescript
export default (commandLineArgs: any): RollupOptions[] => {
const isDev = commandLineArgs.watch
const isProduction = !isDev
return defineConfig([
envConfig,
clientConfig,
createNodeConfig(isProduction),
createCjsConfig(isProduction),
])
}
因此虽然Vite
的dev
和build
命令相似,但生成的产物是不同的,并且src/node
只有在dev
下才会生成sourcemap
,并对我们打断点有帮助。
json
"dev": "rimraf dist && pnpm run build-bundle -w",
"build": "rimraf dist && run-s build-bundle build-types",
cli
我们从最基本的流程开始------当我们在一个项目键入vite
的时候,会发生什么?
我们知道,在一个项目中键入一个命令,通常会自动指向某个包所暴露的bin
字段,由此执行对应的文件。
而Vite
所暴露出来的文件路径是bin/vite.js
。而这个文件开头指定了node
运行这个脚本。也就是说执行了
shell
$ node bin/vite.js
我们来看看这个文件做了什么。
js
import { performance } from 'node:perf_hooks'
// 记录 Vite 启动时间
global.__vite_start_time = performance.now()
// --debug 相关逻辑
// 定义启动 Vite 的函数
function start() {
return import('../dist/node/cli.js')
}
if (profileIndex > 0) {
// 启用性能分析
// --profile相关的逻辑
} else {
start() // 启动 Vite
}
在一开始,引入了性能监控模块记录了 Vite
启动时间。然后,它检查命令行参数以确定是否启用了调试模式(-d
或 --debug
),如果启用了调试模式,则根据命令行参数设置相应的调试环境变量。接下来,它定义了一个启动函数 start()
,并根据命令行参数来决定是否启用性能分析,并且同时启动inspector
,并进行性能分析设置。
否则直接启动 Vite
。
显然,我们这里直接启动了Vite
,也就是执行了../dist/node/cli.js
文件。
它的源码对应的vite/src/node/cli.ts
在cli.ts
中我们注意到,在一开始使用了cac
库创建了一个名为 vite
的 CLI
实例。
cac
是一个用于构建命令行界面,也就是CLI
的库。它提供了简单而灵活的方式来创建命令行工具,并能够处理命令、选项和参数等。
Vite
使用cac
的实例定义了一些公共参数。
typescript
cli
// 使用指定的配置文件
.option('-c, --config <file>', `[string] use specified config file`)
// 设置公共路径,默认为 `/`,并使用 `convertBase` 进行类型转换
.option('--base <path>', `[string] public base path (default: /)`, {
type: [convertBase],
})
// 设置logLevel `info`、`warn`、`error` 或 `silent`
.option('-l, --logLevel <level>', `[string] info | warn | error | silent`)
// 允许或禁止在记录日志时清空屏幕
.option('--clearScreen', `[boolean] allow/disable clear screen when logging`)
// 显示调试日志,可选参数 `feat` 是可选的字符串或布尔值
.option('-d, --debug [feat]', `[string | boolean] show debug logs`)
// 过滤调试日志
.option('-f, --filter <filter>', `[string] filter debug logs`)
// 设置环境模式
.option('-m, --mode <mode>', `[string] set env mode`)
注:
convertBase
转换逻辑是入参为0返回空字符串,其他情况原值返回。
然后,设定了不同的命令选项和回调函数。
typescript
// dev
cli
.command('[root]', 'start dev server') // 默认匹配
.alias('serve') // serve也会走这个匹配
.alias('dev') // dev也会走这个匹配
//省略选项和回调函数
// build
cli
.command('build [root]', 'build for production') //省略选项和回调函数
// optimize
cli
.command('optimize [root]', 'pre-bundle dependencies') //省略选项和回调函数
// preview
cli
.command('preview [root]', 'locally preview production build') //省略选项和回调函数
显然,我们执行的 Vite
没有附带任何参数或命令。因此,程序将进入默认的匹配回调函数。
我们注意,默认匹配的命令指定了两个别名serve
,dev
。
也就是说,下面三个命令具有同样的效果
shell
$ vite
$ vite dev
$ vite serve
文件最后,执行解析函数,执行对应的回调。
typescript
cli.parse()
我们接着看对应的回调函数。
typescript
// 过滤选项
filterDuplicateOptions(options)
// 动态导入 './server' 模块中的 createServer 方法
const { createServer } = await import('./server')
try {
// 使用 createServer 方法创建服务器实例,传入相应的配置选项
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
optimizeDeps: { force: options.force },
server: cleanOptions(options),
})
if (!server.httpServer) {
// 如果没有 HTTP 服务器,则抛出错误
throw new Error('HTTP server not available')
}
await server.listen()
const info = server.config.logger.info
// 获取 Vite 的启动时间
const viteStartTime = global.__vite_start_time ?? false
// 计算启动时间并格式化
const startupDurationString = viteStartTime
? colors.dim(
`ready in ${colors.reset(
colors.bold(Math.ceil(performance.now() - viteStartTime)),
)} ms`,
)
: ''
// 省略... 检查是否有已有的日志输出
// 打印 Vite 版本信息和启动时间
info(
`\n ${colors.green(
`${colors.bold('VITE')} v${VERSION}`,
)} ${startupDurationString}\n`,
{
clear: !hasExistingLogs,
},
)
// 打印服务器 URL 信息
server.printUrls()
// 定义自定义的命令行快捷方式数组
const customShortcuts: CLIShortcut<typeof server>[] = []
// 省略... 如果有性能分析会话,则添加启动/停止分析器的快捷方式
// 绑定命令行快捷方式
server.bindCLIShortcuts({ print: true, customShortcuts })
} catch (e) {
// 省略... 如果出现错误,记录错误信息并退出进程
process.exit(1)
}
首先,通过回调函数获取到了root
和options
,这两个是基于命令行解析获取到的。我们并没有在命令行附带参数,那么root
就是undefined
,而因为cac
的特性,options
会存在一个默认的--
key和一个空数组value。
但接着会被filterDuplicateOptions
过滤。filterDuplicateOptions
会检查其中的属性值是否为数组。如果属性值是数组类型,它会将对象中键对应的值修改为数组的最后一个元素。
这里数组最后一个元素是undefined
。
typescript
const options = {--:[]}
filterDuplicateOptions(options)
// {--:undefined}
接着使用 createServer
方法创建服务器实例,传入相应的配置选项,除了server
以外,这些配置都是直接通过options
来得到的,而server
使用cleanOptions
进行了浅拷贝,但移除了与全局 CLI 选项(如 --, c, config, base, l, logLevel, clearScreen, d, debug, f, filter, m, mode)相对应的key
。
此外,函数还会检查sourcema
选项,如果存在且值为字符串类型的布尔值('true'
或 'false'
),则将其转换为相应的布尔值。如果不是布尔字符串,则保持不变。
最终返回清理后的对象副本。
创建服务器实例成功后,启动了该实例,开始监听端口等待连接。
还记得在bin/vite.js
中,Vite
在global.__vite_start_time
挂在了启动时间,在这里会获取这个时间,然后计算出总启动时间,格式化后,附带Vite
的版本,一起输出在控制台。
就是我们启动Vite
见到的那行字。
之后,还会再输出服务器信息
有些人在这里就有疑问:是不是少了点啥,应该还有第三行,提示命令行快捷方式。
没错,虽然他们在排版上是在一起的,但实际上是不同的逻辑输出的。服务器信息是server.printUrls()
输出的。
命令行快捷方式是通过server.bindCLIShortcuts({ print: true, customShortcuts })
注册,并输出文案。
如果整个逻辑没问题,那么会在注册命令行快捷方式逻辑执行完毕后结束,否则,会走到catch
里面。
通过日志记录错误原因,并中断进程。
快捷键的注册
我们上文提到了注册快捷方式,server.bindCLIShortcuts
方法最终调用的是vite/src/node/shortcuts.ts
的bindCLIShortcuts
函数。我们来看看它的逻辑。
typescript
export function bindCLIShortcuts<Server extends ViteDevServer | PreviewServer>(
server: Server,
opts?: BindCLIShortcutsOptions<Server>,
): void {
// 检查条件,如果不符合条件则直接返回,不进行后续操作
if (!server.httpServer || !process.stdin.isTTY || process.env.CI) {
return
}
// 如果传入的 opts 中包含 print 选项,则在日志中打印帮助提示信息
if (opts?.print) {
server.config.logger.info(
colors.dim(colors.green(' ➜')) +
colors.dim(' press ') +
colors.bold('h + enter') +
colors.dim(' to show help'),
)
}
// 构建快捷键数组,包括用户自定义的和基础预设的快捷键
const shortcuts = (opts?.customShortcuts ?? []).concat(
(isDev
? BASE_DEV_SHORTCUTS
: BASE_PREVIEW_SHORTCUTS) as CLIShortcut<Server>[],
)
// 标记动作是否正在执行中
let actionRunning = false
// 处理用户输入的逻辑
const onInput = async (input: string) => {
if (actionRunning) return
// 省略...如果输入是 'h',则在日志中打印快捷键操作信息
if (input === 'h') {
return
}
// 查找匹配输入的快捷键并执行相应的动作
const shortcut = shortcuts.find((shortcut) => shortcut.key === input)
if (!shortcut || shortcut.action == null) return
actionRunning = true
await shortcut.action(server)
actionRunning = false
}
// 创建一个接口,监听标准输入并处理输入事件
const rl = readline.createInterface({ input: process.stdin })
rl.on('line', onInput)
// 在 HTTP 服务器关闭时,关闭输入监听
server.httpServer.on('close', () => rl.close())
}
我们可以看到这个函数首先输出了提示信息,也就是下图
然后才构建快捷键监听数组,并且根据环境不同,使用不同的监听数组。
最后,通过创建一个接口 readline.createInterface()
并添加一个 line
事件监听器来捕获用户在终端输入的内容。
当用户在终端输入内容并按下回车键时,onInput
函数将被调用来处理用户输入。通过检测用户输入的字符,匹配监听数组对应的元素,从而执行对应的函数。
当服务器关闭的时候,关闭对事件的监听。
事件处理
r:restart
快捷键r
是服务器重启指令。
最后被vite/src/node/server/index.ts
的restartServerWithUrls
执行。
typescript
export async function restartServerWithUrls(
server: ViteDevServer,
): Promise<void> {
// 如果服务器配置为中间件模式,则直接执行服务器的重启操作
if (server.config.server.middlewareMode) {
await server.restart()
return
}
// 保存之前的端口号、主机名和 URL 地址信息
const { port: prevPort, host: prevHost } = server.config.server
const prevUrls = server.resolvedUrls
// 执行服务器重启操作
await server.restart()
// 获取日志记录器、端口号和主机名
const {
logger,
server: { port, host },
} = server.config
// 检查端口号、主机名是否发生变化以及 DNS 排序是否改变
if (
(port ?? DEFAULT_DEV_PORT) !== (prevPort ?? DEFAULT_DEV_PORT) || // 检查端口号是否改变
host !== prevHost || // 检查主机名是否改变
diffDnsOrderChange(prevUrls, server.resolvedUrls) // 检查 DNS 排序是否改变
) {
logger.info('') // 输出空行
server.printUrls() // 打印新的 URL 地址信息
}
}
restartServerWithUrls
首先检查服务器的配置,如果它是在中间件模式下运行,会直接执行服务器的重启操作。如果不是中间件模式,则会保存当前的端口号、主机名和 URL 地址信息。
无论服务器处于哪种模式,接下来都会执行服务器的重启操作。
然后,函数会比较重启前后的端口号、主机名以及 network、local是否发生变化。如果有任何变化,它都会在终端里面利用server.printUrls
打印新的信息。
u:show server url
代码很简单,就是输出一个空字符然后换行,再次输出服务器信息。
typescript
server.config.logger.info('')
server.printUrls()
我们见过很多次server.printUrls
,本质上是读取server.resolvedUrls
和createServer.host
进行打印。
还记得上文我们通过createServer
创建一个服务器实例了吗?它的入参有个经过清理的server
。实际上就是这个createServer
。
o: open in browser
对应的处理函数在vite/src/node/server/index.ts
中。
typescript
openBrowser() {
const options = server.config.server // 获取服务器配置选项
const url = server.resolvedUrls?.local[0] ?? server.resolvedUrls?.network[0] // 获取将要打开的URL地址
if (url) {
// 如果存在URL地址
const path = // 构建要打开的路径
typeof options.open === "string" ? new URL(options.open, url).href : url
// 等待打开浏览器时,我们已经知道浏览器要打开的 URL 地址
// 所以我们可以在等待浏览器的过程中开始发送请求。
// 这样做的目的是在浏览器实际打开前大约提前 500 毫秒进行HTML解析
// 需要启用preTransformRequests选项进行此优化
if (server.config.server.preTransformRequests) {
setTimeout(() => {
const getMethod = path.startsWith("https:") ? httpsGet : httpGet
// 省略...发送首页GET请求
}, 0)
}
_openBrowser(path, true, server.config.logger) // 打开浏览器
} else {
server.config.logger.warn("No URL available to open in browser") // 没有可打开的URL地址
}
}
这段代码中,openBrowser
主要完成以下几个任务:
- 从服务器配置中获取相关选项和要打开的 URL。
- 如果存在 URL,则构建要打开的路径。
- 若配置启用了
preTransformRequests
选项,则会在等待浏览器启动前发送请求。这样做可以在大约 500 毫秒之前开始HTML解析。 - 最后,使用
_openBrowser
方法打开浏览器。如果没有可用的 URL ,显示警告消息。
需要注意的是preTransformRequests
是并没有在Vite
文档中说明,且默认启用的特性。可以在config
中配置server.preTransformRequests: false
关闭。
我们注意到,打开浏览器使用的是_openBrowser
,也就是vite/src/node/server/openBrowser.ts
中的openBrowser
方法。
typescript
export function openBrowser(
url: string, // 要打开的 URL 地址
opt: string | true, // 浏览器选项或 true
logger: Logger, // 日志记录器实例
): void {
// 要打开的浏览器
const browser = typeof opt === 'string' ? opt : process.env.BROWSER || ''
// 如果浏览器选项以 .js 结尾,则执行 Node 脚本。
if (browser.toLowerCase().endsWith('.js')) {
executeNodeScript(browser, url, logger)
} else if (browser.toLowerCase() !== 'none') {
// 如果浏览器选项不是 'none',则启动浏览器进程。
const browserArgs = process.env.BROWSER_ARGS
? process.env.BROWSER_ARGS.split(' ')
: [] // 获取浏览器参数
startBrowserProcess(browser, browserArgs, url)
}
}
我们注意到,这里可能通过process.env.BROWSER
获取默认浏览器,文档里面也提到过。
如果你想在你喜欢的某个浏览器打开该开发服务器,你可以设置环境变量 process.env.BROWSER (例如 firefox)。你还可以设置 process.env.BROWSER_ARGS 来传递额外的参数(例如 --incognito)。
BROWSER 和 BROWSER_ARGS 都是特殊的环境变量,你可以将它们放在 .env 文件中进行设置
但是,这里判断了获取到的浏览器是否是以.js
结尾的,如果是的话,那么会执行executeNodeScript
,如果不是且不为none
,那么会执行startBrowserProcess
。
这就有点意思了,浏览器可以指定一个js文件。
executeNodeScript
的逻辑很简洁,就是通过 Node
的 spawn
方法启动一个新的进程来执行指定的js文件,同时将指定的 URL 作为命令行参数传递给这个脚本。
换句话说,我们可以自定义通过一个js文件接受需要打开的 URL,然后自己去处理对应的逻辑。
我们来试一下。下面是.env.local
的内容
ini
BROWSER=./t.js
js文件内容很简单,就是打印获取到的参数,如果参数能获取到,那么之后的逻辑就相当自由了。
js
console.log('----executeNodeScript----');
console.log(process.argv);
然后执行o命令。
可以看到,js文件已经被执行,且获取到了传入的参数。
那么如果是正常逻辑呢?就执行执行startBrowserProcess
。
typescript
async function startBrowserProcess(
browser: string | undefined, // 要使用的浏览器
browserArgs: string[], // 浏览器参数列表
url: string, // 要打开的 URL 地址
) {
// 如果我们在 OS X 上,并且用户没有明确请求使用不同的浏览器,
// 我们可以尝试使用 AppleScript 打开 Chromium 浏览器。
// 这使我们可以在可能时重用现有标签页而不是创建新标签页。
// 省略对应逻辑
// 另一个特殊情况:在 OS X 上,检查 BROWSER 是否被设置为 "open"。
// 在这种情况下,不要将字符串 `open` 传递给 `open` 函数(这不起作用),
// 只需忽略它(以确保预期的行为,即打开系统浏览器):
if (process.platform === 'darwin' && browser === 'open') {
browser = undefined
}
// 回退到 open(它将始终打开新标签页)
try {
const options: open.Options = browser
? { app: { name: browser, arguments: browserArgs } }
: {}
open(url, options).catch(() => {}) // 防止 `unhandledRejection` 错误。
return true
} catch (err) {
return false
}
}
这个函数是一个异步函数,它负责打开浏览器并加载指定的 URL 地址。
首先,如果是 macOS 指定了 Chromium
浏览器或者没有指定浏览器。会尝试使用 AppleScript
在 macOS 上打开 Chromium
浏览器,以便尽可能地重用现有的标签页。
如果无法通过 AppleScript
复用浏览器标签页,或者不是 macOS 系统,或者指定的浏览器不是 Chromium
,则使用open
这个包打开浏览器。
注:Chromium浏览器,指的Chromium内核的浏览器,比如Chrome、Edge
c: clear console
typescript
function clearScreen() {
// 计算在控制台上需要清除的行数
const repeatCount = process.stdout.rows - 2;
// 根据需要清除的行数生成空白行
const blank = repeatCount > 0 ? '\n'.repeat(repeatCount) : '';
// 打印生成的空白行到控制台
console.log(blank);
// 将光标定位到控制台的顶部左侧
readline.cursorTo(process.stdout, 0, 0);
// 清除控制台屏幕下方的内容
readline.clearScreenDown(process.stdout);
}
清除控制台的逻辑倒是比较简单。
由于需要按下回车,命令才会执行,所以本身命令行中已经出现了一个换行。
并且,输出'\n'会自带一次换行,所以实际需要的换行符数量为命令行总行 - 2
然后在终端上输出这些换行符,然后利用 Node 中的 readline
模块,将光标移动到终端的顶部,并清空光标止之后的内容。
q: quit
quit
调用了close
方法,最后无论成功与否,都是调用process.exit()
。
typescript
await server.close().finally(() => process.exit())
我们看看close
方法
typescript
async close() {
// 若非中间件模式,移除对 SIGTERM 信号和标准输入流结束事件的处理器
if (!middlewareMode) {
process.off('SIGTERM', exitProcess)
if (process.env.CI !== 'true') {
process.stdin.off('end', exitProcess)
}
}
// 等待多个异步操作完成,包括关闭 watcher、WebSocket 连接、容器连接、两个依赖项优化器的关闭以及 HTTP 服务器的关闭
await Promise.allSettled([
watcher.close(),
ws.close(),
container.close(),
getDepsOptimizer(server.config)?.close(),
getDepsOptimizer(server.config, true)?.close(),
closeHttpServer(),
])
// 在关闭服务器前,等待未完成的请求处理完毕
while (server._pendingRequests.size > 0) {
await Promise.allSettled(
[...server._pendingRequests.values()].map((pending) => pending.request)
)
}
// 清空服务器的 resolvedUrls 属性
server.resolvedUrls = null
}
close
是一个异步函数,如果不是中间件模式,移除对 SIGTERM 信号和标准输入流结束事件的处理。
然后使用 Promise.allSettled()
等待多个异步操作完成,包括关闭 watcher、WebSocket 连接、容器连接、两个依赖项优化器的关闭以及 HTTP 服务器的关闭。
在等待正在进行的请求完成之前,阻塞等待。对于非 SSR 请求,如果server
正在关闭,它会提前在 transformRequest
和 hooks
中抛出错误,因此此处将等待这些请求完成。最后,将server
的 resolvedUrls
属性设置为 null
,以清空其值。
preTransformRequests
我们提到了preTransformRequests
会发送首页的GET
请求,来进行优化,显而易见,解析html
的逻辑会触发两次(快捷键打开浏览器一次、浏览器实际GET
一次),乍一看是负优化,怎么叫做优化呢?
我们知道Vite
服务器启动之后,会进行预构建,然后是创建模块的依赖图。
虽然有transformMiddleware
这个中间件来进行模块转换,并创建模块间的依赖图。
但严谨一点说------在dev
环境下,并且在spa
或者mpa
应用里面,进行以上行为的实际上是transform index.html
的中间件indexHtmlMiddleware
!
也就是说,如果模块很多的话,创建模块间的依赖图的时间就会较长,从而使transform index.html
的时间拉长,结果就是在打开浏览器的时候,出现了人体可感知的白屏。
那么简单粗暴的解决办法,就是尽可能提前transform index.html
的时间。因为模块依赖图是缓存的,所以第二次首页GET
,也就是浏览器访问的时候,除了部分新增的依赖模块------比如@vite/client
,访问的是已经被解析过的模块依赖图,因此浏览器所触发的indexHtmlMiddleware
,可以很快给浏览器html
页面。
从这个角度来看解析html
的逻辑会触发两次,但相比之下,可以提前获取模块依赖图,减少浏览器打开的白屏时间,因此这些代价是可接受的。
那么为什么模块依赖图会在解析html
的时候生成的呢?
我们看看GET
请求触发了indexHtmlMiddleware
并进而触发了server.transformIndexHtml
。
而server.transformIndexHtml
实际上是根据传入的config
(对,就是上文我们提到createServer
接受的入参)进行包装的applyHtmlTransforms
。
applyHtmlTransforms
是编译html
的主要函数,可以通过不同的hook
,对html
进行插值、变量替换等操作。
这些hook
里面有个devHtmlHook
。这个hook
递归遍历了当前模块所依赖的模块。
这个代码在vite/src/node/server/middlewares/indexHtml.ts
中
typescript
const devHtmlHook: IndexHtmlTransformHook = async (
html, // 原始 HTML 内容
{ path: htmlPath, filename, server, originalUrl }
) => {
const { config, moduleGraph, watcher } = server!
const base = config.base || "/" // 获取基本路径,默认为根路径 '/'
// 省略。。。虚拟模块
const s = new MagicString(html) // 使用 MagicString 处理 HTML 字符串
// 省略。。。初始化样式 URL 数组和内联样式数组
// 遍历 HTML 节点
await traverseHtml(html, filename, (node) => {
// 处理 script
if (node.nodeName === "script") {
const { src, sourceCodeLocation, isModule } = getScriptInfo(node) // 获取脚本标签信息
if (src) {
const processedUrl = processNodeUrl(
src.value,
isSrcSet(src),
config,
htmlPath,
originalUrl,
server,
!isModule
)
if (processedUrl !== src.value) {
// 替换src
overwriteAttrValue(s, sourceCodeLocation!, processedUrl)
}
} else if (isModule && node.childNodes.length) {
// esm内联当做代理为外联js
addInlineModule(node, 'js')
} else if (node.childNodes.length) {
// 省略。。。其他内联当做代理为外联js
}
}
})
// 省略。。。 并行处理样式和内联样式
html = s.toString() // 将 MagicString 实例转换为字符串
return {
html, // 修改后的 HTML
tags: [
{
tag: "script",
attrs: {
type: "module",
src: path.posix.join(base, CLIENT_PUBLIC_PATH), // 插入/@vite/client
},
injectTo: "head-prepend", // 插入位置
},
],
}
}
代码首先确定文件路径和相关信息,然后根据这些信息使用traverseHtml
对html
进行处理。最后得出处理后的html
字符串返回,并返回对应的信息,同时插入/@vite/client
。
而traverseHtml
有三个入参,除了html
字符串和文件地址以外,还注册一个回调函数。
我们分析一下回调函数,这个回调函数接收node
节点,如果这个节点是script
存在src
,那么会使用processNodeUrl
处理这个模块。
如果这个节点是script
且type
是module
,那么会使用addInlineModule
处理这个标签。
其他情况,会解析有没有使用import
,如果使用了,会将引入的文件路径交给processNodeUrl
处理。
当然这个回调函数还处理了样式的相关逻辑,分内联样式和外联样式,推入inlineStyles
和styleUrl
这两个数组,并在traverseHtml
执行完毕后使用Promise.all
加载处理。
那么我们看看traverseHtml
做了什么。
traverseHtml
在vite/src/node/plugins/html.ts
typescript
function traverseNodes( // 遍历节点函数
node: DefaultTreeAdapterMap['node'], // 节点类型和信息
visitor: (node: DefaultTreeAdapterMap['node']) => void, // 回调函数
) {
visitor(node) // 当前节点
if (
nodeIsElement(node) || // 如果节点是元素节点
node.nodeName === '#document' || // 或者是文档节点
node.nodeName === '#document-fragment' // 或文档片段节点
) {
node.childNodes.forEach((childNode) => traverseNodes(childNode, visitor)) // 递归处理当前节点的子节点
}
}
export async function traverseHtml( // 遍历HTML函数
html: string, // HTML字符串
filePath: string, // 文件路径
visitor: (node: DefaultTreeAdapterMap['node']) => void, // 回调函数
): Promise<void> {
const { parse } = await import('parse5') // 异步导入parse5库
const ast = parse(html, { // 使用parse5解析HTML
scriptingEnabled: false, // 禁用脚本解析,允许<noscript>内解析
sourceCodeLocationInfo: true, // 收集源代码位置信息
onParseError: (e: ParserError) => { // 解析错误处理函数
handleParseError(e, html, filePath) // 处理解析错误
},
})
traverseNodes(ast, visitor) // 对AST根节点进行遍历处理
}
traverseHtml
是对 traverseNodes
的封装,它接受一个 html
,一个文件路径 filePath
,以及一个回调函数 visitor
。 首先,它使用 parse5
(一个 html
解析器)对给定的 html
字符串进行解析,并生成一个 AST。
在解析过程中,还置了一些选项,比如禁用了脚本解析和开启了源代码位置信息的收集,同时还提供了一个错误处理函数 handleParseError
。
一旦解析完成,它调用 traverseNodes
函数,从 AST 根节点开始递归遍历整个节点树,对每个节点都应用传入的回调函数 visitor
。
然后我们看看traverseNodes
。这个函数是递归遍历节点树的核心。它接受一个节点 node
和回调函数 visitor
。
首先,它会对当前节点应用访问器函数。
如果当前节点是一个元素节点或者是文档或文档片段,它会对当前节点的子节点逐个调用 traverseNodes
,从而递归地深入遍历整个节点树。
也就是说,引入的模块,都会经过processNodeUrl
处理。
这个正是引发短暂白屏的原因之一。
而processNodeUrl
的逻辑就不贴全部代码了。代码在vite/src/node/server/middlewares/indexHtml.ts
中
processNodeUrl
判断了各种情况,到最后会进行以下操作。
typescript
if (preTransformUrl) {
preTransformRequest(server, preTransformUrl, config.base)
}
而addInlineModule
解析出引入的模块,会转换成esm,并调用
typescript
preTransformRequest(server!, modulePath, base)
而preTransformRequest
很简单
typescript
function preTransformRequest(server: ViteDevServer, url: string, base: string) {
if (!server.config.server.preTransformRequests) return
// 如果preTransformRequests是false,直接返回,不进行处理
// 尝试对URL进行处理,去除基础路径并解码
try {
url = unwrapId(stripBase(decodeURI(url), base))
// 具体处理方式是:解码URL -> 去除基础路径 -> 解封装ID
} catch {
// 如果处理过程中出现错误,忽略错误,直接返回
// 可能的错误是URL无法解码或无法从URL中移除基础路径
return
}
// 触发服务器的预热请求,用于准备指定URL的资源
server.warmupRequest(url)
}
可以看到实际上preTransformRequest
就是基于server.warmupRequest
的封装。
我们注意到关键词warmup
。
在Vite5
新增了server.warmup
,这是一项改善启动时间的新功能。它允许定义应在服务器启动后立即进行预转换的模块列表。
在预构建结束之后,会执行warmupFiles(server)
warmupFiles
我们可以在vite/src/node/server/warmup.ts
看到
typescript
export function warmupFiles(server: ViteDevServer): void {
const options = server.config.server.warmup; // 获取预热选项
const root = server.config.root; // 获取服务器根目录
if (options?.clientFiles?.length) {
// 如果存在文件需要预热
mapFiles(options.clientFiles, root).then((files) => {
// 将客户端文件映射为绝对路径
for (const file of files) {
warmupFile(server, file, false); // 对每个文件进行预热
}
});
}
// 略。。。 ssr
}
可以看到warmupFiles(server)
实际上就是读取server.warmup
,并使用warmupFile
处理每个模块。
typescript
async function warmupFile(server: ViteDevServer, file: string, ssr: boolean) {
// 如果文件是 HTML 类型的
if (file.endsWith('.html')) {
// 将 HTML 文件路径转换为 URL 地址
const url = htmlFileToUrl(file, server.config.root);
if (url) {
try {
// 读取文件内容
const html = await fs.readFile(file, 'utf-8');
// 使用 transformIndexHtml 钩子对 HTML 进行预处理
await server.transformIndexHtml(url, html);
} catch (e) {
// 捕获并记录预处理过程中的错误
server.config.logger.error(
`Pre-transform error (${colors.cyan(file)}): ${e.message}`,
{
error: e,
timestamp: true,
},
);
}
}
}
// 对于其他类型的文件
else {
// 将文件路径转换为 URL 地址
const url = fileToUrl(file, server.config.root);
// 通过 transformRequest 进行请求预热
await server.warmupRequest(url, { ssr });
}
}
可以看到,如果是html
文件那么使用server.transformIndexHtml
,如果是其他文件,那么使用server.warmupRequest(url, { ssr })
。
还记得我们一开始的入口index.html
怎么处理的吗?
就是使用server.transformIndexHtml
处理的,所以我们发现一个新的优化项,如果希望更加快速的构建模块依赖图,我们可以直接把入口文件放到server.warmup
里面,跟使用快捷键打开浏览器执行的逻辑是一样的,甚至更加提前。
如果是其他模块,使用server.warmupRequest
,也就是前文preTransformRequest
调用的方法。
世界性收束了。
那么server.warmupRequest
做了什么呢?
实际上就是调用了transformRequest
。
而 transformRequest
就是构建模块依赖图的逻辑,我们之后再细讲。
所以我们得出结论preTransformRequests
的优化逻辑是提前触发indexHtmlMiddleware
,从而触发transformRequest
构建模块依赖图。
而server.warmup
的实现也是基于上面的逻辑,如果是html
跟indexHtmlMiddleware
的逻辑相同,其他情况也是直接触发transformRequest
构建模块依赖图。
结束
当前流程是非常浅显的,讲了一些细节上的东西同时留下了两个坑,这将是之后的入手点
createServer
究竟做了什么,这个才是Vite
的主逻辑, 而我们只细说了创建server
之后的逻辑,以及preTransformRequest
和server.warmup
的逻辑,预构建只提到几次,但并没有深入源码,同时Vite
如何兼容Rollup
插件也没有细说,等等。- 关于构建模块依赖图我们也点到为止,因此我们将在预构建内容结束后,深入了解模块依赖图是如何构建出来的,以及为什么构建它。