Vite Dev Server 构建原理

当 vite 作为开发服务器时,使用的编译工具为 esbuild,因为快。

比如你使用 pnpm create vite 新建了一个 React + Vite 的项目,那么 package.json 会有个命令 dev,内容是 "vite" 字符串。

顺带一提 pnpm create vite 实际上就是 pnpm dlx create-vite ,也就是直接把 create-vite 这个包下过来之后立即执行。

当你 pnpm dev 启动了 vite 开发服务器,vite 就会监听某个端口(一般是 5173)然后托管静态资源。这就意味着可以通过 http://localhost:5173/:filepath 的形式来请求某个资源。

以开发 SPA 为例,通过 vite 创建的初始项目都会有个 index.html,里面默认都会带一个 <script type="module" src="/src/main.tsx"></script> 这样的东西表明在浏览器中启用 esm。然后,把这个 index.html 放到浏览器中进行加载,加载到这行之后发现要请求 /src/main.tsx 这个资源,然后就会请求 http://localhost:5173/src/main.tsx 这个东西,把它加载进来。

当然浏览器本身是不支持 tsx 文件格式的,所以在请求到这个文件内容之后会立马用 esbuild 进行一次编译,编译到 js 之后再返回给浏览器。

所以简单来说,在开发环境的 Vite 服务器就是在某个端口启动的一个托管了静态资源的服务器(类似于 express.static 之类的玩意),然后会捕获浏览器的 GET 请求。只要判断浏览器的这个 GET 请求路径不是静态资源,那么就会做 HTML fallback 只返回 index.html,然后就会执行这个 html 的内容,发现了 script 的部分就会执行脚本,要编译的部分就交给 esbuild 编译好再返回给浏览器。

如果一个 index.html 有多个 <script> 标签,vite 都会匹配到,然后提前对这些文件做编译。

所以接下来的重头戏在 编译 这件事本身。

编译本身实际上是用了非常非常多的 vite plugin,专门用于编译不同类型的文件,可以类比于 rollup 的插件。核心是 vite:esbuild 插件,这个插件对 js/ts 做编译,然后返回编译后的 code 和 sourcemap,这样子你就可以在 Sources 里面看到编译后的东西和它的源码了。

需要注意的是,esbuild 本身不对源代码中的 import 语句做 resolve,也就是说在 vite:esbuild 的阶段是不会去解析 import 的文件的内容的,也不会像打包时候那样读取文件内容 inline 到编译产物中,而是 非常单纯的把 JS/TS/JSX/TSX 翻译成 ESM 形式的 JS 代码而已。从这里我们可以窥见 Vite 的一个非常重要的设计哲学------

开发模式下,不打包! 保持原生模块结构。

然后,在 vite:esbuild 编译完之后,这个时候的代码已经是 js 代码了,但是其中的 import 代码还是原来的,然后就是调用 vite:import-analysis 插件来对 import 的路径进行解析,使其能够完美兼容我们浏览器执行 js 代码后发起 localhost:5173 本地服务器的请求路径。

换个简单的话来说,就是 vite:import-analysis 把 import 代码解析成浏览器能够实际进行我们本地静态资源服务器请求的资源的路径。

比如,有个 import App from './App.tsx' ,因为你总不能直接请求 localhost:5173/./App.tsx 吧?所以 vite:import-analysis 先会转成绝对路径比如 import App from '/src/App.tsx' ,然后把这段 js 代码发到浏览器之后呢,浏览器执行它。因为浏览器是原生支持 ESM 的(别提那些老浏览器了,我会死),所以执行到这行代码之后,会直接发请求: localhost:5173/src/App.tsx ,然后 vite 拦截到这个请求之后,识别一下,发现这是一个静态资源请求的路径,所以直接再走一遍之前讲的编译、解析、返回的全流程。相当于递归吧,直到解析到最后一个文件为止。这也可以引出 Vite 的另一个设计哲学:

浏览器自己解析 import,最大限度利用浏览器的能力(而不是 Webpack 式的一锅乱炖)

