乐趣区

关于源码分析:Vite-依赖预编译缩短数倍的冷启动时间

前言

前段时间,Vite 做了一个优化 依赖预编译 (Dependency Pre-Bundling)。简而言之,它指的是 Vite 会 在 DevServer 启动前 对须要预编译的依赖进行编译,而后在剖析模块的导入(import)时会动静地 利用编译过的依赖

这么一说,我想大家可能立马会抛出一个疑难:Vite 不是 No Bundle 吗?的确 Vite 是 No Bundle,然而依赖预编译并不是意味着 Vite 要走向 Bundle,咱们不要急着下定义,因为它的存在必然是有着其 理论的价值

那么,明天本文将会围绕以下 3 点来和大家一起从疑难点登程,深入浅出 一番 Vite 的依赖预编译过程:

  • 什么是依赖预编译
  • 依赖预编译的作用
  • 依赖预编译的实现(源码剖析)

一、什么是依赖预编译

当你在我的项目中援用了 vuelodash-es,那么你在启动 Vite 的时候,你会在终端看到这样的输入内容:

而这示意 Vite 将你在我的项目中引入的 vuelodash-es 进行了依赖预编译!这里,咱们通过大白话认识一下 Vite 的依赖预编译:

  • 默认状况下,Vite 会将 package.json 中生产依赖 dependencies 的局部启用依赖预编译,即会先对该依赖进行编译,而后将编译后的文件缓存在内存中(node_modules/.vite 文件下),在启动 DevServer 时间接申请该缓存内容。
  • 在 vite.config.js 文件中配置 optimizeDeps 选项能够抉择 须要或不须要 进行预编译的依赖的名称,Vite 则会依据该选项来确定是否对该依赖进行预编译。
  • 在启动时增加 --force options,能够用来 强制从新 进行依赖预编译。

须要留神,强制从新依赖预编译指的是疏忽之前已编译的文件,间接从新编译。

所以,回到文章开始所说的疑难,这里咱们能够这样了解依赖预编译,它的呈现是一种优化,即没有它其实 No Bundle 也能够,有它更好(xiang)! 而且,依赖预编译并非无米之炊,Vite 也是受 Snowpack 的启发才提出的。

那么,上面咱们就来理解一下依赖预编译的作用是什么,即优化的意义~

二、依赖预编译的作用

对于依赖预编译的作用,Vite 官网也做了具体的介绍。那么,这里咱们通过联合图例的形式来认识一下,具体会是两点:

1. 兼容 CommonJS 和 AMD 模块的依赖

因为 Vite 的 DevServer 是基于浏览器的 Natvie ES Module 实现的,所以对于应用的依赖如果是 CommonJS 或 AMD 的模块,则须要进行模块类型的转化(ES Module)。

2. 缩小模块间依赖援用导致过多的申请次数

通常咱们引入的一些依赖,它 本人又会一些其余依赖。官网文档中举了一个很经典的例子,当咱们在我的项目中应用 lodash-es 的时候:

import {debounce} from "lodash-es"

如果在没用依赖预编译的状况下,咱们关上页面的 Dev Tool 的 Network 面板:

能够看到此时大略有 600+ 和 lodash-es 相干的申请,并且所有申请加载花了 1.11 s,仿佛还好?当初,咱们来看一下应用依赖预编译的状况:

此时,只有 1 个和 lodash-es 相干的申请(通过预编译),并且所有申请加载才花了 142 ms,缩短了足足 7 倍多的工夫! 而这里节俭的工夫,就是咱们常说的 冷启动 工夫。

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

三、依赖预编译的实现

在 Vite 源码中,默认的依赖预编译过程会在 DevServer 开启之前进行。这里,咱们依然以在我的项目中引入了 vuelodash-es 依赖为例。

须要留神的是以下和源码相干的函数都是取的 外围逻辑 解说(伪代码)。

3.1 Dev Server 启动前

首先,Vite 会创立一个 DevServer,也就是咱们平时应用的本地开发服务器,这个过程是由 createServer 函数实现:

// packages/vite/src/node/server/index.ts
async function createServer(inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
  ...
  // 通常状况下咱们会命中这个逻辑
  if (!middlewareMode && httpServer) {
    // 重写 DevServer 的 listen,保障在 DevServer 启动前进行依赖预编译
    const listen = httpServer.listen.bind(httpServer)
    httpServer.listen = (async (port: number, ...args: any[]) => {
      try {
        ...
        // 依赖预编译相干
        await runOptimize()} 
      ...
    }) as any
    ...
  } else {await runOptimize()
  }
  ...
}

