前段时间,Vite 作了一个优化依赖预编译(Dependency Pre-Bundling)。简而言之,它指的是 Vite 会在 DevServer 启动前对须要预编译的依赖进行编译,而后在分析模块的导入(import)时会动态地应用编译过的依赖。javascript
这么一说,我想你们可能立马会抛出一个疑问:Vite 不是 No Bundle 吗?确实 Vite 是 No Bundle,可是依赖预编译并非意味着 Vite 要走向 Bundle,咱们不要急着下定义,由于它的存在必然是有着其实际的价值。前端
那么,今天本文将会围绕如下 3 点来和你们一块儿从疑问点出发,深刻浅出一番 Vite 的依赖预编译过程:vue
当你在项目中引用了 vue
和 lodash-es
,那么你在启动 Vite 的时候,你会在终端看到这样的输出内容:java
而这表示 Vite 将你在项目中引入的 vue
和 lodash-es
进行了依赖预编译!这里,咱们经过大白话认识一下 Vite 的依赖预编译:node
dependencies
的部分启用依赖预编译,即会先对该依赖进行编译,而后将编译后的文件缓存在内存中(node_modules/.vite 文件下),在启动 DevServer 时直接请求该缓存内容。optimizeDeps
选项能够选择须要或不须要进行预编译的依赖的名称,Vite 则会根据该选项来肯定是否对该依赖进行预编译。--force
options,能够用来强制从新进行依赖预编译。须要注意,强制从新依赖预编译指的是忽略以前已编译的文件,直接从新编译。
因此,回到文章开始所说的疑问,这里咱们能够这样理解依赖预编译,它的出现是一种优化,即没有它其实 No Bundle 也能够,有它更好(xiang)! 并且,依赖预编译并不是无米之炊,Vite 也是受 Snowpack 的启发才提出的。react
那么,下面咱们就来了解一下依赖预编译的做用是什么,即优化的意义~git
对于依赖预编译的做用,Vite 官方也作了详细的介绍。那么,这里咱们经过结合图例的方式来认识一下,具体会是两点:github
1. 兼容 CommonJS 和 AMD 模块的依赖面试
由于 Vite 的 DevServer 是基于浏览器的 Natvie ES Module 实现的,因此对于使用的依赖若是是 CommonJS 或 AMD 的模块,则须要进行模块类型的转化(ES Module)。json
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 开启以前进行。这里,咱们仍然以在项目中引入了 vue
和 lodash-es
依赖为例。
须要注意的是如下和源码相关的函数都是取的 核心逻辑讲解(伪代码)。
首先,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 启动时重复编译相同的依赖,即依赖并无发生变化,不须要从新编译。browserHash
由 hash
和在运行时发现的额外的依赖生成的,用于让预编译的依赖的浏览器请求无效。optimized
包含每一个进行过预编译的依赖,其对应的属性会描述依赖源文件路径 src
和编译后所在路径 file
。needsInterop
主要用于在 Vite 进行依赖性导入分析,这是由 importAnalysisPlugin
插件中的 transformCjsImport
函数负责的,它会对须要预编译且为 CommonJS 的依赖导入代码进行重写。举个例子,当咱们在 Vite 项目中使用 react
时:import React, { useState, createContext } from 'react'
此时 react
它是属于 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'
有兴趣继续往这方面了解的同窗能够查看这个 PR https://github.com/vitejs/vit...,这里就不作过于详细的介绍了~
2. 注册依赖预编译相关函数
调用 createMissingImpoterRegisterFn
函数,它会返回一个函数,其仍然内部会调用 optimizeDeps
函数进行预编译,只是不一样于第一次预编译过程,此时会传人一个 newDeps
,即新的须要进行预编译的依赖。
那么,显然不管是第一次预编译,仍是后续的预编译,它们二者的实现都是调用的 optimizeDeps
函数。因此,下面咱们来看一下 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
函数来找出和预编译相关的依赖 deps
,deps
会是一个对象:
{ 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
,它的本质是建立一个 esbuild
的 service
,使用 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 三、Vite)、前端工程化、跨端等技术学习和分享,欢迎关注个人 微信公众号:Code center。