这个"最大限度地利用浏览器能力"到什么地步呢? Vite 是第一个能够直接在 Chrome DevTools Sources 里面对你自己在 IDE 写的源代码进行调试的开发服务器。 这也就意味着,你在开发 web 应用的时候,你不仅可以观察到编译完成的、真的发给浏览器执行的 esm js 代码,你还可以看到由 source map 还原出的你在 IDE 里面写的真实代码。然后你可以直接给你的源代码在 DevTools 里面直接打断点调试。

可以看到,在 Chrome DevTools 里面的 Sources 选项卡的左侧目录栏,看起来就像是一个文件目录树,对不对?其实这就是浏览器根据你传过来的 ESM JavaScript 的路径进行请求,然后把请求到的文件编了一个 ModuleGraph,根据文件路径的先后关系,排列组合成了一个虚拟文件目录树。

这才是真正最大限度地发挥浏览器本身的作用啊,真厉害

顺带一提,这里可以看到同一个名字的文件重复出现了两次,一个是斜体,一个是正常的。这是 Chrome DevTools 特别给提供了 sourcemap 的源代码做的特别优化,正常的是浏览器接到和执行的源代码,斜体的是你直接在 IDE 写的源代码,是从浏览器接到的 JS 代码 还原 来的。为啥这里能还原出原本的代码呢,是因为 vite 在编译完你写的代码之后在代码底部附带了一个 base64 的 JavaScript Sourcemap。

这种 // # sourceMappingUrl=xxx 的形式,就是附带了 sourcemap 的意思,直接内联在编译完的 js 代码中,Chrome DevTools 能够很好的将其解析,也省去了还得额外请求一遍文件的性能开销。

让我们再过一遍整体的解析入口源文件的流程(以 Vite + React + TSX 为例):

当然,仅仅是编译和解析你自己新建的文件的代码的路径解析自然是简单的。但是你平时写东西肯定会用到第三方库,而这第三方库会涉及到很麻烦很麻烦的几个问题,也就是 node_modules。

好的,接下来正式进入 vite dev server 的 node_modules 的解析、编译、加载策略解析。

前面我们基本上已经把 vite dev server 是啥、怎么解析编译文件的基本上都理清楚了。而对于你自己安装的第三方包,有几个非常麻烦的问题需要解决:

  1. 我们知道浏览器是只支持 ESM 的 import 语法的,在执行拿到的 JS 代码时会直接根据 import 的路径去 baseUrl 上请求资源发请求。但是如果安装的第三方包是 CJS 的咋办?
  2. 如果每个模块都是请求时编译,那向 lodash-es 这种包,它有几百个模块的 import,这样跑起来,一个 node_modules 下的包就有几百个请求,依赖多了以后很容易几千个请求,直接交给浏览器会崩溃的。

这些问题我们得全部解决。我们期望的效果是,每个你安装的包都应该是一个 ESM,而且要把每一个包全部做提前打包,打成 一个 ESM。

而这些问题 Vite 全部解决的非常完美,而这个过程有一个专门的词去描述------prebundle,预构建。

当我们在启动 vite dev server 的时候,vite 就会 立马 对 node_modules 底下的代码做一次打包,这个过程叫 deps optimize,也就是依赖优化。

在当前 Vite 版本中(我看的是 6.3.5 版本的源码)这个已经被内联到了 DevEnvironment 这个 class 的 constructor 中了,也就意味着只要 new 了它就会自动执行依赖优化。虽然可以配置是否关闭,不过默认是 true。

在启动 vite dev server 的时候,首先会执行一遍 resolveConfig 函数

它的主要作用是 整合、处理和规范化各种来源的配置信息,最终生成一个供内部各模块使用的、完全解析的、不可变的配置对象(ResolvedConfig) 。可以把它想象成一个大管家,负责把用户写的、插件提供的、以及 Vite 默认的各种配置项,按照一定的规则和优先级, meticulously 地整理和计算,最后输出一份清晰、完整、可以直接使用的行动指南。按照我们平常用的话,主要就是解析 vite.config.ts 这个文件以及其中的各种配置项。

然后这个函数会调用 resolveEnvironmentOptions 函数解析 config 中的 environment 配置:

