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 执行时所做的“依赖预构建”。这个过程有两个目的:
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 进行协商缓存。
而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求。
文件被设置为缓存一年过期。那么,下次再访问该文件的时候会直接走浏览器缓存,并不会再经过 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的判断
- 实际路径在 node_modules 的模块会被预构建,这是第三方模块。
- 实际路径不在 node_modules 的模块,证明该模块是通过文件链接,链接到 node_modules 内的(monorepo 的实现方式),是开发者自己写的代码,不执行预构建。
-
- 如下[‘vue’,‘axios’]会被判断为裸模块
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 命令行选项重新启动开发服务器,以使更改生效。
重复删除:
由于链接的依赖项解析方式不同,传递依赖项(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 会根据应用入口(entries)自动搜集依赖,然后进行预构建,这是不是说明 Vite 可以百分百准确地搜集到所有的依赖呢?事实上并不是,某些情况下 Vite 默认的扫描行为并不完全可靠,这就需要联合配置include来达到完美的预构建效果了。接下来,我们好好梳理一下到底有哪些需要配置include的场景。
inclued 使用场景——动态 import:
在这个例子中,动态 import 的路径只有运行时才能确定,无法在预构建阶段被扫描出来。因此,Vite 运行时发现了新的依赖,随之重新进行依赖预构建,并刷新页面。这个过程也叫二次预构建。在一些比较复杂的项目中,这个过程会执行很多次。
然而,二次预构建的成本也比较大。我们不仅需要把预构建的流程重新运行一遍,还得重新刷新页面,并且需要重新请求所有的模块。尤其是在大型项目中,这个过程会严重拖慢应用的加载速度!因此,我们要尽力避免运行时的二次预构建。我们可以通过 include 参数提前声明需要按需加载的依赖。
exclude 是optimizeDeps中的另一个配置项,与include相对,用于将某些依赖从预构建的过程中排除。不过这个配置并不常用,也不推荐大家使用。如果真遇到了要在预构建中排除某个包的情况,需要注意它所依赖的包是否具有 ESM 格式,如下面这个例子:
手动排除了预构建的包,@ loadable/component本身具有ESM格式的产物,但他的某个依赖hoist-non-react-statics的产物并没有提供ESM格式,导致运行时加载错误。
这个时候include配置就派上用场了,我们可以强制对hoist-non-react-statics这个间接依赖进行预构建:
在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 在每次预构建之后都将一些关键信息写入到了_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
在开启dev server之前进行依赖预构建。首先,vite会执行一个createServer函数来创建一个dev server:
// vite/src/node/server/index.ts
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
...
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 并将结果写入到缓存文件。
全流程上我们已经清楚了,接下来我们就深入上述流程图中绿色方块(逻辑复杂)的代码。因为步骤之间的代码关联比较少,在分析下面逻辑时会截取片段代码。
调用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<string, string>, // missing imports encountered after server has started
ssr?: boolean
): Promise<DepOptimizationMetadata | null> {
// ...
// 缓存文件信息
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<string, string>, missing: Record<string, string>
// 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,我们待会拎出来分析。其他部分的代码我们通过一张流程图来讲解:
1、开始通过 scanImports 找到全部入口并扫描全部的依赖做预构建;返回 deps 依赖列表、missings 丢失的依赖列表;
2、基于 deps 做 hash 编码,编码结果赋给 data.browserHash,这个结果就是浏览器发起这些资源的 hash 参数;
3、对于使用了 node_modules 下没有定义的包,会发出错误信息,并终止服务;举个例子,引入 abcd 包:
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 后,我们就可以看到这个结果:
我们的 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:
// 在组件中引入
<script setup>
import lodash from 'lodash-es'
lodash.get({a:3},'a')
</script>
在 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 预构建的产物了
再打开浏览器,我们会发现,使用 loadsh 会产生655个请求,大部分是和 lodash-es 相关的请求,一共花了1.47s,再新项目没有其他多余包的情况下,这段时间还是蛮长的。为什么会有这么多请求呢?因为通常我们引入的一些依赖,它自己又会引用一些其他依赖,如果没有使用预构建的话,那么就会因为模块间依赖引用过多导致过多的请求次数。
接着我们再把移除预构建的相关配置删掉,再次试试,会发现,在 node_modules 中 .vite/deps,目录下会产生 lodash-es 预构建的产物:
// 注释以下代码-----
optimizeDeps:{
exclude:['lodash-es'] //在预构建中强制排除某依赖项
}
// -----
// 此时我们启动vite
npm run dev
然后我们打开浏览器,可以看到这次和lodash-es相关的请求只有一个,并且所有请求所花时间只有502ms。
这就是预构建给冷启动带来的作用。
手动实践例子 预构建解析
依赖扫描的入口
上述整个流程逻辑比较简单,就梳理一个主流程并实际展示了部分配置的作用。还有一个关键的环节我们略过了——scanImports。
/**
* 扫描全部引入
* @param {ResolvedConfig} config
*/
export async function scanImports(config: ResolvedConfig): Promise<{
deps: Record<string, string>
missing: Record<string, string>
}> {
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<string, string> = {}
// 缺失的依赖,用于错误提示
const missing: Record<string, string> = {}
// 创建插件容器,为什么这里需要单独创建一个插件容器?而不是使用 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 进行构建。整个流程汇总成一张图如下:
esbuildScanPlugin:
重点来了,使用 vite:dep-scan 插件扫描依赖,并将在 node_modules 中的依赖定义在 deps 对象中,缺失的依赖定义在 missing 中。接着我们就进入该插件内部,一起学习 esbuild 插件机制:
// 匹配 html <script type="module"> 的形式
const scriptModuleRE =
/(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
// 匹配 vue <script> 的形式
export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)<\/script>/gims
// 匹配 html 中的注释
export const commentRE = /<!--(.|[\r\n])*?-->/
// 匹配 src 的内容
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im
// 匹配 type 的内容
const typeRE = /\btype\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im
// 匹配 lang 的内容
const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im
// 匹配 context 的内容
const contextRE = /\bcontext\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im
/**
* esbuid 扫描依赖插件
*
* @param {ResolvedConfig} config 配置信息:Vite 的解析好的用户配置
* @param {PluginContainer} container 插件容器:这里只会用到 container.resolveId 的方法,这个方法能将模块路径转成真实路径。例如 vue 转成 xxx/node_modules/dist/vue.esm-bundler.js。
* @param {Record<string, string>} depImports 预构建的依赖:用于存储扫描到的依赖对象,插件执行过程中会被修改
* @param {Record<string, string>} missing 缺失的依赖:用于存储缺少的依赖的对象,插件执行过程中会被修改
* @param {string[]} entries optimizeDeps.entries 的数据:存储所有入口文件的数组
* @return {*} {Plugin}
*/
function esbuildScanPlugin(
config: ResolvedConfig,
container: PluginContainer,
depImports: Record<string, string>,
missing: Record<string, string>,
entries: string[]
): Plugin {
const seen = new Map<string, string | undefined>()
// 这个方法能将模块路径转成真实路径,并进行缓存
const resolve = async (id: string, importer?: string) => {
const key = id + (importer && path.dirname(importer))
// 如果有缓存,就直接使用缓存
if (seen.has(key)) {
return seen.get(key)
}
// 使用 vite 插件容的解析能力,获取具体的依赖信息
// 将模块路径转成真实路径
const resolved = await container.resolveId(
id,
importer && normalizePath(importer)
)
// 缓存解析过的路径,之后可以直接获取
const res = resolved?.id
seen.set(key, res)
return res
}
// 获取 optimizeDeps.include 配置
const include = config.optimizeDeps?.include
// 排除预构建到包内的文件
const exclude = [
...(config.optimizeDeps?.exclude || []),
'@vite/client',
'@vite/env'
]
// 将 external 设置为 true 以将模块标记为外部,这意味着它不会包含在包中,而是会在运行时导入
const externalUnlessEntry = ({ path }: { path: string }) => ({
path,
external: !entries.includes(path)
})
// 返回 esbuild 的插件
return {
name: 'vite:dep-scan',
setup(build) {
console.log('build options ------->', build.initialOptions)
// ...省略大量 onResolve 和 onLoad 的回调
}
}
}
上述代码先定义了一堆正则表达式,具体的匹配内容已经在注释中声明。可以看到,扫描依赖核心就是对依赖进行正则匹配(esbuild 的 onResolve),然后对于语法不支持的文件做处理(esbuild onLoad)。接下来我们就从 DEMO 入手,来完整地执行一遍 esbuild 的构建流程。
模块扫描
先看一下项目中模块的依赖关系: 深度解读 Vite 的依赖扫描
从入口的 HTML 文件开始,根据模块的 import 依赖关系,可以连接成一颗模块依赖树。要扫描出所有的 bare import,就需要遍历整个依赖树,这就涉及到了树的深度遍历。我们只需要深度遍历所有树节点,找出所有 import 语句,把 import 的模块记录下来即可。思路虽然很简单,但真正实现起来,会有几个比较困难的问题:
JS 文件中,如何找到 import 语句?
这个可以用正则表达式匹配,也可以先将代码解析成 AST 抽象语法树,然后找到 Import 节点。后者更准确。
找到 import 语句后:
- 如果 import 的模块是第三方依赖,则记录下来。如: vue
- 如果开发者自己写的项目模块,则继续递归处理该模块。如:Main.vue,这时候应该继续处理 Main.vue
下面是一个例子:
import { createApp, defineCustomElement } from 'vue'
import Main from './Main.vue'
import CustomElement from './CustomElement.ce.vue'
- vue 会被记录
- ./Main.vue 和 ./CustomElement.ce.vue 将会被继续深入地处理
HTML 文件如何处理?
因为 HTML 文件内,可能存在 script 标签,这部分的代码,就可能包含 import 语句。且项目本身就是把 HTML 文件当成入口的。因此必须得处理 HTML。
由于不关心 HTML 中其他的部分,我们只需要先把 script 标签的内容提取出来,然后再按 JS 的处理方式处理即可。Vue 文件,也是类似的处理方式。
CSS、PNG 等非 JS 模块如何处理?
这些文件不需要任何处理,直接跳过即可,因为这些文件不可能再引入 JS 模块
以上这几个难题,如果全部都要自己实现,是相当困难的,因此 Vite 巧妙的借助了打包工具进行处理,可以使用打包工具处理的原因如下:
- 如何找到 import 语句模块分析/打包流程与我们深度遍历模块树的过程完全相同在模块处理过程中,将第三方依赖记录下来
- HTML 文件的处理: 打包工具能对每个模块进行处理,在模块加载时,可以把模块处理成生成新的内容。Vue 文件的 template,就是在模块加载时,转换成 JS 的 render 函数。 不过这里我们就不是生成 render 函数了,而是把 HTML、Vue 等文件,直接加载成 JS,即只保留它们 script 的部分,其他部分丢弃(依赖扫描不关心非 JS 的内容)
- CSS、PNG 等非 JS 模块的处理: 打包工具支持将模块标记为 external,就是不打包该模块了。标记之后,打包工具就不会深入分析该模块内部的依赖。 对于 CSS、PNG 这种不需要深入分析的模块,直接 external 即可。
树的深度遍历
要扫描出所有的 bare import,就需要遍历整个依赖树,这就涉及到了树的深度遍历
当我们在讨论树的遍历时,一般会关注这两点:
- 什么时候停止深入?
- 如何处理叶子节点?
当前叶子节点不需要继续深入遍历的情况:
- 当遇到 bare import 节点时,记录下该依赖,就不需要继续深入遍历
- 遇到其他 JS 无关的模块,如 CSS、SVG 等,因为不是 JS 代码,因此也不需要继续深入遍历
当所有的叶子节点遍历完成后,记录的 bare import 对象,就是依赖扫描的结果。
依赖扫描的实现思路其实非常容易理解,但实际的处理就不简单了。
我们来看看叶子节点的处理:
- bare import
可以通过模块 id 判断,模块 id 不为路径的模块,就是 bare import。遇到这些模块则记录依赖,不再深入遍历。
- 其他 JS 无关的模块
可以通过模块的后缀名判断,例如遇到 *.css 的模块,无需任何处理,不再深入遍历。
- JS 模块
要获取 JS 代码中依赖的子模块,就需要将代码转成 AST,获取其中 import 语句引入的模块,或者正则匹配出所有 import 的模块,然后继续深入遍历这些模块
- HTML 类型模块
这类模块比较复杂,例如 HTML 或 Vue,里面有一部分是 JS,需要把这部分 JS 代码提取出来,然后按 JS 模块进行分析处理,继续深入遍历这些模块。这里只需要关心 JS 部分,其他部分不会引入模块。
具体实现-用 esbuild 工具打包
我们已经知道了依赖扫描的实现思路,思路其实不复杂,复杂的是处理过程,尤其是 HTML、Vue 等模块的处理。
Vite 这里用了一种比较巧妙的办法 —— 用 esbuild 工具打包
为什么可以用 esbuild 打包代替深度遍历的过程?
本质上打包过程也是个深度遍历模块的过程,其替代的方式如下:
各类模块的处理:
例子 | 处理 | |
bare import | vue | 在解析过程中, 将裸依赖保存到 deps 对象中,设置为 external |
其他 JS 无关的模块 | less文件 | 在解析过程中, 设置为 external |
JS 模块 | ./mian.ts | 正常解析和加载即可,esbuild 本身能处理 JS |
html 类型模块 | index.html、 app.vue | 在加载过程中,将这些模块 加载成 JS |
js模块
要获取 JS 代码中依赖的子模块,就需要将代码转成 AST,获取其中 import 语句引入的模块,或者正则匹配出所有 import 的模块,然后继续深入遍历这些模块。
- esbuild 能够分析出 js 文件中的依赖,并进一步深入处理这些依赖。
非js模块
可以通过模块的后缀名判断,例如遇到 *.css 的模块,无需任何处理,不再深入遍历。
// external urls
build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({
path,
external: true
}))
// external css 等文件
build.onResolve(
{
filter: /\.(css|less|sass|scss|styl|stylus|pcss|postcss|json|wasm)$/
},
({ path }) => ({
path,
external: true
}
)
// 省略其他 JS 无关的模块
bare import
可以通过模块 id 判断,模块 id 不为路径的模块,就是 bare import。遇到这些模块则记录依赖,不再深入遍历。
build.onResolve(
{
// 第一个字符串为字母或 @,且第二个字符串不是 : 冒号。如 vite、@vite/plugin-vue
// 目的是:避免匹配 window 路径,如 D:/xxx
filter: /^[\w@][^:]/
},
async ({ path: id, importer, pluginData }) => {
// depImports中已存在
if (depImports[id]) {
return externalUnlessEntry({ path: id })
}
// 将模块路径转换成真实路径,实际上调用 container.resolveId
const resolved = await resolve(id, importer, {
custom: {
depScan: { loader: pluginData?.htmlType?.loader }
}
})
// 如果解析到路径,证明找得到依赖
// 如果解析不到路径,则证明找不到依赖,要记录下来后面报错
if (resolved) {
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
// 如果模块在 node_modules 中,则记录 bare import
if (resolved.includes('node_modules')) {
// 记录 bare import
depImports[id] = resolved
return {
path,
external: true
}
}
// isScannable 判断该文件是否可以扫描,可扫描的文件有 JS、html、vue 等
// 因为有可能裸依赖的入口是 css 等非 JS 模块的文件,如import 'xx.less'
else if (isScannable(resolved)) {
// 真实路径不在 node_modules 中,则证明是 monorepo,实际上代码还是在用户的目录中
// 是用户自己写的代码,不应该 external
return {
path: path.resolve(resolved)
}
} else {
// 其他模块不可扫描,直接忽略,external
return {
path,
external: true
}
}
} else {
// 解析不到依赖,则记录缺少的依赖
missing[id] = normalizePath(importer)
}
}
)
html、vue 类型模块
这类模块比较复杂,例如 HTML 或 Vue,里面有一部分是 JS,需要把这部分 JS 代码提取出来,然后按 JS 模块进行分析处理,继续深入遍历这些模块。
加载阶段的主要做有以下流程:
1.读取文件源码
2.正则匹配出所有的 script 标签,并对每个 script 标签的内容进行处理
2.1外部 script,改为用 import 引入
2.2内联 script,改为引入虚拟模块,并将对应的虚拟模块的内容缓存到 script 对象。
3.最后返回转换后的 js
由于依赖扫描过程,只关注引入的 JS 模块,因此可以直接丢弃掉其他不需要的内容,直接取其中 JS、 html 类型文件(包括 vue)的转换,有两种情况:
- 每个外部 script,会直接转换为 import 语句,引入外部 script
- 每个内联 script,其内容将会作为虚拟模块被引入。
什么是虚拟模块?
是模块的内容并非直接从磁盘中读取,而是编译时生成。
举个例子,src/main.ts 是磁盘中实际存在的文件,而 virtual-module:D:/project/index.html?id=0 在磁盘中是不存在的,需要借助打包工具(如 esbuild),在编译过程生成。
为什么需要虚拟模块?
因为一个 html 类型文件中,允许有多个 script 标签,多个内联的 script 标签,其内容无法处理成一个 JS 文件 (因为可能会有命名冲突等原因)
既然无法将多个内联 script,就只能将它们分散成多个虚拟模块,然后分别引入了。
esbuild 插件是如何编写的?
每个模块都会经过解析(resolve)和加载(load)的过程:
- 解析:将模块路径,解析成文件真实的路径。例如 vue ,会解析到实际 node_modules 中的 vue 的入口 js 文件
- 加载:根据解析的路径,读取文件的内容
- 通过 onResolve 、onLoad 定义解析和加载过程
- onResolve 的第一个参数为 过滤条件, 第二个参数为回调函数,解析时调用,返回值可以给模块做标记,如 external 、namespace (用于过滤),还需要返回模块的路径
- 每个模块, onResolve 会被依次调用,直到回调函数返回有效的值,后面的不再调用。如果都没有有效返回,则使用默认的解析方式
- onLoad 的第一个参数为过滤条件,第二个参数为回调函数,加载时调用,可以读取文件的内容,然后进行处理,最后返回加载的内容
- 每个模块,onLoad 会被依次调用,直到回调函数返回有效的值,后面的不再调用。如果都没有有效返回,则使用默认的加载方式。
demo源码解析:
先来看一下 DEMO 的模块依赖图:
对于本文示例而言,entries 就只有根目录的 index.html,所以首先会进入 html 流程:
// const htmlTypesRE = /\.(html|vue|svelte|astro)$/,这些格式的文件都被归类成 html 类型
// html types: 提取 script 标签
build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
// 将模块路径,转成文件的真实路径
const resolved = await resolve(path, importer)
if (!resolved) return
// 不处理 node_modules 内的
if (resolved.includes('node_modules'){
return
}
console.log('html type resolve --------------->', path)
return {
path: resolved,
// path: await resolve(path, importer),
// 将满足这类正则的文件全部归类成 html
namespace: 'html'
}
})
// extract scripts inside HTML-like files and treat it as a js module
// 匹配上述归类成 html 的模块,namespace 是对应的
// 正则,匹配例子: <script type=module></script>
const scriptModuleRE = /(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
// 正则,匹配例子: <script></script>
export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)<\/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: <script type=module></script>
// scriptRE: <script></script>
// 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 开始标签的内容(包含属性):它的值的例子: <script type="module" lang="ecmascript" src="xxx">
// content: script 标签的内容
const [, openTag, content] = match
// 找到 type 的值, 正则匹配出 openTag 中的 type 和 lang 属性
const typeMatch = openTag.match(typeRE)
const type =
typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
// 匹配语言,比如 vue-ts 中经常写的 <script lang="ts">
const langMatch = openTag.match(langRE)
const lang =
langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
// skip type="application/ld+json" and other non-JS types
// 跳过 type="application/ld+json" 和其他非 non-JS 类型
if (
type &&
!(
type.includes('javascript') ||
type.includes('ecmascript') ||
type === 'module'
)
) {
continue
}
// esbuild load 钩子可以设置 应的 loader
// 等于这些结果,都用当前lang的解析器,esbuild 都支持这些 loader
if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
loader = lang
}
// 正则匹配出 script src 属性
//可能有三种情况:src="xxx"、src='xxx'、src=xxx
// 匹配 src,例子中的 index.html 匹配上了就转成 import '/src/main.js'
const srcMatch = openTag.match(srcRE)
// 有 src 属性,证明是外部 script
if (srcMatch) {
const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
// 外部 script,改为用 import 引入外部 script
js += `import ${JSON.stringify(src)}\n`
} else if (content.trim()) {
// 内联的 script,它的内容要做成虚拟模块
// moduleScripts:`<script context="module">` in Svelte and `<script>` in Vue
// localScripts:`<script>` in Svelte and `<script setup>` in Vue
const contextMatch = openTag.match(contextRE)
const context =
contextMatch &&
(contextMatch[1] || contextMatch[2] || contextMatch[3])
// localScripts
if (
(path.endsWith('.vue') && setupRE.test(openTag)) ||
(path.endsWith('.svelte') && context !== 'module')
) {
// append imports in TS to prevent esbuild from removing them
// since they may be used in the template
const localContent =
content +
(loader.startsWith('ts') ? extractImportPaths(content) : '')
localScripts[path] = {
loader,
contents: localContent
}
// 虚拟模块的路径,如 virtual-module:D:/project/index.html?id=0
// 加上 virtual-module: 前缀
js += `import ${JSON.stringify(virtualModulePrefix + path)}\n`
} else {
js += content + '\n'
}
}
}
// ...
return {
loader,
contents: js
}
}
)
当入口是 index.html 时,命中了 build.onResolve({ filter: htmlTypesRE }, ...) 这条解析规则,通过 resolve 处理后返回 index.html 的绝对路径,并将 namespace 标记为 html,也就是归成 html 类。后续匹配上 html 的 load 钩子,就会进入回到函数中。
build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, ...) 也是通过 filter 和 namespace 去匹配文件。读取 index.html 文件内容之后,通过大量的正则表达式去匹配引入内容。重点在于 这段代码会被解析成 import '/src/main.js',这就会进入下一个 resolve、load 过程。在进入 JS_TYPE 的解析之前,有一个全匹配 resolver 先提出来:
build.onResolve(
{
filter: /.*/
},
async ({ path: id, importer }) => {
console.log('all resloved --------------->', id)
// 使用 vite 解析器来支持 url 和省略的扩展
const resolved = await resolve(id, importer)
if (resolved) {
// 外部依赖
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
// 扩展匹配上了 htmlTypesRE,就将其归类于 html
const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
return {
path: path.resolve(cleanUrl(resolved)),
namespace
}
} else {
// resolve failed... probably unsupported type
return externalUnlessEntry({ path: id })
}
}
)
build.onLoad({ filter: JS_TYPES_RE }, ({ path: id }) => {
console.log('js load --------------->', id)
// 获取文件的后缀扩展
let ext = path.extname(id).slice(1)
// 如果是 mjs,将 loader 重置成 js
if (ext === 'mjs') ext = 'js'
let contents = fs.readFileSync(id, 'utf-8')
// 通过 esbuild.jsxInject 来自动为每一个被 ESbuild 转换的文件注入 JSX helper
if (ext.endsWith('x') && config.esbuild && config.esbuild.jsxInject) {
contents = config.esbuild.jsxInject + `\n` + contents
}
// import.meta.glob 的处理
if (contents.includes('import.meta.glob')) {
return transformGlob(contents, id, config.root, ext as Loader).then(
(contents) => ({
loader: ext as Loader,
contents
})
)
}
return {
loader: ext as Loader,
contents
}
})
/.*/ 全匹配的 resolver 通过 resolve 函数获取完整地依赖路径,比如这里的 /src/main.js 就会被转成完整的绝对路径。然后再来到 JS_TYPES_RE 的 loader,最终输出文件内容 contents 和对应的解析器。/src/main.js 文件内容:
import { createApp } from 'vue'
import App from './App.vue'
import '../lib/index'
createApp(App).mount('#app')
接着就会处理 vue、./App.vue、../lib/index 3个依赖。
对于 vue 依赖,会跟 /^[\w@][^:]/ (regex101: build, test, and debug regex) 匹配。
// bare imports: record and externalize ----------------------------------
build.onResolve(
{
// avoid matching windows volume
filter: /^[\w@][^:]/
},
async ({ path: id, importer }) => {
console.log('bare imports --------------->', id)
// 首先判断是否在外部入口列表中
if (moduleListContains(exclude, id)) {
return externalUnlessEntry({ path: id })
}
// 缓存,对于在node_modules或者optimizeDeps.include中已经处理过的依赖,直接返回
if (depImports[id]) {
return externalUnlessEntry({ path: id })
}
const resolved = await resolve(id, importer)
if (resolved) {
// 对于一些特殊的场景,也会将其视作 external 排除掉
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
// 判断是否在node_modules或者optimizeDeps.include中的依赖
if (resolved.includes('node_modules') || include?.includes(id)) {
// 存到depImports中,这个对象就是外部传进来的deps,最后会写入到缓存文件中的对象
// 如果是这类依赖,直接将其视作为“外部依赖”,不在进行接下来的resolve、load
if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
depImports[id] = resolved
}
return externalUnlessEntry({ path: id })
} else {
const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
// linked package, keep crawling
return {
path: path.resolve(resolved),
namespace
}
}
} else {
missing[id] = normalizePath(importer)
}
}
)
经过上述解析,vue 依赖会被视作 external dep,并将它缓存到 depImports,因为对于 node_modules 或者 optimizeDeps.include 中的依赖,只需抓取一次即可。
对于 ./App.vue,首先会进入 html type resolve,然后进入 html load load 的回调中。跟 index.html 不同的时,此时是 .vue 文件,在执行 match = regex.exec(raw) 的时候匹配到 <script setup> 没有 src 路径,会进入 else 逻辑。将 App.vue 中的 script 内容提取出来,存到 localScripts 中。最终生成的对象:
localScripts = {
'/Users/yjcjour/Documents/code/vite/examples/vue-demo/src/App.vue': {
loader: 'js',
// vue 中 <script setup> 内容
contents: `
// This starter template is using Vue 3 <script setup> SFCs
// Check out https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup
import HelloWorld from './components/HelloWorld.vue'
import _ from 'lodash-es'
console.log(_.trim(' hello '))`
}
}
这次 App.vue 执行 load 后返回以下内容:
{
loader: 'js',
contents: 'import "virtual-module:/Users/yjcjour/Documents/code/vite/examples/vue-demo/src/App.vue"'
}
虚拟模块加载对应的 script 代码:
从而继续执行 virtualModuleRE 的爬取:(直接从 script 对象中,读取之前缓存起来的内容)
// virtualModuleRE -> /^virtual-module:.*/
// 匹配虚拟的这些模块,比如 `<script>` in Svelte and `<script setup>` in Vue 都会生成虚拟模块
build.onResolve({ filter: virtualModuleRE }, ({ path }) => {
console.log('virtual module resolved -------------->', path)
return {
// 去掉 prefix
// virtual-module:D:/project/index.html?id=0 => D:/project/index.html?id=0
// strip prefix to get valid filesystem path so esbuild can resolve imports in the file
path: path.replace(virtualModulePrefix, ''),
namespace: 'local-script'
}
})
// 之前的内联 localScripts 内容,保存到 localScripts 对象,加载虚拟模块的时候取出来
build.onLoad({ filter: /.*/, namespace: 'local-script' }, ({ path }) => {
// 即返回上述
return localScripts[path]
})
virtual-module 的解析就是直接从 localScripts 获取内容:
import HelloWorld from './components/HelloWorld.vue'
import _ from 'lodash-es'
console.log(_.trim(' hello '))`
又会接着处理 ./components/HelloWorld.vue、lodash-es 依赖,这两个依赖跟 App.vue 和 vue 的处理过程都是一样的,这里就不详细说明了。
扫描结果
最后对于 ./lib/index,跟上述解析 /src/main.js 的流程也是一致的。我们可以将整个依赖抓取和解析的过程用下图总结:
通过 resolve 依赖,load 解析,esbuild 就扫描完整个应用的依赖。具体的处理路径通过在源码中打上日志,我们可以看到以下的处理路径:
路径结果跟我们的分析是一样的。插件执行结束,作为参数传入的 deps、missing 也完成了它们的收集(在对 /^[\w@][^:]/ 做依赖分析时会给这两个对象挂属性)。
// deps
{
'lodash-es': '/Users/yjcjour/Documents/code/vite/examples/vue-demo/node_modules/lodash-es/lodash.js',
'vue': '/Users/yjcjour/Documents/code/vite/examples/vue-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}
// missing
{}
再结合开头的的流程,将 optimizeDeps.include 定义的项添加到 deps ,最终生成 deps 会添加上 lib/index 这条数据:
// deps
{
'lodash-es': '/vite/examples/vue-demo/node_modules/lodash-es/lodash.js',
'vue': '/vite/examples/vue-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js',
// optimizeDeps.include 定义的预构建项
'/vite/examples/vue-demo/lib/index.js': '/vite/examples/vue-demo/lib/index.js'
}
有了完整的 deps 信息,就可以开始执行真正的预构建。
小结
依赖扫描是预构建前的一个非常重要的步骤,这决定了 Vite 需要对哪些依赖进行预构建。
实现依赖扫描的基本思路 —— 深度遍历依赖树,并对各种类型的模块进行处理。
- 最复杂的就是 html 类型模块的处理,需要使用虚拟模块
- 当遇到 bare import 时,需要判断是否在 node_modules 中
- 其他 JS 无关的模块就直接 external
- JS 模块由于 esbuild 本身能处理,不需要做任何的特殊操作
最后获取到的 depImport 是一个记录依赖以及其真实路径的对象
执行预构建、输出结果
-
使用esbuild构建依赖
// 调用esbuild.build打包文件
const result = await build({
// ...
entryPoints: Object.keys(flatIdDeps),// 入口
format: 'esm',// 打包成esm模式
external: config.optimizeDeps?.exclude,// 剔除exclude文件
outdir: cacheDir,// 输出地址
// ...
})
-
预构建完成后,重新写入_metadata.json
// 重新写入_metadata.json
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
}
}
writeFile(dataPath, JSON.stringify(data, null, 2))
一般情况下,esbuild 会输出嵌套的产物目录结构,比如对于Vue 来说,其产物在dist/vue.runtime.esm-bundler.js中,那么经过 esbuild 正常打包之后,预构建的产物目录如图:
由于各个第三方包的产物目录结构不一致,这种深层次的嵌套目录对于 Vite 路径解析来说,其实是增加了不少的麻烦的,带来了一些不可控的因素。为了解决嵌套目录带来的问题,Vite 做了两件事情来达到扁平化的预构建产物输出:
① 嵌套路径扁平化,/被换成下划线,如 react/jsx-dev-runtime,被重写为react_jsx-dev-runtime;
② 用虚拟模块来代替真实模块,作为预打包的入口。
回到optimizeDeps函数中,在执行完依赖扫描的步骤后,就会执行路径的扁平化操作:
在 esbuildDepPlugin 函数内,通过正则判断是否为入口模块,如果是,则标记上dep的 namespace,成为一个虚拟模块。然后 Esbuild 会将虚拟模块作为入口来进行打包,最后的产物目录会变成扁平结构:
例:fs-extra
在开始这部分的分析之前,我们先安装一个纯 commonjs 的依赖 fs-extra。之后在 main.js 中引入这个包:
import { createApp } from 'vue'
import * as fsExtra from 'fs-extra'
import App from './App.vue'
import '../lib/index'
createApp(App).mount('#app')
console.log(fsExtra)
然后在下述代码打上断点:
// esbuild generates nested directory output with lowest common ancestor base
// this is unpredictable and makes it difficult to analyze entry / output
// mapping. So what we do here is:
// 1. flatten all ids to eliminate slash
// 2. in the plugin, read the entry ourselves as virtual files to retain the
// path.
// 拍平的依赖IDs
const flatIdDeps: Record<string, string> = {}
// id <--> export
const idToExports: Record<string, ExportsData> = {}
// 拍平的id <--> export
const flatIdToExports: Record<string, ExportsData> = {}
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
await init
for (const id in deps) {
const flatId = flattenId(id)
const filePath = (flatIdDeps[flatId] = deps[id])
const entryContent = fs.readFileSync(filePath, 'utf-8') // 读取依赖内容
let exportsData: ExportsData
try {
// 使用 es-module-lexer 对模块的导入、导出做解析
exportsData = parse(entryContent) as ExportsData
} catch {
// ...
}
// ss -> export start se -> export end
for (const { ss, se } of exportsData[0]) {
// 截取 export、import 整个表达式
const exp = entryContent.slice(ss, se)
// 存在复合写法 -> export * from 'xxx',打上 hasReExports 的标志符号
if (/export\s+\*\s+from/.test(exp)) {
exportsData.hasReExports = true
}
}
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData
}
// 组合环境变量
const define: Record<string, string> = {
'process.env.NODE_ENV': JSON.stringify(config.mode)
}
for (const key in config.define) {
const value = config.define[key]
define[key] = typeof value === 'string' ? value : JSON.stringify(value)
}
const start = performance.now()
// 执行预编译
const result = await build({
// 绝对路径的工作目录(项目根目录)
absWorkingDir: process.cwd(),
// 入口点
entryPoints: Object.keys(flatIdDeps),
// 集合,将全部文件构建后内联到入口文件
bundle: true,
// 输出文件格式,支持 iife、cjs、esm
format: 'esm',
// 打包后的支持的环境目标
target: config.build.target || undefined,
// 排除某些依赖的打包
external: config.optimizeDeps?.exclude,
// 日志级别,只显示错误信息
logLevel: 'error',
// 代码拆分
splitting: true,
sourcemap: true,
// 构建输出目录,默认是 node_modules/.vite
outdir: cacheDir,
// 忽略副作用注释
ignoreAnnotations: true,
// 输出构建文件
metafile: true,
// 全局声明
define,
// 插件
plugins: [
...plugins,
esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr)
],
...esbuildOptions
})
// 执行构建传入了 metafile: true,这里能够拿到构建信息
const meta = result.metafile!
// the paths in `meta.outputs` are relative to `process.cwd()`
// 缓存目录
const cacheDirOutputPath = path.relative(process.cwd(), cacheDir)
// 更新 optimized 信息,全部写入到 data.optimized 中
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
needsInterop: needsInterop(
id,
idToExports[id],
meta.outputs,
cacheDirOutputPath
)
}
}
// 预编译结果写入metadata文件
writeFile(dataPath, JSON.stringify(data, null, 2))
debug(`deps bundled in ${(performance.now() - start).toFixed(2)}ms`)
return data
在执行预构建前,先将存在多层级的 dep 拍平,目的是为了产物能够更明确和简单。然后遍历 deps 做 import、export 的解析。对于有 export * from 'xx' 的表达式,会打上 hasReExports 标记;对于本文 DEMO 而言,执行完 deps 的分析之后 flatIdDeps、 idToExports、flatIdToExports 的结果如截图所示:
从结果可以清楚地看到变量的含义:
- flatIdDeps:拍平之后的 id 跟具体依赖路径的映射;
- idToExports:依赖 id 的 imports、 exports 信息;
- flatIdToExports:拍平 id 的 imports、exports 信息;
将以上变量传到 esbuildDepPlugin 创建预构建插件,再将环境变量等信息全部传入,执行真正的预构建。此时 esbuild.build 需要重点关注参数有:
- entryPoints: Object.keys(flatIdDeps),就是上述拍平的依赖 id;
- outdir: cacheDir,将产物输出到缓存目录,默认是 node_modules/.vite;
- plugins: [ ...plugins, esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr) ] 使用预构建插件。
esbuildDepPlugin: 预构建执行之前做了什么?
紧接着我们进入 esbuildDepPlugin 插件内部,看预构建执行之前做了什么事情?
export function esbuildDepPlugin(
qualified: Record<string, string>,
exportsData: Record<string, ExportsData>,
config: ResolvedConfig,
ssr?: boolean
): Plugin {
// 默认 esm 默认解析器
const _resolve = config.createResolver({ asSrc: false })
// node 的 cjs 解析器
const _resolveRequire = config.createResolver({
asSrc: false,
isRequire: true
})
const resolve = (
id: string,
importer: string,
kind: ImportKind,
resolveDir?: string
): Promise<string | undefined> => {
let _importer: string
// explicit resolveDir - this is passed only during yarn pnp resolve for
// entries
if (resolveDir) {
_importer = normalizePath(path.join(resolveDir, '*'))
} else {
// map importer ids to file paths for correct resolution
_importer = importer in qualified ? qualified[importer] : importer
}
// kind 是 esbuild resolve 回调中的一个参数,表示模块类型,总共有 7 种类型
// https://esbuild.github.io/plugins/#on-resolve-arguments
// 以require开头的表示cjs、否则用esm的解析器
const resolver = kind.startsWith('require') ? _resolveRequire : _resolve
return resolver(id, _importer, undefined, ssr)
}
return {
name: 'vite:dep-pre-bundle',
setup(build) {
console.log('dep-pre-bundle -------->, ', build.initialOptions)
// ...
/**
* 解析入口
*
* @param {string} id
* @return {*}
*/
function resolveEntry(id: string) {
const flatId = flattenId(id)
if (flatId in qualified) {
return {
path: flatId,
namespace: 'dep'
}
}
}
build.onResolve(
{ filter: /^[\w@][^:]/ },
async ({ path: id, importer, kind }) => {
// ...
// ensure esbuild uses our resolved entries
let entry: { path: string; namespace: string } | undefined
// if this is an entry, return entry namespace resolve result
if (!importer) {
if ((entry = resolveEntry(id))) return entry
// ...
}
// use vite's own resolver
const resolved = await resolve(id, importer, kind)
if (resolved) {
if (resolved.startsWith(browserExternalId)) {
return {
path: id,
namespace: 'browser-external'
}
}
if (isExternalUrl(resolved)) {
return {
path: resolved,
external: true
}
}
return {
path: path.resolve(resolved)
}
}
}
)
const root = path.resolve(config.root)
build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {
console.log('dep load --------->', id)
const entryFile = qualified[id]
// 相对根目录的路径
let relativePath = normalizePath(path.relative(root, entryFile))
// 自动加上路径前缀
if (
!relativePath.startsWith('./') &&
!relativePath.startsWith('../') &&
relativePath !== '.'
) {
relativePath = `./${relativePath}`
}
let contents = ''
// 获取文件的 import、export 信息
const data = exportsData[id]
const [imports, exports] = data
if (!imports.length && !exports.length) {
// cjs
contents += `export default require("${relativePath}");`
} else {
if (exports.includes('default')) {
contents += `import d from "${relativePath}";export default d;`
}
if (
data.hasReExports ||
exports.length > 1 ||
exports[0] !== 'default'
) {
contents += `\nexport * from "${relativePath}"`
}
}
let ext = path.extname(entryFile).slice(1)
if (ext === 'mjs') ext = 'js'
return {
loader: ext as Loader,
contents,
resolveDir: root
}
})
// ...
}
}
}
流程可以概括成两大步:
- 计算依赖相较于 root 项目根目录的相对路径,并做规范化——添加相对路径符号;
- 根据 exportsData 也就是上面的 flatIdToExports 变量获取 imports、exports 信息;然后做了三层判断:
- 如果满足 !imports.length && !exports.length,说明这是一个 CJS 模块,就会输出 export default require("${relativePath}");,对于 DEMO 而言,fs-extra 就是这种情况,最后输出的 contents 是 'export default require("./node_modules/fs-extra/lib/index.js");';
- 不是 CJS,就判断是否存在默认导出(export default),有的话就会在 contents 上拼接 import d from "${relativePath}";export default d;;对于 DEMO 而言,lodash-es 因为有默认导出,同时也会执行 c 的流程,最终生成的 contents 如 'import d from "./node_modules/lodash-es/lodash.js";export default d;\nexport * from "./node_modules/lodash-es/lodash.js"';
- 在上一步的基础上,如果有其他的导出表达式比如 export { compile }; ,就会加多一层复合导出,将模块的内容全部导出,即 \nexport * from "${relativePath}",对于 DEMO 而言,vue 和 lib/index.js 会执行这个逻辑,最后返回 '\nexport * from "./node_modules/vue/dist/vue.runtime.esm-bundler.js"' 和 '\nexport * from "./lib/index.js"'。
第一层依赖4个入口就处理完成了,接下来就会对递归搜寻依赖的依赖。
完成整个预构建的依赖查找之后,就会执行构建,构建后的 metafile 信息如下:
input 信息太长,只打印了搜查的依赖总长度是 692,最后构建的产物从上图能够看到对于 lodash-es 这种包,会将依赖全部打成一个包,减少 http 次数。
最后的最后,将 deps 信息更新到 data.optimized 并写入到缓存文件目录。整个预构建流程就结束了。