(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)

 vite分享ppt,感兴趣的可以下载:

​​​​​​​Vite分享、原理介绍ppt

什么是vite系列目录:

(一)什么是Vite——vite介绍与使用-CSDN博客

(二)什么是Vite——Vite 和 Webpack 区别(冷启动)-CSDN博客

(三)什么是Vite——Vite 主体流程(运行npm run dev后发生了什么?)-CSDN博客

(四)什么是Vite——冷启动时vite做了什么(源码、middlewares)-CSDN博客

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)-CSDN博客

未完待续。。。

什么是预构建

当你首次启动 vite 时,Vite 在本地加载你的站点之前预构建了项目依赖。默认情况下,它是自动且透明地完成的。

当我们在第一次启动项目的时候,可以在命令行窗口看见如下的信息

同时,在项目启动成功后,你可以在根目录下的node_modules中发现.vite目录,这就是预构建产物文件存放的目录.

预构建的结果默认保存在node_modules/.vite中,具体预构建的依赖列表在_metadata.json 文件中,其中_metadata.json 的内容为一个 json 结构

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第1张图片

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第2张图片

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第3张图片

为什么要依赖预购建:原因 #

这就是 Vite 执行时所做的“依赖预构建”。这个过程有两个目的:

1、CommonJS 和 UMD 兼容性:

在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。

在转换 CommonJS 依赖项时,Vite 会进行智能导入分析,这样即使模块的导出是动态分配的(例如 React),具名导入(named imports)也能正常工作:

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

2、性能:

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

有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。

通过将 lodash-es 预构建成单个模块,现在我们只需要一个HTTP请求!

注意:

依赖预构建仅适用于开发模式,并使用 esbuild 将依赖项转换为 ES 模块。在生产构建中,将使用 @rollup/plugin-commonjs。

简单讲,可总结为以下两点:

  • 将非 ESM 规范的代码转换为符合 ESM 规范的代码;
  • 将第三方依赖内部的多个文件合并为一个,减少 http 请求数量;

预构建是用来提升页面重载速度,预构建这一步由 esbuild 执行,这使得 Vite 的冷启动时间比任何基于 JavaScript 的打包程序都要快得多。

缓存:如何开启预构建

当首次启动 vite 时,Vite 在本地加载你的站点之前预构建了项目依赖。默认情况下,它是自动且透明地完成的。

 Vite 同时利用 HTTP 头来加速整个页面的重新加载:源码模块的请求会根据 304 Not Modified 进行协商缓存。

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第4张图片

而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求。 

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第5张图片

文件被设置为缓存一年过期。那么,下次再访问该文件的时候会直接走浏览器缓存,并不会再经过 Vite Dev Server.

预构建的结果默认保存在node_modules/.vite中,具体预构建的依赖列表在_metadata.json 文件中,其中_metadata.json 的内容为一个 json 结构

如果以下几个地方都没有改动,Vite 将一直使用缓存文件:

  • 包管理器的锁文件内容,例如 package-lock.json,yarn.lock,pnpm-lock.yaml,或者 bun.lockb;
  • 补丁文件夹的修改时间;
  • vite.config.js 中的相关字段;
  • NODE_ENV 的值。

上面提到了预构建中本地文件系统的产物缓存机制,而少数场景下我们不希望用本地的缓存文件,比如需要调试某个包的预构建结果,推荐使用下面任意一种方法清除缓存,还有手动开启预构建:

  • 删除node_modules/.vite目录。
  • 在 Vite 配置文件中,将server.force设为true。
  • 命令行执行npx vite --force或者npx vite optimize。

需要预构建的模块: #

  • 只有 bare import(裸依赖)会执行依赖预构建
    • bare import:一般是 npm 安装的模块,是第三方的模块,不是我们自己写的代码,一般情况下是不会被修改的,因此对这部分的模块提前执行构建并且进行缓存,有利于提升性能。
    • monorepo下的模块不会被预构建,部分模块虽然是 bare import,但这些模块也是开发者自己写的,不是第三方模块,因此 Vite 没有对该部分的模块执行预构建。(Monorepo 是一种将多个项目存放在同一个代码库中的开发策略。Monorepo 里面的每个功能模块,则像积木一样,有标准的接口,即使从这个项目中拆离出去,也能使用。)
// vue 是 bare import
import vue from "vue"
import xxx from "vue/xxx"

