vite 5.0 源码分析(一):cli 和 服务器快捷键 和 server.warmup

本文使用vite 5.0.10版本

Vite已经发布了5.0版本。虽然之前对2.x版本有所了解,但一直没有深入研究。现在,尝试浅显着眼其源码层面,了解Vite采用的双引擎架构。

未来,Vite将使用Rolldown ------ 一个锈化的Rollup取代esbuildRollup。同时,Rolldown将与Rspack共享一些底层工具和功能,所有为即将涉及的RolldownRspack技术做好技术储备。

本文你会学到

  • 键入vite到服务器开启发生了什么,以及如何做的
  • 服务器快捷键的注册和实现
  • 自定义打开浏览器实现逻辑
  • preTransformRequests 做了什么优化
  • server.warmup是怎么做的

项目结构

Vitemonorepo结构包含三个关键包:

  • 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),
  ])
}

因此虽然Vitedevbuild命令相似,但生成的产物是不同的,并且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库创建了一个名为 viteCLI 实例。

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)
}

首先,通过回调函数获取到了rootoptions,这两个是基于命令行解析获取到的。我们并没有在命令行附带参数,那么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中,Viteglobal.__vite_start_time挂在了启动时间,在这里会获取这个时间,然后计算出总启动时间,格式化后,附带Vite的版本,一起输出在控制台。

就是我们启动Vite见到的那行字。

之后,还会再输出服务器信息

有些人在这里就有疑问:是不是少了点啥,应该还有第三行,提示命令行快捷方式。

没错,虽然他们在排版上是在一起的,但实际上是不同的逻辑输出的。服务器信息是server.printUrls()输出的。

命令行快捷方式是通过server.bindCLIShortcuts({ print: true, customShortcuts })注册,并输出文案。

如果整个逻辑没问题,那么会在注册命令行快捷方式逻辑执行完毕后结束,否则,会走到catch里面。

通过日志记录错误原因,并中断进程。

快捷键的注册

我们上文提到了注册快捷方式,server.bindCLIShortcuts方法最终调用的是vite/src/node/shortcuts.tsbindCLIShortcuts函数。我们来看看它的逻辑。

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.tsrestartServerWithUrls执行。

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.resolvedUrlscreateServer.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 主要完成以下几个任务:

  1. 从服务器配置中获取相关选项和要打开的 URL。
  2. 如果存在 URL,则构建要打开的路径。
  3. 若配置启用了 preTransformRequests 选项,则会在等待浏览器启动前发送请求。这样做可以在大约 500 毫秒之前开始HTML解析。
  4. 最后,使用 _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的逻辑很简洁,就是通过 Nodespawn 方法启动一个新的进程来执行指定的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正在关闭,它会提前在 transformRequesthooks 中抛出错误,因此此处将等待这些请求完成。最后,将serverresolvedUrls 属性设置为 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", // 插入位置
      },
    ],
  }
}

代码首先确定文件路径和相关信息,然后根据这些信息使用traverseHtmlhtml进行处理。最后得出处理后的html字符串返回,并返回对应的信息,同时插入/@vite/client

traverseHtml有三个入参,除了html字符串和文件地址以外,还注册一个回调函数。

我们分析一下回调函数,这个回调函数接收node节点,如果这个节点是script存在src,那么会使用processNodeUrl处理这个模块。

如果这个节点是scripttypemodule,那么会使用addInlineModule处理这个标签。

其他情况,会解析有没有使用import,如果使用了,会将引入的文件路径交给processNodeUrl处理。

当然这个回调函数还处理了样式的相关逻辑,分内联样式和外联样式,推入inlineStylesstyleUrl这两个数组,并在traverseHtml执行完毕后使用Promise.all加载处理。

那么我们看看traverseHtml做了什么。

traverseHtmlvite/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的实现也是基于上面的逻辑,如果是htmlindexHtmlMiddleware的逻辑相同,其他情况也是直接触发transformRequest构建模块依赖图。

结束

当前流程是非常浅显的,讲了一些细节上的东西同时留下了两个坑,这将是之后的入手点

  • createServer 究竟做了什么,这个才是 Vite的主逻辑, 而我们只细说了创建server之后的逻辑,以及preTransformRequestserver.warmup的逻辑,预构建只提到几次,但并没有深入源码,同时 Vite 如何兼容 Rollup插件也没有细说,等等。
  • 关于构建模块依赖图我们也点到为止,因此我们将在预构建内容结束后,深入了解模块依赖图是如何构建出来的,以及为什么构建它。
相关推荐
@大迁世界2 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路11 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug15 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213817 分钟前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中38 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路42 分钟前
GDAL 实现矢量合并
前端
hxjhnct44 分钟前
React useContext的缺陷
前端·react.js·前端框架
冰暮流星1 小时前
javascript逻辑运算符
开发语言·javascript·ecmascript
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全