能够看到在 DevServer 真正启动之前,它会先调用 runOptimize 函数,进行依赖预编译相干的解决(用 bind 进行简略的重写)。

runOptimize 函数:

// packages/vite/src/node/server/index.ts
const runOptimize = async () => {
  // config.optimzizeCacheDir 指的是 node_modules/.vite
  if (config.optimizeCacheDir) {
    ..
    try {server._optimizeDepsMetadata = await optimizeDeps(config)
    }
    ..
    server._registerMissingImport = createMissingImpoterRegisterFn(server)
  }
}

runOptimize 函数负责的是调用和注册解决依赖预编译相干的 optimizeDeps 函数,具体来说会是两件事:

1. 进行依赖预编译

optimizeDeps 函数是 Vite 实现依赖预编译的 外围函数 ,它会依据配置 vite.config.js 的 optimizeDeps 选项和 package.json 的 dependencies 的参数进行 第一次预编译。它会返回解析 node_moduels/.vite/_metadata.json 文件后生成的对象(蕴含预编译后的依赖所在的文件地位、原文件所处的文件地位等)。

_metadata.json 文件:

{
  "hash": "bade5e5e",
  "browserHash": "830194d7",
  "optimized": {
    "vue": {
      "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/vue.js",
      "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "needsInterop": false
    },
    "lodash-es": {
      "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/lodash-es.js",
      "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js",
      "needsInterop": false
    }
  }
}

这里,咱们来别离认识一下这 4 个属性的含意:

  • hash 由须要进行预编译的文件内容生成的,用于避免 DevServer 启动时反复编译雷同的依赖,即依赖并没有发生变化,不须要从新编译。
  • browserHashhash 和在运行时发现的额定的依赖生成的,用于让预编译的依赖的浏览器申请有效。
  • optimized 蕴含每个进行过预编译的依赖,其对应的属性会形容依赖源文件门路 src 和编译后所在门路 file
  • needsInterop 次要用于在 Vite 进行依赖性导入剖析,这是由 importAnalysisPlugin 插件中的 transformCjsImport 函数负责的,它会对须要预编译且为 CommonJS 的依赖导入代码 进行重写。举个例子,当咱们在 Vite 我的项目中应用 react 时:
import React, {useState, createContext} from 'react'

此时 react 它是属于 needsInteroptrue 的领域,所以 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'

有趣味持续往这方面理解的同学能够查看这个 PR https://github.com/vitejs/vit…,这里就不做过于具体的介绍了~

2. 注册依赖预编译相干函数

调用 createMissingImpoterRegisterFn 函数,它会返回一个函数,其依然外部会调用 optimizeDeps 函数进行预编译,只是 不同于第一次预编译过程 ,此时会传人一个 newDeps,即 新的 须要进行预编译的依赖。

那么,显然无论是第一次预编译,还是后续的预编译,它们两者的实现都是调用的 optimizeDeps 函数。所以,上面咱们来看一下 optimizeDeps 函数~

3.2 预编译实现外围 optimizeDeps 函数

optimizeDeps 函数被定义在 packages/vite/node/optimizer/index.ts 中,它负责对依赖进行预编译过程:

// packages/vite/node/optimizer/index.ts
export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.server.force,
  asCommand = false,
  newDeps?: Record<string, string>
): Promise<DepOptimizationMetadata | null> {...}

因为 optimizeDeps 外部逻辑较为繁多,这里咱们拆分为 5 个步骤解说:

1. 读取该依赖此时的文件信息

既然是编译依赖,很显然的是每次编译都须要晓得此时文件内容对应的 Hash 值,以便于依赖 发生变化 时能够 从新进行依赖编译,从而利用最新的依赖内容。

所以,这里会先调用 getDepHash 函数获取依赖的 Hash 值:

// 获取该文件此时的 hash
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
  hash: mainHash,
  browserHash: mainHash,
  optimized: {}}

而对于 data 中的这三个属性,咱们在下面曾经介绍过了,这里就不反复阐述了~

2. 比照缓存文件的 Hash

后面,咱们也提及了如果启动 Vite 时应用了 --force Option,则会强制从新进行依赖预编译。所以,当不是 --force 场景时,则会进行比拟新旧依赖的 Hash 值的过程:

// 默认为 false
if (!force) {
  let prevData
  try {
    // 获取到此时缓存(本地磁盘)中编译的文件信息
    prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
  } catch (e) {}
  // 比照此时的 
  if (prevData && prevData.hash === data.hash) {log('Hash is consistent. Skipping. Use --force to override.')
    return prevData
  }
}