我们主要看 dev 部分的函数:

这个函数会执行 defaultCreateClientDevEnvironment 这个函数:

这个函数就是 new DevEnvironment 的所在之处:

所以可以理解为在启动 dev server 的时候的第一件事就是自动执行一遍依赖优化。这件事情在进行任何 JS/TS 代码解析之前,甚至是启动服务器、监听端口之前。这也确保了之后的 vite:import-analysis 插件能够正确解析 import 的 node_modules、浏览器也能够正确的执行编译完的 JS 代码,进行 import 的 ESM 模块请求。

明白了是在什么时机进行的依赖优化,我们回过头看看依赖优化本身做了什么工作。

依赖优化原本的代码在这:

github.com/vitejs/vite...

核心是 createDepsOptimizer 这个函数。这个函数的里面有个闭包函数 init ,这个函数里面进行了实际的依赖项扫描:

这个函数又是调用了 scanImports 这个函数进行扫描:

这个函数最终返回由 prepareEsbuildScanner 函数处理完成的结果:

跟踪这个函数,我们发现它的返回值最终其实是返回由 esbuild 编译后的结果。也就是说,扫描出 import 的 node_modules 依赖这件事本身是由 esbuild 来做的。esbuild.context 和 esbuild.build 的功能差不多,不过比 build 更高级,支持监视模式和本地开发服务器等附加功能。

下面来解析一下这里的 esbuild.context 配置。

  1. stdin 是 esbuild 的标准输入字段,实际上就是指明需要进行打包的入口文件。如果进行 Debug,可以看到 esbuild.context 的标准输入的 contents 就是咱们 Vite + React 项目中的 index.html 文件,所以实际上这段打包的含义就是对入口 index.html 开始做打包。
  2. 输出格式为 ESM。
  3. write 为 false,不写入磁盘。因为 Vite 在扫描阶段不希望 esbuild 真正生成任何文件到磁盘上,因为它的目的仅仅是分析依赖关系图,而不是产出构建结果。不要忘了,这个过程本身是可以给 esbuild 的 plugin 提供上下文的。即使不产生结果,plugin 也能够进行它们该干的工作。这是下面的重点。

我们知道,esbuild 原本只是支持 js/ts 而不支持 html 的,为了能够从入口文件正确解析 html 引入的 js/ts/jsx/tsx 文件,vite 专门写了一个内置的 Plugin:

这个 plugin 能够在 esbuild 构建过程中被插进去,消费 esbuild 构建过程的上下文然后收集依赖。

没错,这个 esbuildScanPlugin 实际上就是 进行依赖扫描的根本函数,靠这个函数将依赖扫描到 depImports 数组中。这个数组由外部传入,由这个插件加工完之后给上下文消费。

核心工作流程就是:esbuild 在尝试构建一个虚拟入口(由所有真实入口 import 组成)时,这个插件会拦截和处理各种文件的解析和加载请求。它会智能地识别出项目实际依赖的第三方包,并将它们收集起来,同时将非 JavaScript 或不需要预构建的资源标记为外部依赖,从而让 esbuild 只关注于 JavaScript 相关的依赖关系图谱构建。

所以 discover = discoverProjectDependencies(devToScanEnvironment(environment))const deps = await discover.result 最终的结果就是通过 esbuild 构建中使用插件来扫依赖。这样子之后 就知道需要对哪些包进行依赖优化了。

对了,举个例子吧。假设你的 main.tsx 的内容是这样的:

tsx 复制代码
import { isVisible } from '@scope/ui-library'
import { debounce } from 'lodash-es'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { calculate } from './utils/math.js'

那用这个插件实际分析出来的 deps 对象大概长这样:

json 复制代码
{
  "react": "/Users/yourname/yourproject/node_modules/react/index.js",
  "react-dom/client": "/Users/yourname/yourproject/node_modules/react-dom/client.js",
  "lodash-es": "/Users/yourname/yourproject/node_modules/lodash-es/lodash.js",
  "@scope/ui-library": "/Users/yourname/yourproject/node_modules/@scope/ui-library/dist/index.js"
}