// 以下不是裸依赖,用路径去访问的模块,不是 bare import
import foo from "./foo.ts" 
import foo1 from "/foo.ts" 

 (五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第6张图片

  • vite的判断
    • 实际路径在 node_modules 的模块会被预构建,这是第三方模块。
    • 实际路径不在 node_modules 的模块,证明该模块是通过文件链接,链接到 node_modules 内的(monorepo 的实现方式),是开发者自己写的代码,不执行预构建。
    • 如下[‘vue’,‘axios’]会被判断为裸模块

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第7张图片

Monorepo 和 链接依赖 #

在一个 monorepo 启动中,该仓库中的某个包可能会成为另一个包的依赖。Vite 会自动侦测 没有从 node_modules 解析的依赖项,并将链接的依赖视为 源码。它不会尝试打包被链接的依赖,而是会分析被链接依赖的依赖列表。

然而,这需要被链接的依赖被导出为 ESM 格式。如果不是,可以在配置里将此依赖添加到 optimizeDeps.include 和 build.commonjsOptions.include 这两项中。

export default defineConfig({
  optimizeDeps: {
    include: ['linked-dep'],
  },
  build: {
    commonjsOptions: {
      include: [/linked-dep/, /node_modules/],
    },
  },
})

当对链接的依赖进行更改时,请使用 --force 命令行选项重新启动开发服务器,以使更改生效。

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第8张图片

重复删除:

由于链接的依赖项解析方式不同,传递依赖项(transitive dependencies)可能会被错误地去重,从而在运行时出现问题。如果遇到此问题,请使用 npm pack 命令来修复它。

自定义行为 (optimizeDeps.include 、 optimizeDeps.exclude)

有时候默认的依赖启发式算法(discovery heuristics)可能并不总是理想的。如果您想要明确地包含或排除依赖项,可以使用 optimizeDeps 配置项 来进行设置。(启发式算法,是一种常用于求解复杂优化问题的计算方法,其主要思想是模拟人类或自然界中蕴含的智慧和经验来寻找问题最优解。定义:一个基于直观或经验构造的算法,在可接受的花费(指计算时间和空间)下给出待解决组合优化问题每一个实例的一个可行解,该可行解与最优解的偏离程度一般不能被预计。)

optimizeDeps.include 或 optimizeDeps.exclude 的一个典型使用场景,是当 Vite 在源码中无法直接发现 import 的时候。例如,import 可能是插件转换的结果。这意味着 Vite 无法在初始扫描时发现 import —— 只能在文件被浏览器请求并转换后才能发现。这将导致服务器在启动后立即重新打包。

include 和 exclude 都可以用来处理这个问题。如果依赖项很大(包含很多内部模块)或者是 CommonJS,那么你应该包含它;如果依赖项很小,并且已经是有效的 ESM,则可以排除它,让浏览器直接加载它。

你也可以使用 optimizeDeps.esbuildOptions 选项 来进一步自定义 esbuild。例如,添加一个 esbuild 插件来处理依赖项中的特殊文件。

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第9张图片

Vite 会根据应用入口(entries)自动搜集依赖,然后进行预构建,这是不是说明 Vite 可以百分百准确地搜集到所有的依赖呢?事实上并不是,某些情况下 Vite 默认的扫描行为并不完全可靠,这就需要联合配置include来达到完美的预构建效果了。接下来,我们好好梳理一下到底有哪些需要配置include的场景。

inclued 使用场景——动态 import:

在这个例子中,动态 import 的路径只有运行时才能确定,无法在预构建阶段被扫描出来。因此,Vite 运行时发现了新的依赖,随之重新进行依赖预构建,并刷新页面。这个过程也叫二次预构建。在一些比较复杂的项目中,这个过程会执行很多次。

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第10张图片

然而,二次预构建的成本也比较大。我们不仅需要把预构建的流程重新运行一遍,还得重新刷新页面,并且需要重新请求所有的模块。尤其是在大型项目中,这个过程会严重拖慢应用的加载速度!因此,我们要尽力避免运行时的二次预构建。我们可以通过 include 参数提前声明需要按需加载的依赖。

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第11张图片

exclude 是optimizeDeps中的另一个配置项,与include相对,用于将某些依赖从预构建的过程中排除。不过这个配置并不常用,也不推荐大家使用。如果真遇到了要在预构建中排除某个包的情况,需要注意它所依赖的包是否具有 ESM 格式,如下面这个例子:

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第12张图片

手动排除了预构建的包,@ loadable/component本身具有ESM格式的产物,但他的某个依赖hoist-non-react-statics的产物并没有提供ESM格式,导致运行时加载错误。

这个时候include配置就派上用场了,我们可以强制对hoist-non-react-statics这个间接依赖进行预构建:

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第13张图片

在include参数中,我们将所有不具备 ESM 格式产物包都声明一遍,这样再次启动项目就没有问题了。

依赖预购建全流程

到这里我们就已经了解了 Vite 依赖预编译概念和作用。我想大家都会好奇这个过程又是怎么实现的?下面,我们就深入 Vite 源码来更进一步地认识依赖预编译过程。

预构建的入口是initDepsOptimizer函数,在initDepsOptimizer函数里面,调用createDepsOptimizer创建了一个依赖分析器,并且通过loadCachedDepOptimizationMetadata获取了上一次预构建的产物cachedMetadata。

Vite 在 optimizeDeps 函数中调用 loadCachedDepOptimizationMetadata 函数,读取上一次预构建的产物,如果产物存在,则直接return。

如果不存在则调用discoverProjectDependencies(通过scanImports来扫描依赖,而scanImports则是通过esbuild插件esbuildScanPlugin来工作的。scanImports会通过computeEntries方法获取入口文件,其中核心方法就是entries = await globEntries('**/*.html', config)。)对依赖进行扫描,获取到项目中的所有依赖,并返回一个deps。

然后通过toDiscoveredDependencies函数把依赖包装起来,再通过runOptimizeDeps进行依赖打包。

返回metadata产物。

1.首先会去查找缓存目录(默认是 node_modules/.vite)下的 _metadata.json 文件;然后找到当前项目依赖信息(xxx-lock 文件)拼接上部分配置后做哈希编码(depHash),最后对比缓存目录下的 hash 值是否与编码后的 hash 值一致,一致并且没有开启 force 就直接返回预构建信息,结束整个流程;

为什么第一次启动时会输出预构建相关的 log 信息了,其实这些信息都是通过依赖扫描阶段来搜集的,而此时还并未开始真正的依赖打包过程。

可能大家有个疑问,为什么项目入口打包一次就能够收集到所有的依赖信息呢?事实上,日志的收集都是使用esbuildScanPlugin这个函数创建 scan 插件来实现的,在 scan 插件里面就是解析各种 import 语句,最终通过它来记录依赖信息。

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第14张图片

首先,是预构建缓存的判断。Vite 在每次预构建之后都将一些关键信息写入到了_metadata.json文件中,第二次启动项目时会通过这个文件中的 hash 值来进行缓存的判断,如果命中缓存则不会进行后续的预构建流程

如果没有命中缓存,则会正式地进入依赖预构建阶段。不过 Vite 不会直接进行依赖的预构建,而是在之前探测一下项目中存在哪些依赖,收集依赖列表,也就是进行依赖扫描的过程。并且,这个过程是必须的,因为 Esbuild 需要知道我们到底要打包哪些第三方依赖。

收集完依赖之后,就正式地进入到依赖打包的阶段了,主要是调用 Esbuild 进行打包并写入产物到磁盘中。

在打包过程完成之后,Vite 会拿到 Esbuild 构建的元信息,也就是上面代码中的meta对象,然后将元信息保存到_metadata.json文件中。

详解 Vite 依赖预构建流程: #

首次执行 vite 时,服务启动后会对 node_modules 模块和配置 optimizeDeps 的目标进行预构建(demo)

// 先准备好一个例子。用 vue 的模板去初始化 DEMO:
npm create vite my-vue-app -- --template vue

// 项目创建好之后,我们再安装 lodash-es 这个包,去研究 vite 是如何将几百个文件打包成一个文件的:
npm i lodash-es

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第15张图片

在开启dev server之前进行依赖预构建。首先,vite会执行一个createServer函数来创建一个dev server:

// vite/src/node/server/index.ts
export async function createServer(
  inlineConfig: InlineConfig = {}
 ): Promise {
  ...    
  if (!middlewareMode && httpServer) {
    let isOptimized = false    
    // 重写listen,确保server启动之前执行。
    const listen = httpServer.listen.bind(httpServer)
    httpServer.listen = (async (port: number, ...args: any[]) => {
      if (!isOptimized) {
        try {
          // 插件容器初始化
          await container.buildStart({})
          // 预构建
          await runOptimize()
          isOptimized = true
        } catch (e) {
          httpServer.emit('error', e)
          return
        }
      }
      return listen(port, ...args)
    }) as any
  } else {
  await container.buildStart({})
  // 执行预构建
  await runOptimize()
}
  ...
}

从这个函数我们可以看到,在dev server启动之前,vite会先调用 runOptimize 函数,来执行预构建。我们再看一下runOptimize函数:

// vite/src/node/server/index.ts
 // ...     
    const runOptimize = async () => {
     // 设置构建状态的标志位为true
      server._isRunningOptimizer = true
      try {
        // 依赖预构建
        server._optimizeDepsMetadata = await optimizeDeps(
          config,
          config.server.force || server._forceOptimizeOnRestart
        )
      } finally {
      // 设置构建状态的标志位为flase
        server._isRunningOptimizer = false
      }
      // 返回一个预构建函数可以随时进行预构建
      server._registerMissingImport = createMissingImporterRegisterFn(server)
    }
    

runOptimize 函数负责的是调用和注册处理依赖预编译相关的 optimizeDeps 函数。

预打包代码的实现逻辑就是在 optimizeDeps 这个方法中。同时 config.optimizeCacheDir 默认为node_modules/.vite,Vite 会将预构建的依赖缓存到这个文件夹下,判断是否需要使用到缓存的条件,我们后面随着代码深入讲到。

1、首先会去查找缓存目录(默认是 node_modules/.vite)下的 _metadata.json 文件;然后找到当前项目依赖信息(xxx-lock 文件)拼接上部分配置后做哈希编码(depHash),最后对比缓存目录下的 hash 值是否与编码后的 hash 值一致,一致并且没有开启 force 就直接返回预构建信息,结束整个流程;

// _metadata.json 文件
{
  "hash": "e9c95f13",
  "browserHash": "fc87bd4f",
  "optimized": {
    "lodash-es": {
      "src": "../../lodash-es/lodash.js",
      "file": "lodash-es.js",
      "fileHash": "087c7579",
      "needsInterop": false
    },
    "vue": {
      "src": "../../vue/dist/vue.runtime.esm-bundler.js",
      "file": "vue.js",
      "fileHash": "13723626",
      "needsInterop": false
    }
  },
  "chunks": {}
}
  • hash是根据需要预构建的文件内容生成的,实现一个缓存的效果,在启动dev server时可以避免重复构建相同的依赖;
  • browserHash是由hash和在运行时发现额外依赖时生成的,用于使预构建的依赖的浏览器请求无效;
  • optimized是一个包含所有需要预构建的依赖的对象,src表示依赖的源文件,file表示构建后所在的路径;
  • needsInterop表示是否对CommoJS的依赖引入代码进行重写。比如,当我们在vite项目中使用react时:import React, { useState } from 'react' , reac t的 needsInterop 为 true,所以 importAnalysisPlugin 插件的会对导入 react 的代码进行重写:
import $viteCjsImport1_react from "/@modules/react.js";
const React = $viteCjsImport1_react;
const useState = $viteCjsImport1_react["useState"];
const createContext = $viteCjsImport1_react["createContext"];

因为 CommonJS 的模块并不支持命名方式的导出,所以需要对其进行重写。如果不重写的话,则会看到以下报错:

Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'

2、如果开启了 force 或者项目依赖有变化的情况,先保证缓存目录干净(node_modules/.vite 下没有多余文件),在 node_modules/.vite/package.json 文件写入 type: module 配置。这就是为什么 vite 会将预构建产物视为 ESM 的原因。

3、分析入口,依次查看是否存在 optimizeDeps.entries、build.rollupOptions.input、*.html,匹配到就通过 dev-scan 的插件寻找需要预构建的依赖,输出 deps 和 missing,并重新做 hash 编码;

4、最后使用 es-module-lexer 对 deps 模块进行模块化分析,拿到分析结果做预构建。构建结果将合并内部模块、转换 CommonJS 依赖。最后更新 data.optimizeDeps 并将结果写入到缓存文件。

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第16张图片

全流程上我们已经清楚了,接下来我们就深入上述流程图中绿色方块(逻辑复杂)的代码。因为步骤之间的代码关联比较少,在分析下面逻辑时会截取片段代码。

调用optimizeDeps函数实现预构建,这个过程主要分为以下几步:

计算依赖 hash

  • 调用getDepHash函数获取此时依赖的hash值,并与上次构建信息的hash值做对比,判定是否需要重新于构建:
  const dataPath = path.join(cacheDir, '_metadata.json')
  //    生成此次构建的hash值
  const mainHash = getDepHash(root, config)
  const data: DepOptimizationMetadata = {
    hash: mainHash,
    browserHash: mainHash,
    optimized: {}
  }
  // vite配置中的force参数决定是否每次都重新构建  
  if (!force) {
    let prevData
    try {
      //    加载上次构建信息
      prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
    } catch (e) {}
     //        前后比对hash,如果相同则直接返回上一次的构建内容
    if (prevData && prevData.hash === data.hash) {
      return prevData
    }
  }
  • 如果前后hash值不相同,则表示没有命中缓存,vite会删除有缓存作用的cacheDir文件夹,如果是第一次依赖预构建,则创建一个新的cacheDir文件夹:
 if (fs.existsSync(cacheDir)) {
    emptyDir(cacheDir)
  } else {
    fs.mkdirSync(cacheDir, { recursive: true })
  }

整体函数代码大致如下:

export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.server.force,
  asCommand = false,
  newDeps?: Record, // missing imports encountered after server has started
  ssr?: boolean
): Promise {
  // ...
 // 缓存文件信息
  const dataPath = path.join(cacheDir, '_metadata.json')
  // 获取依赖的hash,这里的依赖是 lock 文件、以及 config 的部分信息
  const mainHash = getDepHash(root, config)
  // 定义预编译优化的元数据
  const data: DepOptimizationMetadata = {
    hash: mainHash,
    browserHash: mainHash,
    optimized: {}
  }
 
  // 用户的force参数决定是否每次都重新构建 
  // 不强制刷新
  if (!force) {
    let prevData: DepOptimizationMetadata | undefined
    try {
      // 读取 metadata 信息; 加载上次构建信息
      prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
    } catch (e) {}
    // hash is consistent, no need to re-bundle
    // 前后比对hash,相同则直接返回
    if (prevData && prevData.hash === data.hash) {
      log('Hash is consistent. Skipping. Use --force to override.')
      return prevData
    }
  }
  
  // 存在缓存目录,清空目录
  if (fs.existsSync(cacheDir)) {
    emptyDir(cacheDir)
  } else {
    // 创建多层级缓存目录
    fs.mkdirSync(cacheDir, { recursive: true })
  }
  // 缓存目录的模块被识别成 ESM
  writeFile(
    path.resolve(cacheDir, 'package.json'),
    JSON.stringify({ type: 'module' })
  )
  
  // ...
  
   
// 所有可能的依赖 lock 文件,分别对应 npm、yarn、pnpm 的包管理
const lockfileFormats = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']
 
/**
 * 获取依赖的 hash 值
 *
 * @param {string} root 根目录
 * @param {ResolvedConfig} config 服务配置信息
 * @return {*}  {string}
 */
function getDepHash(root: string, config: ResolvedConfig): string {
  // 获取 lock 文件的内容
  let content = lookupFile(root, lockfileFormats) || ''
  // 同时也将跟部分会影响依赖的 config 的配置一起加入到计算 hash 值
  content += JSON.stringify(
    {
      mode: config.mode,
      root: config.root,
      resolve: config.resolve,
      assetsInclude: config.assetsInclude,
      plugins: config.plugins.map((p) => p.name),
      optimizeDeps: {
        include: config.optimizeDeps?.include,
        exclude: config.optimizeDeps?.exclude
      }
    },
    (_, value) => {
      // 常见的坑:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
      if (typeof value === 'function' || value instanceof RegExp) {
        return value.toString()
      }
      return value
    }
  )
  // 通过 crypto 的 createHash 进行 hash 加密
  return createHash('sha256').update(content).digest('hex').substring(0, 8)
}

上述代码先去 cacheDir 目录下获取 _metadata.json 的信息,然后计算当前依赖的 hash 值,计算过程主要是通过 xxx-lock 文件,结合 config 中跟依赖相关的部分配置去计算 hash 值。最后判断如果服务没有开启 force (即刷新缓存的参数)时,去读取缓存元信息文件中的 hash 值,结果相同就直接返回缓存元信息文件即 _metadata.json 的内容;

否则就判断是否存在 cacheDir(默认情况下是 node_modules/.vite),存在就清空目录文件,不存在就创建缓存目录;最后在缓存目录下创建 package.json 文件并写入 type: module 信息,这就是为什么预构建后的依赖会被识别成 ESM 的原因。

在开启了 force 参数或者依赖前后的 hash 值不相同时,就会去扫描并分析依赖,这就进入下一个阶段。

依赖搜寻,智能分析:

vite是怎么解决路径问题的:自动依赖搜寻

如果没有找到现有的缓存,Vite 会扫描源代码,并自动寻找引入的依赖项(即 "bare import",表示期望从 node_modules 中解析),并将这些依赖项作为预构建的入口点。预打包使用 esbuild 执行,因此通常速度非常快。

在服务器已经启动后,如果遇到尚未在缓存中的新依赖项导入,则 Vite 将重新运行依赖项构建过程,并在需要时重新加载页面。

  • 在服务启动后,如果有新的依赖加入时,会被放在 newDeps 中。接着 vite 使用 esbuild 的 scanImports 函数来扫描源码,找出与预构建相关的依赖 deps 对象:
// newDeps参数是在服务启动后加入依赖时传入的依赖信息。
  let deps
  if (!newDeps) {
     //    使用esbuild扫描源码,获取依赖
    ;({ deps, missing } = await scanImports(config))
  } else {
    deps = newDeps
    missing = {}
  }
  • 加入 vite.config.js 配置中 optimizeDeps.include 相关依赖:
 const include = config.optimizeDeps?.include
  if (include) {
    //     ...加入用户指定的include
    const resolve = config.createResolver({ asSrc: false })
    for (const id of include) {
      if (!deps[id]) {
        const entry = await resolve(id)
        if (entry) {
          deps[id] = entry
        } else {
          throw new Error(
            `Failed to resolve force included dependency: ${chalk.cyan(id)}`
          )
        }
      }
    }
  }

整体函数代码大致如下:

// ... 接hash整体代码
 
 
 let deps: Record, missing: Record
  // newDeps参数是在服务启动后加入依赖时传入的依赖信息。
  // 没有新的依赖的情况,扫描并预构建全部的 import
  if (!newDeps) {
    // 借助esbuild扫描源码,获取依赖
    ;({ deps, missing } = await scanImports(config))
  } else {
    deps = newDeps
    missing = {}
  }
 
  // update browser hash
  data.browserHash = createHash('sha256')
    .update(data.hash + JSON.stringify(deps))
    .digest('hex')
    .substring(0, 8)
 
  // 遗漏的包
  const missingIds = Object.keys(missing)
  if (missingIds.length) {
    throw new Error(
      `The following dependencies are imported but could not be resolved:\n\n  ${missingIds
        .map(
          (id) =>
            `${colors.cyan(id)} ${colors.white(
              colors.dim(`(imported by ${missing[id]})`)
            )}`
        )
        .join(`\n  `)}\n\nAre they installed?`
    )
  }
 
  // 获取 optimizeDeps?.include 配置(加入用户指定的include)
  const include = config.optimizeDeps?.include
  if (include) {
    // 创建模块解析器
    const resolve = config.createResolver({ asSrc: false })
    for (const id of include) {
      // normalize 'foo   >bar` as 'foo > bar' to prevent same id being added
      // and for pretty printing
      const normalizedId = normalizeId(id)
      if (!deps[normalizedId]) {
        const entry = await resolve(id)
        if (entry) {
          deps[normalizedId] = entry
        } else {
          throw new Error(
            `Failed to resolve force included dependency: ${colors.cyan(id)}`
          )
        }
      }
    }
  }
 
  const qualifiedIds = Object.keys(deps)
 
  if (!qualifiedIds.length) {
    writeFile(dataPath, JSON.stringify(data, null, 2))
    log(`No dependencies to bundle. Skipping.\n\n\n`)
    return data
  }
 
  const total = qualifiedIds.length
  // pre-bundling 的列表最多展示 5 项
  const maxListed = 5
  // 列表数量
  const listed = Math.min(total, maxListed)
  // 剩余的数量
  const extra = Math.max(0, total - maxListed)
  // 预编译依赖的信息
  const depsString = colors.yellow(
    qualifiedIds.slice(0, listed).join(`\n  `) +
      (extra > 0 ? `\n  (...and ${extra} more)` : ``)
  )
  // CLI 下才需要打印
  if (!asCommand) {
    if (!newDeps) {
      // This is auto run on server start - let the user know that we are
      // pre-optimizing deps
      logger.info(colors.green(`Pre-bundling dependencies:\n  ${depsString}`))
      logger.info(
        `(this will be run only when your dependencies or config have changed)`
      )
    }
  } else {
    logger.info(colors.green(`Optimizing dependencies:\n  ${depsString}`))
  }
 
 // ...

上述代码很长,关键都在 scanImports 函数,这个涉及到 esbuild 插件和 API,我们待会拎出来分析。其他部分的代码我们通过一张流程图来讲解:

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第17张图片

1、开始通过 scanImports 找到全部入口并扫描全部的依赖做预构建;返回 deps 依赖列表、missings 丢失的依赖列表;

2、基于 deps 做 hash 编码,编码结果赋给 data.browserHash,这个结果就是浏览器发起这些资源的 hash 参数;

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第18张图片

3、对于使用了 node_modules 下没有定义的包,会发出错误信息,并终止服务;举个例子,引入  abcd 包:

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第19张图片

4、将 vite.config.ts 中的 optimizeDeps.include 数组中的值添加到 deps 中,也举个例子:

// vite.config.js
import { defineConfig } from 'vite'

import vue from '@vitejs/plugin-vue'
 
export default defineConfig({
  plugins: [vue()],
 
  base:'./',
  optimizeDeps: {
    include: [
      './src/lib/index.js'
    ]
  }
})
 
// ./lib/index.js 文件
import { sayHello } from './foo'
 
sayHello()
 
// ./lib/foo.js
export function sayHello () {
  console.log('hello vite prebundling')
}

上述代码我们将 ./lib/index.js 这个文件添加到预构建的 include 配置中,lib 下的两个文件内容也已经明确了。接下来执行 dev 后,我们就可以看到这个结果:

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第20张图片

我们的 lib/index.js 已经被添加到预构建列表: node_modules/.vite,有一个 ___src_lib_index__js.js  文件,并且已经被构建,还有 sourcemap 文件(___src_lib_index__js.js.map 文件),这就是 optimizeDeps.include 的作用:默认情况下,不在 node_modules 中的,链接的包不会被预构建。使用此选项可强制预构建链接的包。具体如何构建这个文件的我们在 导出分析 去梳理。

那预构建有啥作用呢?我们先来看看没有使用预构建的效果。

5、没预构建的是什么样的?

我们对 App.vue 文件做一个小修改,引入 lodash-es:

// 在组件中引入

在 vite.config.ts 中强制排除lodash-es的预构建:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
 
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  base:'./',
  optimizeDeps:{
    exclude:['lodash-es'] //在预构建中强制排除某依赖项
  }
})

