前端工程化基建探索(5)Vite4.0.0都悄悄在“卷”出来了,是时候去探索Vite的设计和实现了

6,411 阅读7分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

上篇我们提到webpack相关的设计和实现,Vite的出现确实动摇了一下老大哥webpack的江湖地位,从2021年2月份Vite2更新,到2022年7月份Vite3发布,到今年2022年11月份Vite 4.0.0-alpha.0 (2022-11-07)开始更新,不得不说,Vite在改善前端开发体验的道路上越来越卷了,下面我们还是带着问题去探索Vite的核心设计和实现:

image.png

一、Vite号称下一代的前端工具链,解决了什么问题?

1、改进了前端开发服务器启动时间

Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了传统工具,在开发服务器启动时间

依赖,Vite 将会使用 esbuild 预构建依赖,esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍

image.png

源码,这里通常是指一些需要转换的文件(比如JSX,CSS 或者 Vue/Svelte 组件),Vite 以 原生 ESM 方式提供源码,让浏览器接管打包程序的工作,并按需提供源码。

二、Vite启动都做了什么?

注: 本文vite源码版本为 v3.2.4

因为在之前的文章有写类似查看源码的方式,这里就快速定位了 packages/vite/src/node/cli.ts

image.png 可以看到这里创建server,是通过引用packages\vite\src\node\server\index.ts 里的createServer() 方法,我们重点看看这个方法主要干了什么事情

image.png

我们看着这段代码往下读:

...
 const config = await resolveConfig(inlineConfig, 'serve', 'development')
 ...

(1)首先会通过resolveConfig函数解析启动服务时候需要的配置,这包含plugins 用户插件和内建插件、cacheDirnpm 依赖预构建之后的缓存目录、在之后浏览器按需获取文件时对请求进行截获,返回相对应内容的处理函数 createResolve ,以及定义在 vite.config.js 里面的 resolve ,包含用户自定义的一些 alias 文件的处理等。

...
  const middlewares = connect() as Connect.Server
  const httpServer = middlewareMode
    ? null
    : await resolveHttpServer(serverConfig, middlewares, httpsOptions)
  const ws = createWebSocketServer(httpServer, config, httpsOptions)
  ...

(2)创建httpServerws服务,创建watcher,设置代码文件监听,创建server对象, 文件监听变动,websocket向前端通信。然后注册一系列中间件用于处理浏览器请求,包括对 / 、js/css/vue 的请求等

...
  const container = await createPluginContainer(config, moduleGraph, watcher)
  ...

(3)创建插件处理中心container

...
 const server: ViteDevServer = {
 ...
  }
  server.transformIndexHtml = createDevHtmlTransformFn(server)
  ...

(4)处理 html 进行转换,在 html 文件中注入我们在 localhost network 面板中看到的 <script type="module" src="/@vite/client"></script> 脚本,运行 vite 相关的 client 脚本内容。

image.png
(5)在服务启动前,首先执行 container.buildStart({}) 调用所有注册插件的 buildStart 钩子函数,然后运行 initDepsOptimizer() 优化预构建的依赖,接收来自浏览器的请求。

三、解析通过源码vite的中的一些原理

3.1 Vite 为什么要做预构建?

个人认为这是Vite在追求极速的服务启动,牺牲首屏渲染速度之间的一个选择。实现Vite 针对用户项目中的各种文件都是不做打包处理的,而是在浏览器运行时按需请求,并进行转换处理,相比其他构建工具需要打包依赖再启动服务来说,在耗时上是质的飞跃。

前面我们对vite启动过程的分析知道,启动Vite它会收集处理config 、注册各种中间件、初始化一些之后会用到的插件容器 container 以及模块依赖图 moduleGraph 等事情都不消耗时间的,最消耗时间的就依赖预构建。我们可以简单看一下Vite的预构建做了那些事情

image.png
(1) 缓存判断,node_modules\.vite\_metadata.json看看是不是有上次缓存的预构建结果,通过判断存储缓存信息文件hash值是否与最新的hash值相同,要是相同则返回上次缓存的预构建结果

(2)依赖扫描,如果没有缓存结果和缓存hash值不一致,通过discoverProjectDependencies()进行依赖扫描,从入口开始收集依赖,得到deps,将这些依赖项添加到已发现的列表中,preAliasPlugin用来支持别名和优化的deps。

