Vite 开发环境为何这么快?

本文只是笔者作为一个初学者,在学习中与看了诸多业界的优秀实践文章之后的思考和沉淀,如果你在看的过程中觉得有些不妥的地方,可以随时和我联系,一起探讨学习。

提到 Vite,第一个想到的字就是 ,到底快在哪里呢?为什么可以这么快?
本文从以下几个地方来讲

  • 快速的冷启动: No Bundle + esbuild 预构建
  • 模块热更新:利用浏览器缓存策略
  • 按需加载:利用浏览器 ESM 支持

Vite 本质上是一个本地资源服务器,还有一套构建指令组成。

  • 本地资源服务器,基于 ESM 提供很多内建功能,HMR 速度很快
  • 使用 Rollup 打包你的代码,预配件了优化的过配置,输出高度优化的静态资源

快递的冷启动

No-bundle

在冷启动开发者服务器时,基于 Webpack 这类 bundle based 打包工具,启动时必须要通过 依赖收集、模块解析、生成 chunk、生成模块依赖关系图,最后构建整个应用输出产物,才能提供服务。

这意味着不管代码实际是否用到,都是需要被扫描和解析。

Vite 开发环境为何这么快?

而 Vite 的思路是,利用浏览器原生支持 ESM 的原理,让浏览器来负责打包程序的工作。而 Vite 只需要在浏览器请求源码时进行转换并按需提供源码即可。

这种方式就像我们编写 ES5 代码一样,不需要经过构建工具打包成产物再给浏览器解析,浏览器自己就能够解析。

Vite 开发环境为何这么快?
与现有的打包构建工具 Webpack 等不同,Vite 的开发服务器启动过程仅包括加载配置和中间件,然后立即启动服务器,整个服务启动流程就此结束。

Vite 利用了现代浏览器支持的 ESM 特性,在开发阶段实现了 no-bundle 模式,不生成所有可能用到的产物,而是在遇到 import 语句时发起资源文件请求。

当 Vite 服务器接收到请求时,才对资源进行实时编译并将其转换为 ESM,然后返回给浏览器,从而实现按需加载项目资源。而现有的打包构建工具在启动服务器时需要进行项目代码扫描、依赖收集、模块解析、生成 chunk 等操作,最后才启动服务器并输出生成的打包产物。

正是因为 Vite 采用了 no-bundle 的开发模式,使用 Vite 的项目不会随着项目迭代变得庞大和复杂而导致启动速度变慢,始终能实现毫秒级的启动。

esbuild 预构建

当然这里的毫秒级是有前提的,需要是非首次构建,并且没有安装新的依赖,项目代码中也没有引入新的依赖。

这是因为 Vite 的 Dev 环境会进行预构建优化。
在第一次运行项目之后,直接启动服务,大大提高冷启动速度,只要没有依赖发生变化就会直接出发热更新,速度也能够达到毫秒级。

这里进行预构建主要是因为 Vite 是基于浏览器原生**支持 **ESM 的能力实现的,但要求用户的代码模块必须是ESM模块,因此必须将 commonJSUMD 规范的文件提前处理,转化成 ESM 模块并缓存入 node_modules/.vite

在转换 commonJS 依赖时,Vite 会进行智能导入分析,即使模块导出时动态分配的,具名导出也能正常工作。

// 符合预期
import React, { useState } from 'react'

另一方面是为了性能优化

为了提高后续页面加载的性能,Vite 将那些具有许多内部模块的 ESM 依赖转为单个模块。

比如我们常用的 lodash 工具库,里面有很多包通过单独的文件相互导入,而 lodash-es这种 ESM 包会有几百个子模块,当代码中出现 import { debounce } from 'lodash-es'发出几百个 HTTP 请求,这些请求会造成网络堵塞,影响页面的加载。

通过将 lodash-es 预构建成一个单独模块,只需要一个 HTTP 请求。

那么如果是首次构建呢?Vite 还能这么快吗?

在首次运行项目时,Vite 会对代码进行扫描,对使用到的依赖进行预构建,但是如果使用 rollup、webpack 进行构建同样会拖累项目构建速度,而 Vite 选择了 esbuild 进行构建。

btw,预构建只会在开发环境生效,并使用 esbuild 进行 esm 转换,在生产环境仍然会使用 rollup 进行打包。

生产环境使用 rollup 主要是为了更好的兼容性和 tree-shaking 以及代码压缩优化等,以减小代码包体积

为什么选择 esbuild?

esbuild 的构建速度非常快,比 Webpack 快非常多,esbuild 是用 Go 编写的,语言层面的压制,运行性能更好

Vite 开发环境为何这么快?

核心原因就是 esbuild 足够快,可以在 esbuild 官网看到这个对比图,基本上是 上百倍的差距。

前端的打包工具大多数是基于 JavaScript 实现的,由于语言特性 JavaScript 边运行边解释,而 esbuild 使用 Go 语言开发,直接编译成机器语言,启动时直接运行即可。