然后我们启动vite来查看一下效果:.vite/deps,此时我们会发现目录下不会产生 lodash-es 预构建的产物了

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第21张图片

再打开浏览器,我们会发现,使用 loadsh 会产生655个请求,大部分是和 lodash-es 相关的请求,一共花了1.47s,再新项目没有其他多余包的情况下,这段时间还是蛮长的。为什么会有这么多请求呢?因为通常我们引入的一些依赖,它自己又会引用一些其他依赖,如果没有使用预构建的话,那么就会因为模块间依赖引用过多导致过多的请求次数。

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第22张图片

接着我们再把移除预构建的相关配置删掉,再次试试,会发现,在 node_modules 中 .vite/deps,目录下会产生 lodash-es 预构建的产物:

// 注释以下代码-----
optimizeDeps:{
  exclude:['lodash-es'] //在预构建中强制排除某依赖项
}
// -----

// 此时我们启动vite
npm run dev

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第23张图片

然后我们打开浏览器,可以看到这次和lodash-es相关的请求只有一个,并且所有请求所花时间只有502ms。

这就是预构建给冷启动带来的作用。

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第24张图片

手动实践例子 预构建解析

依赖扫描的入口

上述整个流程逻辑比较简单,就梳理一个主流程并实际展示了部分配置的作用。还有一个关键的环节我们略过了——scanImports。

