侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

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

lumozx
2024-01-06 / 0 评论 / 0 点赞 / 17 阅读 / 32838 字

本文使用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

$ 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,下面会讲)

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,并对我们打断点有帮助。

 "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运行这个脚本。也就是说执行了

$ node bin/vite.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的实例定义了一些公共参数。

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返回空字符串,其他情况原值返回。

然后,设定了不同的命令选项和回调函数。

// 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

也就是说,下面三个命令具有同样的效果

$ vite
$ vite dev
$ vite serve

文件最后,执行解析函数,执行对应的回调。

cli.parse()

我们接着看对应的回调函数。

// 过滤选项
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

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函数。我们来看看它的逻辑。

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

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

代码很简单,就是输出一个空字符然后换行,再次输出服务器信息。

server.config.logger.info('')
server.printUrls()

我们见过很多次server.printUrls,本质上是读取server.resolvedUrlscreateServer.host进行打印。

还记得上文我们通过createServer创建一个服务器实例了吗?它的入参有个经过清理的server。实际上就是这个createServer

o: open in browser

对应的处理函数在vite/src/node/server/index.ts中。

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方法。

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的内容

BROWSER=./t.js

js文件内容很简单,就是打印获取到的参数,如果参数能获取到,那么之后的逻辑就相当自由了。

console.log('----executeNodeScript----');
console.log(process.argv);

然后执行o命令。

可以看到,js文件已经被执行,且获取到了传入的参数。

那么如果是正常逻辑呢?就执行执行startBrowserProcess

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

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

await server.close().finally(() => process.exit())

我们看看close方法

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

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

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判断了各种情况,到最后会进行以下操作。

if (preTransformUrl) {
  preTransformRequest(server, preTransformUrl, config.base)
}

addInlineModule解析出引入的模块,会转换成esm,并调用

preTransformRequest(server!, modulePath, base)

preTransformRequest很简单

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看到

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处理每个模块。

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插件也没有细说,等等。

  • 关于构建模块依赖图我们也点到为止,因此我们将在预构建内容结束后,深入了解模块依赖图是如何构建出来的,以及为什么构建它。

0
博主关闭了所有页面的评论