更多关于 Go 和 JavaScript 的语言特性差异,可以检索一下。

不久前,字节开源了 Rspack 构建工具,它是基于 Rust 编写的,同样构建速度很快

  • Rust 编译生成的 Native Code 通常比 JavaScript 性能更为高效,也意味着 rspack 在打包和构建中会有更高的性能。
  • 同时 Rust 支持多线程,意味着可以充分利用多核 CPU 的性能进行编译。而 Webpack 受限于 JavaScript 对多线程支持较弱,导致很难进行并行计算。

不过,Rspack 的插件系统还不完善,同时由于插件支持 JS 和 rust 编写,如果采用 JS 编写估计会损失部分性能,而使用 rust 开发,对于开发者可能需要一定的上手成本

Vite 开发环境为何这么快?

同时发现 Vite 4 已经开始增加对 SWC 的支持,这是一个基于 Rust 的打包器,可以替代 Babel,以获取更高的编译性能。

**Rust 会是 JavaScript 基建的未来吗?**推荐阅读:zhuanlan.zhihu.com/p/433300816

模块热更新

主要是通过 WebSocket 创建浏览器和服务器的通信监听文件的改变,当文件被修改时,服务端发送消息通知客户端修改相应的代码,客户端对应不同的文件进行不同的操作的更新。

WebpackVite 在热更新上有什么不同呢?

Webpack: 重新编译,请求变更后模块的代码,客户端重新加载

Vite 通过监听文件系统的变更,只对发生变更的模块重新加载,只需要让相关模块的 boundary 失效即可,这样 HMR 更新速度不会因为应用体积增加而变慢,但 Webpack 需要经历一次打包构建流程,所以 HMR Vite 表现会好于 Webpack

核心流程

Vite 热更新流程可以分为以下:

  1. 创建一个 websocket 服务端和client文件,启动服务
  2. 监听文件变更
  3. 当代码变更后,服务端进行判断并推送到客户端
  4. 客户端根据推送的信息执行不同操作的更新

Vite 开发环境为何这么快?

创建 WebSocket 服务

在 dev server 启动之前,Vite 会创建websocket服务,利用chokidar创建一个监听对象 watcher 用于对文件修改进行监听等等,具体核心代码在 node/server/index 下

Vite 开发环境为何这么快?

createWebSocketServer 就是创建 websocket 服务,并封装内置的 close、on、send 等方法,用于服务端推送信息和关闭服务

源码地址:packages/vite/src/node/server/ws.ts

Vite 开发环境为何这么快?

执行热更新

当接受到文件变更时,会执行 change 回调

watcher.on('change', async (file) => {
  file = normalizePath(file)
  // invalidate module graph cache on file change
  moduleGraph.onFileChange(file)

  await onHMRUpdate(file, false)
})

当文件发生更改时,这个回调函数会被触发。file 参数表示发生更改的文件路径。

首先会通过 normalizePath 将文件路径标准化,确保文件路径在不同操作系统和环境中保持一致。

然后会触发 moduleGraph 实例上的 onFailChange 方法,用来清空被修改文件对应的 ModuleNode 对象的 transformResult 属性,**使之前的模块已有的转换缓存失效。**这块在下一部分会讲到。

  • ModuleNode 是 Vite 最小模块单元
  • moduleGraph 是整个应用的模块依赖关系图

源码地址:packages/vite/src/node/server/moduleGraph.ts

onFileChange(file: string): void {
  const mods = this.getModulesByFile(file)
  if (mods) {
    const seen = new Set<ModuleNode>()
    mods.forEach((mod) => {
      this.invalidateModule(mod, seen)
    })
  }
}

invalidateModule(
  mod: ModuleNode,
  seen: Set<ModuleNode> = new Set(),
  timestamp: number = Date.now(),
  isHmr: boolean = false,
  hmrBoundaries: ModuleNode[] = [],
): void {
  ...
  // 删除平行编译结果
  mod.transformResult = null
  mod.ssrTransformResult = null
  mod.ssrModule = null
  mod.ssrError = null
  ...
  mod.importers.forEach((importer) => {
    if (!importer.acceptedHmrDeps.has(mod)) {
      this.invalidateModule(importer, seen, timestamp, isHmr)
    }
  })
}

可能会有疑惑,Vite 在开发阶段不是不会打包整个项目吗?怎么生成模块依赖关系图

确实是这样,Vite 不会打包整个项目,但是仍然需要构建模块依赖关系图,当浏览器请求一个模块时

  • Vite 首先会将请求的模块转换成原生 ES 模块
  • 分析模块依赖关系,也就是 import 语句的解析
  • 将模块及依赖关系添加到 moduleGraph
  • 返回编译后的模块给浏览器

因此 Vite 的 Dev 阶段时动态构建和更新模块依赖关系图的,无需打包整个项目,这也实现了真正的按需加载。

handleHMRUpdate