/**
 * 扫描全部引入
 * @param {ResolvedConfig} config
 */
export async function scanImports(config: ResolvedConfig): Promise<{
  deps: Record
  missing: Record
}> {
  const start = performance.now()
 
  let entries: string[] = []
 
  // 预构建自定义条目
  const explicitEntryPatterns = config.optimizeDeps.entries
  // rollup 入口点
  const buildInput = config.build.rollupOptions?.input
 
  // 自定义条目优先级最高
  if (explicitEntryPatterns) {
    entries = await globEntries(explicitEntryPatterns, config)
  // 其次是 rollup 的 build 入口
  } else if (buildInput) {
    const resolvePath = (p: string) => path.resolve(config.root, p)
    // 字符串,转成数组
    if (typeof buildInput === 'string') {
      entries = [resolvePath(buildInput)]
    // 数组,遍历输出路径
    } else if (Array.isArray(buildInput)) {
      entries = buildInput.map(resolvePath)
    // 对象,返回对象的value数组
    } else if (isObject(buildInput)) {
      entries = Object.values(buildInput).map(resolvePath)
    } else {
      throw new Error('invalid rollupOptions.input value.')
    }
  // 默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项
  } else {
    entries = await globEntries('**/*.html', config)
  }
 
  // 合法的入口文件只能是存在的 js、html、vue、svelte、astro 文件
  entries = entries.filter(
    (entry) =>
      (JS_TYPES_RE.test(entry) || htmlTypesRE.test(entry)) &&
      fs.existsSync(entry)
  )
 
  // 找不到需要预构建的入口
  if (!entries.length) {
    if (!explicitEntryPatterns && !config.optimizeDeps.include) {
      config.logger.warn(
        colors.yellow(
          '(!) Could not auto-determine entry point from rollupOptions or html files ' +
            'and there are no explicit optimizeDeps.include patterns. ' +
            'Skipping dependency pre-bundling.'
        )
      )
    }
    return { deps: {}, missing: {} }
  } else {
    debug(`Crawling dependencies using entries:\n  ${entries.join('\n  ')}`)
  }
 
  // 扫描到的依赖,会放到该对象
  const deps: Record = {}
  // 缺失的依赖,用于错误提示
  const missing: Record = {}
  // 创建插件容器,为什么这里需要单独创建一个插件容器?而不是使用 createServer 时创建的那个
  const container = await createPluginContainer(config)
  // 创建 esbuild 扫描的插件 重点!!!
  // 它定义了各类模块(节点)的处理方式。
  const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
  // 外部传入的 esbuild 配置,获取用户配置的 esbuild 自定义配置,没有配置就是空的
  const { plugins = [], ...esbuildOptions } =
    config.optimizeDeps?.esbuildOptions ?? {}
 
  // 遍历所有入口全部进行预构建
  await Promise.all(
    // 入口可能不止一个,分别用 esbuid 打包
    entries.map((entry) =>
      build({
        absWorkingDir: process.cwd(),
        write: false,
        entryPoints: [entry],
        bundle: true,
        format: 'esm',
        logLevel: 'error',
        plugins: [...plugins, plugin], // 使用插件
        ...esbuildOptions
      })
    )
  )
 
  debug(`Scan completed in ${(performance.now() - start).toFixed(2)}ms:`, deps)
 
  return {
    deps,
    missing
  }
}