Key 是你直接 import 的包名,然后 value 就是具体的 node_modules 的文件路径。

知道了哪些要优化之后,调用 runOptimizeDeps 函数进行真正的依赖优化,这个函数中再调用 prepareEsbuildOptimizerRun 函数,直接用 esbuild.context 来对这些包本身进行打包,输出 ESM:

不过这里其实还有个细节,我们可以看到这里的 entryPoints 是 Object.keys(flatIdDeps) ,实际上

这里的 flatIdDeps 就是进行过 ID 扁平化后的 实际分析出来的 deps 对象,接着上面的举例:

json 复制代码
{
  "react": "/Users/yourname/yourproject/node_modules/react/index.js",
  "react-dom_client": "/Users/yourname/yourproject/node_modules/react-dom/client.js", // 注意这里的扁平化
  "lodash-es": "/Users/yourname/yourproject/node_modules/lodash-es/lodash.js",
  "@scope_ui-library": "/Users/yourname/yourproject/node_modules/@scope/ui-library/dist/index.js" // 注意这里的扁平化
}

把全部的依赖项扁平化。这也是为啥我们可以看到优化后的文件(在 node_modules/.vite/deps 下边)都是有下划线的,因为把 / 给扁平化成了 _

可以看到,提前进行依赖优化的模块都已经被 esbuild 打包成单个 esm,放到 node_modules/.vite/deps 里面,而 vite:import-analysis 插件就会把这些 import 的裸模块路径给重写到这里。

为啥要扁平化呢?简单来说,esbuild 默认的输出目录结构可能比较复杂且难以预测,特别是当入口文件路径本身就包含目录层级时。通过将所有依赖 ID 扁平化为一个简单的字符串,Vite 可以更精确地控制 esbuild 的入口和输出,使得后续的入口/输出映射分析更容易。esbuild 会将这些扁平化的 ID 作为入口文件名(不带扩展名),生成对应的打包文件。

不过问题是,既然 esbuild.context 的 entryPoints 是 ID,那这根本不是真实的 JS 入口文件,esbuild 怎么进行打包呢?这个时候我们的插件又出场了,Vite 内部实现了 esbuildDepPlugin 插件,专门用来处理 flatIdDeps 的映射。当 esbuild 开始读这个入口 ID 的时候这个插件就会介入,然后根据这个映射把 ID 换成真实 JS 文件路径。

那么为什么非得转一圈,专门用插件来映射路径,我直接 Object.values(flatIdDeps) 这样子难道不行吗?答案是,不行。直接用 Object.values(flatIdDeps) (原始路径) 作为入口,esbuild 会"自作主张"地根据原始路径来组织输出,这不符合 Vite 对预构建产物目录结构的期望。通过引入扁平化 ID 和插件的机制,Vite 能够"欺骗"esbuild,让它以为入口点就是这些简单的扁平化字符串,从而使得输出的文件名也变得简单直接。而真正的路径解析和内容加载则由插件在幕后完成。

你可以理解为,我传入 key,是骗了 esbuild "我要输出结果到 key 这个路径下哦",但是实际上由于插件的介入,实际进行打包的入口文件还是 value。太妙了!

顺带一提,我们可以看到上面的 node_modules/.vite/deps 里面似乎不止有扁平化之后的 .js 文件,还有一些其他的玩意:

  1. _metadata.json
  2. 一堆 chunk-XXX.js
  3. package.json

第一个咱们先留个悬念,它和 vite 对于第三方库的缓存策略息息相关。

第二部分,一堆 chunk,实际上这些是 Vite 在 prebundle 的时候发现某些模块被多个依赖同时使用时,自动生成的共享 chunk。类似于 Rollup 中的 manualChunks 拆包。这个过程是 esbuild 自动进行的公共模块提取,不是手动做的。

第三部分,package.json。为啥要在这个目录下放个这东西呢?我们点进去看看:

