Code前端首页关于Code前端联系我们

Vite 开发环境为何这么快?

terry 2年前 (2023-09-10) 阅读数 85 #前端教程

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

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

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

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

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

快递的冷启动

No-bundle

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

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

Vite 开发环境为何这么快?Vite 开发环境为何这么快?

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

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

Vite 开发环境为何这么快?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 开发环境为何这么快?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 开发环境为何这么快?

同时发现 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 开发环境为何这么快?Vite 开发环境为何这么快?

创建 WebSocket 服务

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

Vite 开发环境为何这么快?Vite 开发环境为何这么快?

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

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

Vite 开发环境为何这么快?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 开发环境为何这么快?Vite 开发环境为何这么快?

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

ws 客户端响应

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

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

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

Vite 开发环境为何这么快?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 作者:小丞同学

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门