扫描入口先从 optimizeDeps.entries 获取;如果没有就去获取 build.rollupOptions.input 配置,处理了 input 的字符串、数组、对象形式;如果都没有,就默认寻找 html 文件。然后传入 deps、missing 调用 esbuildScanPlugin 函数生成扫描插件,并从 optimizeDeps.esbuildOptions 获取外部定义的 esbuild 配置,最后调用 esbuild.build API 进行构建。整个流程汇总成一张图如下:

(五)什么是Vite——冷启动时vite做了什么(依赖、预构建)_第25张图片

esbuildScanPlugin:

重点来了,使用 vite:dep-scan 插件扫描依赖,并将在 node_modules 中的依赖定义在 deps 对象中,缺失的依赖定义在 missing 中。接着我们就进入该插件内部,一起学习 esbuild 插件机制:

// 匹配 html 
const scriptModuleRE = /(]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
// 正则,匹配例子: 
export const scriptRE = /(]*>|>))(.*?)<\/script>/gims

build.onLoad(
  { filter: htmlTypesRE, namespace: 'html' },
  async ({ path }) => {
    console.log('html type load  --------------->', path)
    // 读取源码
    let raw = fs.readFileSync(path, 'utf-8')
    // Avoid matching the content of the comment
    // 注释文字全部删掉,避免后面匹配到注释
    raw = raw.replace(commentRE, '')
    // 是不是 html 文件,也可能是 vue、svelte 等
    const isHtml = path.endsWith('.html')
    // 是 html 文件就用 script module 正则,否则就用 vue 中 script 的匹配规则,具体见正则表达式
    // scriptModuleRE: 
    // scriptRE: 
    // html 模块,需要匹配 module 类型的 script,因为只有 module 类型的 script 才能使用 import
    const regex = isHtml ? scriptModuleRE : scriptRE
    // 注意点:对于带 g 的正则匹配,如果像匹配多个结果需要重置匹配索引
    // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/RegExp/lastIndex
    // 重置正则表达式的索引位置,因为同一个正则表达式对象,每次匹配后,lastIndex 都会改变
    // regex 会被重复使用,每次都需要重置为 0,代表从第 0 个字符开始正则匹配
    regex.lastIndex = 0
    // load 钩子返回值,表示加载后的 js 代码
    let js = ''
    let loader: Loader = 'js'
    let match: RegExpExecArray | null
    
    // 匹配源码的 script 标签,用 while 循环,因为 html 可能有多个 script 标签
    while ((match = regex.exec(raw))) {
      // openTag 开始标签的内容(包含属性):它的值的例子: 
                    
                    

你可能感兴趣的:(vite,前端,javascript,vue,vite)