image.png
(3)构建依赖,通过esbuildDepPlugin()在这个过程中它将将非 ESM 规范的代码转换为符合 ESM 规范的代码,将第三方依赖内部的多个文件合并为单一的可缓存文件,减少 http 请求数量。

image.png
网上有人专门拿出import { debounce } from "lodash-es"做实验,直接使用会时浏览器会导入600+个文件,需要1秒多,而经过依赖预构建之后,浏览器只需要导入一个文件,且只需 20 ms

(4)最后,执行writeFile,再将相关信息保存到_metadata.json,我们可以看看它的内容

{
  "hash": "1a547ddf",
  "browserHash": "2065b8ab",
  "optimized": {
    "@vue/runtime-core": {
      "file": "E:/codeWorlk/xxx/node_modules/.vite/@vue_runtime-core.js",
      "src": "E:/codeWorlk/xxx/node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js",
      "needsInterop": false
    },
    "vue": {
      "file": "E:/codeWorlk/xxx/node_modules/.vite/vue.js",
      "src": "E:/codeWorlk/xxx/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "needsInterop": false
    },
    "vue-router": {
      "file": "E:/codeWorlk/xxx/node_modules/.vite/vue-router.js",
      "src": "E:/codeWorlk/xxx/node_modules/vue-router/dist/vue-router.esm-bundler.js",
      "needsInterop": false
    },
    "@vue/reactivity": {
      "file": "E:/codeWorlk/xxx/node_modules/.vite/@vue_reactivity.js",
      "src": "E:/codeWorlk/xxx/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js",
      "needsInterop": false
    }
  }
}

从上面我们可以看到 Vite 只对 npm 依赖进行预构建,对于用户编写的文件不进行预处理,而是通过浏览器支持的 ES Module 来进行按需读取,所以如果用户文件过多,且没有进行一定的 Code Spliting 等操作。这时候首屏加载渲染是非常慢的。

3.2 一个请求到Vite服务的过程是怎么样的?

<script type="module" src="/src/main.js"></script>
// or
import { get } from './utils'

我们通过上文Vite创建服务的过程源码看到 通过transformMiddleware()函数去处理请求


// main transform middleware
middlewares.use(transformMiddleware(server))

image.png (1)首先它会判断是否请求, 如果是请求,就会进入使用插件容器解析、加载和转换,不是请求就交给其他插件处理 (2)transformRequest()进行解析、加载和转换请求 (3)判断依赖图谱中是否已经存在,存在的话直接返回module
(4)没有话调用插件的路径解析钩子(对于不同的资源会有不同的插件去处理),创建module并保存 (5)最后调用esbuildPlugin插件的transfrom钩子,返回编译后的code,并缓存到依赖map里

(3)Vite 是怎么实现快速的热重载(HMR)?

HMR的原理大多差不多,主要是通过WebSocket创建浏览器和服务器的通信监听文件的改变,当文件被修改时,服务端发送消息通知客户端修改相应的代码,客户端对应不同的文件进行不同的操作的更新。 我们回顾启动Vite服务的(createServer)时候 const ws = createWebSocketServer(httpServer, config, httpsOptions)就启动了WebSocketServer服务,然后监听文件的变化

  watcher.on('change', async (file) => {
    file = normalizePath(file)
    if (file.endsWith('/package.json')) {
      return invalidatePackageData(packageCache, file)
    }
    // invalidate module graph cache on file change
    moduleGraph.onFileChange(file)
    if (serverConfig.hmr !== false) {
      try {
        await handleHMRUpdate(file, server)
      } catch (err) {
        ws.send({
          type: 'error',
          err: prepareError(err)
        })
      }
    }
  })

如果有变化,则则执行监听回调函数handleHMRUpdate(),重新打包文件,并通知到客户端,客户端接收到信息,重新加载到更新后的模块。

最后

Vite4.0.0都悄悄在“卷”出来了,期待它在一次次更新中对前端人更加的友好,本文也是浅读源码其中的核心流程,更多细节,比如Vite的插件机制是怎么样的,具体是怎么样做文件扫描的,都值得各位前端人探索!

下一篇: 前端工程化基建探索(6)前端人对前端资产建设的思考和实践