就一个 "type": "module" ?这到底是啥用呢? 实际上是为了确保 .vite/deps 目录中的所有 .js 文件都被当作 ES Modules 来处理,避免模块语义不一致的问题,兼容 Node、调试工具和 Vite 自身行为。它告诉 Node.js 和其他遵循 package.json 规范的工具(包括 Vite 自身),在这个 deps 目录以及其所有子目录下的 .js 文件都应该被当作 ES Module 来对待。这样子之后,不管是浏览器执行 JS 代码时遇见 import 路径在 /node_modules/.vite/deps/xx.js,还是在 Node 环境下需要解析这些文件,都能够顺利的被当成 ESM 来对待。虽然这些文件本身就是 ESM 就是了。

好,接下来我们来进入对于 _metadata.json 的解析。

前面我们已经讲了 Vite 这么完备的依赖优化手段,实际上还没有做到万无一失。我们平常使用 vite 开发前端应用的依赖项的数目本身可能也是十分多的,如果每一次我们启动 dev server 都要去走一遍这样的依赖构建流程然后走网络请求,实际上很多时候是完全不必要的------因为安装的依赖项本身的代码我们绝大多数情况是不会自己手动更改的。

因此,对于 node_modules 依赖项,vite 做的最后一层优化就是缓存

我们回过头去看一下上面那个截图的代码:

tsx 复制代码
import '/__uno.css'
import '/node_modules/.pnpm/@[email protected]/node_modules/@unocss/reset/tailwind.css'
import __vite__cjsImport1_reactDom_client from '/node_modules/.vite/deps/react-dom_client.js?v=cca3f84b'
import __vite__cjsImport0_react_jsxDevRuntime from '/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=cca3f84b'
import App from '/src/App.tsx'
const jsxDEV = __vite__cjsImport0_react_jsxDevRuntime.jsxDEV
const createRoot = __vite__cjsImport1_reactDom_client.createRoot
createRoot(document.getElementById('root')).render(/* @__PURE__ */
  jsxDEV(App, {}, void 0, false, {
    fileName: 'D:/Folders/code_life/react_about/demo/src/main.tsx',
    lineNumber: 6,
    columnNumber: 53
  }, this)
)

// # sourceMappingURL=data:application/json;base64,xxx

可以看到,import 的路径除了 /node_modules/.vite/deps/react-dom_client.js 以外,还有个查询参数 ?v=cca3f84b 。后面的这个 v=cca3f84b 是什么呢?

我们打开 _metadata.json

json 复制代码
{
  "hash": "6b730507",
  "configHash": "a164052b",
  "lockfileHash": "fe6d65f4",
  "browserHash": "cca3f84b",
  "optimized": {
    "react": {
      "src": "../../.pnpm/[email protected]/node_modules/react/index.js",
      "file": "react.js",
      "fileHash": "4022a9f9",
      "needsInterop": true
    },
    "react-dom": {
      "src": "../../.pnpm/[email protected][email protected]/node_modules/react-dom/index.js",
      "file": "react-dom.js",
      "fileHash": "20d54d0f",
      "needsInterop": true
    },
    "react/jsx-dev-runtime": {
      "src": "../../.pnpm/[email protected]/node_modules/react/jsx-dev-runtime.js",
      "file": "react_jsx-dev-runtime.js",
      "fileHash": "0207c541",
      "needsInterop": true
    },
    "react/jsx-runtime": {
      "src": "../../.pnpm/[email protected]/node_modules/react/jsx-runtime.js",
      "file": "react_jsx-runtime.js",
      "fileHash": "099c43b8",
      "needsInterop": true
    },
    "classnames": {
      "src": "../../.pnpm/[email protected]/node_modules/classnames/index.js",
      "file": "classnames.js",
      "fileHash": "2c9a84ee",
      "needsInterop": true
    },
    "react-dom/client": {
      "src": "../../.pnpm/[email protected][email protected]/node_modules/react-dom/client.js",
      "file": "react-dom_client.js",
      "fileHash": "bc3255af",
      "needsInterop": true
    }
  },
  "chunks": {
    "chunk-EVPYLNJY": {
      "file": "chunk-EVPYLNJY.js"
    },
    "chunk-LIFRF7L7": {
      "file": "chunk-LIFRF7L7.js"
    },
    "chunk-BUSYA2B4": {
      "file": "chunk-BUSYA2B4.js"
    }
  }
}