在 chokidar change 的回调中,还执行了 onHMRUpdate 方法,这个方法会调用执行 handleHMRUpdate 方法

handleHMRUpdate 中主要会分析文件更改,确定哪些模块需要更新,然后将更新发送给浏览器。

浏览器端的 HMR 运行时会接收到更新,并在不刷新页面的情况下替换已更新的模块。

源码地址:packages/vite/src/node/server/hmr.ts

export async function handleHMRUpdate(
  file: string,
  server: ViteDevServer,
  configOnly: boolean,
): Promise<void> {
  const { ws, config, moduleGraph } = server
  // 获取相对路径
  const shortFile = getShortName(file, config.root)
  const fileName = path.basename(file)
  // 是否配置文件修改
  const isConfig = file === config.configFile
  // 是否自定义插件
  const isConfigDependency = config.configFileDependencies.some(
    (name) => file === name,
  )
  // 环境变量文件
  const isEnv =
    config.inlineConfig.envFile !== false &amp;&amp;
    (fileName === '.env' || fileName.startsWith('.env.'))
  if (isConfig || isConfigDependency || isEnv) {
    // auto restart server
    ...
    try {
      await server.restart()
    } catch (e) {
      config.logger.error(colors.red(e))
    }
    return
  }
  ...
  // 如果是 Vite 客户端代码发生更改,强刷
  if (file.startsWith(normalizedClientDir)) {
    // ws full-reload
    return
  }
  // 获取到文件对应的 ModuleNode
  const mods = moduleGraph.getModulesByFile(file)
  ...
  // 调用所有定义了 handleHotUpdate hook 的插件
  for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {
    const filteredModules = await hook(hmrContext)
    ...
  }
  // 如果是 html 文件变更,重新加载页面
  if (!hmrContext.modules.length) {
    // html file cannot be hot updated
    if (file.endsWith('.html')) {
      // full-reload
    } 
    return
  }

  updateModules(shortFile, hmrContext.modules, timestamp, server)
}
  • 配置文件更新、.env更新、自定义插件更新都会重新启动服务 reload server
  • Vite 客户端代码更新、index.html 更新,重新加载页面
  • 调用所有 plugin 定义的 handleHotUpdate 钩子函数
  • 过滤和缩小受影响的模块列表,使 HMR 更准确。
  • 返回一个空数组,并通过向客户端发送自定义事件来执行完整的自定义 HMR 处理
  • 插件处理更新 hmrContext 上的 modules
  • 如果是其他情况更新,调用 updateModules 函数

流程图如下

Vite 开发环境为何这么快?

updateModules 中主要是对模块进行处理,生成 updates 更新列表,ws.send 发送 updates 给客户端

ws 客户端响应

客户端在收到服务端发送的 ws.send 信息后,会进行相应的响应

当接收到服务端推送的消息,通过不同的消息类型做相应的处理,比如 updateconnectfull-reload 等,使用最频繁的是 update(动态加载热更新模块)和 full-reload (刷新整个页面)事件。

源码地址:packages/vite/src/client/client.ts

Vite 开发环境为何这么快?

在 update 的流程里,会使用 Promise.all 来异步加载模块,如果是 js-update,及 js 模块的更新,会使用 fetchUpdate 来加载

if (update.type === 'js-update') {
  return queueUpdate(fetchUpdate(update))
}

fetchUpdate 会通过动态 import 语法进行模块引入

浏览器缓存优化

Vite 还利用 HTTP 加速整个页面的重新加载。
对预构建的依赖请求使用 HTTP 头 max-age=31536000, immutable 进行强缓存,以提高开发期间页面重新加载的性能。一旦被缓存,这些请求将永远不会再次访问开发服务器。

这部分的实现在 transformMiddleware 函数中,通过中间件的方式注入到 Koa dev server 中。

源码地址:packages/vite/src/node/server/middlewares/transform.ts

若需要对依赖代码模块做改动可手动操作使缓存失效:

vite --force

或者手动删除 node_modules/.vite 中的缓存文件。

总结

Vite 采用 No Bundleesbuild 预构建,速度远快于 Webpack,实现快速的冷启动,在 dev 模式基于 ES module,实现按需加载,动态 import,动态构建 Module Graph。

在 HMR 上,Vite 利用 HTTP 头 cacheControl 设置 max-age 应用强缓存,加速整个页面的加载。

当然 Vite 还有很多的不足,比如对 splitChunks 的支持、构建生态 loader、plugins 等都弱于 Webpack。不过 Vite 仍然是一个非常好的构建工具选择。在不少应用中,会使用 Vite 来进行开发环境的构建,采用 Webpack5 或者其他 bundle base 的工具构建生产环境。

参考文章

zhuanlan.zhihu.com/p/467325485

原文链接:https://juejin.cn/post/7256715451144224825 作者:小丞同学

(0)
上一篇 2023年7月18日 上午10:41
下一篇 2023年7月18日 上午10:52

相关推荐

发表回复

登录后才能评论