能够看到如果新旧依赖的 Hash 值相等的时候,则会间接返回旧的依赖内容。

3. 缓存生效或未缓存

如果下面的 Hash 不等,则示意缓存生效,所以会删除 cacheDir 文件夹,又或者此时未进行缓存,即第一次依赖预编译逻辑(cacheDir 文件夹不存在),则创立 cacheDir 文件夹:

if (fs.existsSync(cacheDir)) {emptyDir(cacheDir)
  } else {fs.mkdirSync(cacheDir, { recursive: true})
  }

须要留神的是,这里的 cacheDir 则指的是 node_modules/.vite 文件夹

后面在讲 DevServer 启动时,咱们提及预编译过程会分为两种:第一次预编译和后续的预编译。两者的区别在于后者会传入一个 newDeps,它示意新的须要进行预编译的依赖:

let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {;({ deps, missing} = await scanImports(config))
} else {
  // 存在 newDeps 的时候,间接将 newDeps 赋值给 deps
  deps = newDeps
  missing = {}}

并且,这里能够看到对于前者,第一次预编译,则会调用 scanImports 函数来找出和预编译相干的依赖 depsdeps 会是一个对象:

{
  lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js'
  vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}

missing 则示意在 node_modules 中没找到的依赖。所以,当 missing 存在时,你会看到这样的提醒:

scanImports 函数外部则是调用的一个名为 dep-scan 的外部插件(Plugin)。这里就不解说 dep-scan 插件的具体实现了,有趣味的同学能够自行理解哈~

那么,回到下面对于后者(newDeps 存在时)的逻辑则较为简单,会间接给 deps 赋值为 newDeps,并且不须要解决 missing。因为,newDeps 只有在后续导入并装置了新的 dependencies 依赖,才会传入的,此时是不存在 missing 的依赖的(Vite 内置的 importAnalysisPlugin 插件会提前过滤掉这些)。

4. 解决 optimizeDeps.include 相干依赖

在后面,咱们也提及了须要进行编译的依赖也会由 vite.config.js 的 optimizeDeps 选项决定。所以,在解决完 dependencies 之后,接着须要解决 optimizeDeps

此时,会遍历后面从 dependencies 获取到的 deps,判断 optimizeDeps.iclude(数组)所指定的依赖是否存在,不存在则会抛出异样:

const include = config.optimizeDeps?.include
  if (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)}`
          )
        }
      }
    }
  }

5. 应用 esbuild 编译依赖

那么,在做好上述和预编译依赖相干的解决(文件 hash 生成、预编译依赖确定等)后。则进入依赖预编译的最初一步,应用 esbuild 来对相应的依赖进行编译:

  ...
  const esbuildService = await ensureService()
  await esbuildService.build({entryPoints: Object.keys(flatIdDeps),
    bundle: true,
    format: 'esm',
    ...
  })
  ...

ensureService 函数是 Vite 外部封装的 util,它的实质是创立一个 esbuildservice,应用 service.build 函数来实现编译过程。

此时,传入的 flatIdDeps 参数是一个对象,它是由下面提及的 deps 收集好的依赖创立的,它的作用是为 esbuild 进行编译的时候提供多路口(entry),flatIdDeps 对象:

{
  lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js'
  moment:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/moment/dist/moment.js'
  vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}

好了,到此咱们曾经剖析完了整个依赖预编译的实现 ????(手动给看到这的大家????)。

那么,接下来在 DevServer 启动后,当模块须要申请 通过预编译的依赖 的时候,Vite 外部的 resolvePlugin 插件会解析该依赖是否存在 seen 中(seen 中会存储编译过的依赖映射),是则间接利用 node_modules/.vite 目录下对应的编译后的依赖,防止间接去申请编译前的依赖的状况呈现,从而缩短冷启动的工夫。

结语

通过理解 Vite 依赖预编译的作用、实现等相干常识,我想大家应该不会再去纠结 Bundle 或者 No Bundle 的问题了,依然是那句话,存在即有价值。并且,依赖预编译这个知识点在面试场景下,可能也是一个很乏味的考题 ????。最初,如果文章中存在表白不当或谬误的中央,欢送大家提 Issue~

点赞 ????

通过浏览本篇文章,如果有播种的话,能够 点个赞,这将会成为我继续分享的能源,感激~

我是五柳,喜爱翻新、捣鼓源码,专一于源码(Vue 3、Vite)、前端工程化、跨端等技术学习和分享,欢送关注我的 微信公众号:Code center

退出移动版