嗯?cca3f84b 是不是刚好就是这个 JSON 里面的 browserHash 的字段值?

实际上,这里也是利用了浏览器本身的 强缓存 能力------当 JS 执行到 import __vite__cjsImport1_reactDom_client from "/node_modules/.vite/deps/react-dom_client.js?v=cca3f84b"; 这一行之后,会发起请求: localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=cca3f84b ,这是一个带了个查询参数的请求。在发起这个请求之后,浏览器会根据整个请求的路径(当然,包括后面这个查询参数的 hash 值),把整个 JS 文件给强缓存下来。

Cache-Control: max-age=31536000, immutable 的含义是在资源被首次请求之后的 1 年内(365 天 × 24 小时 × 60 分钟 × 60 秒 = 31536000 秒),浏览器或中间代理缓存都可以认为这个资源是"新鲜的",无需重新向服务器发起请求。immutable 也就是"不可变的"的意思。

不过我们也能够看得出来,这里的强缓存实际上是和后面查询参数的 v 的 hash 值是挂钩的。一旦 hash 值变了,那么就需要重新走一遍上面的依赖优化流程并重新请求加载强缓存。

那么什么时候,这个 hash 值会变呢?换句话说,什么时候需要将资源重新加载一次呢?这点其实在 Vite 官方文档中已经交代的很清楚了:

可以看到,如果我们重新改变了一些依赖项导致 lock 文件变更,或者是改了 vite.config.ts 内容等都会进行重新预构建。而重新预构建之后,通过查询参数(也就是 hash 值)发生变化来绕开强缓存,这是一种显式 cache-busting 技术。 这个 hash 值是通过源码中的 getOptimizedBrowserHash 函数计算出来的:

最终的结果是由 hash、依赖项对象、时间戳三个值共同决定。第一个 hash 是由 lockfile hash 与 config hash 共同计算得出的。getHash 函数接一个字符串,底层调用 crypto 模块的 createHash 函数,使用 sha256 加密算法,然后把产生的哈希字符串截到前八位。

所以你可以理解为,这个哈希是通过 lockfile、vite config、时间戳、依赖项对象共同决定的。有一个变了,那么 hash 就要重新算,缓存也会失效。

至此,我们基本上已经把 Vite Dev Server 的基本内容全部涵盖,还差个 HMR,不过这个东西的重要程度是得单独开一篇文章来讲清楚的,等我再研究研究吧(咕咕咕)

从一开始启动静态资源服务器到递归编译,最后到依赖处理的各种层出不穷的优化手段,Vite 在设计之初就明确了"最大限度利用原生浏览器特性"的目标,事实证明它也做到了。用精炼的话来说,Vite 做的就是把我们在 IDE 写的代码起了个 Node 服务,给浏览器访问我们的项目目录本身做了 100% 的适配工作。

相关推荐
charlee441 小时前
给Markdown渲染网页增加一个目录组件(Vite+Vditor+Handlebars)(上)
javascript·vite·markdown·vditor·handlebars
程序猿小D2 小时前
第24节 Node.js 连接 MongoDB
数据库·mongodb·npm·node.js·编辑器·vim·express
无知好快_Sosoo浪浪3 小时前
Node.js版本管理
node.js
不想说话的麋鹿5 小时前
《NestJS 实战:RBAC 系统管理模块开发 (三)》:角色权限分配与数据一致性
前端·后端·node.js
程序猿小D6 小时前
第26节 Node.js 事件
服务器·前端·javascript·node.js·编辑器·ecmascript·vim
hnlucky6 小时前
安装vue的教程——Windows Node.js Vue项目搭建
前端·javascript·vue.js·windows·node.js
前端服务区7 小时前
package.json文件
node.js
春秋半夏8 小时前
用 React + Tailwind CSS 打造现代博客:功能解析与最佳实践
react.js·node.js
田本初12 小时前
npm符号链接
前端·npm·node.js
water14 小时前
你需要知道的 Node 版本管理工具 fnm——一次彻底的前端工程环境升级
node.js·前端工程化