vite只言片语(一)

第一部分,让我们从构建工具的角度,逐步深入了解 Vite,重点关注它的核心特性——依赖预构建。

理解 vite

Vite 是一个思维比较前卫而且先进的构建工具,它解决了一些Webpack解决不了的问题,同时降低了一些心智负担。
Vite基于自己得天独厚的优势,他已经占有一席之地,Vite是 vue 团队的官方出品,背靠强大的生态,vue-cli已经将Vite作为预设构建工具。使用vue-cli去构建 vue 项目的时候你要写的vue.config.js不再是webpack的配置而是vite的配置(目前只基于浏览器项目)。
Vite支持直接构建reactangular,和svelte项目。
而这只是Vite优势的一角。

什么是构建工具

浏览器只认识 html、css、js。

企业级项目里除了基本的原生 Api,为了提高团队生产力必定会加入其他Library,比如TypeScriptVuesassbabelbuild tool等等。
在前后端没分离的多页应用时期,只要有任何代码、文件改动,都需要重新发布或者部署到服务器,才能看见最新的页面,开发起来相当麻烦。

构建工具能够帮我们把tscreact-complilersassbabeluglifyjs集成到一起,这样我们能把更多精力放在业务代码。

构建工具做了什么

  • 模块化支持:支持从node_modules引入代码 + 多种模块化支持(vite默认是 ESM 规范)
  • 代码兼容性与转译:比如babel语法降级,lessts语法兼容等等
  • 提高项目性能:
    • 压缩文件:压缩 JavaScript、CSS 等代码,减小文件大小,提高加载速度。
    • 文件指纹:为文件生成唯一的指纹或哈希值,以便于缓存管理和版本控制。
    • 代码分割:将代码拆分成更小的块,使得页面加载时只需下载当前视图所需的代码,提高加载速度。
  • 优化开发体验:
    • 错误检查和修复: 使用工具(如ESLintTSLint)检查代码中的错误和潜在问题,并在可能的情况下自动修复。
    • 服务启动:提供本地开发服务器,支持热更新(Hot Module Replacement),使得开发过程更加高效。

如果没有构建工具,我们将要手动执行这些任务,这会导致效率低下、易出错,并且难以保持项目的可维护性。

为什么选 vite?:官方首当其冲的指引

由于Webpack底层实现和兼容性考虑各方面因素,导致编译和打包时间劣于Vite

依赖预加载

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

可以理解为,vite 首先会找到对应的依赖,然后调用esbuild(对 js 语法进行处理的一个库),将其他规范的代码转换成esmodule规范,然后放到当前目录下的node modules/.vite/deps/,对esmodule规范的各个模块统一集成。

官方也给出了相关说明npm-dependency-resolving-and-pre-bundling

为什么需要

  1. 模块兼容性:不同的第三方包会有不同的导入导出格式(比如非 ESM 规范的 react,在开发环境,需要借助预构建将非 ESM 转成 ESM,从而在浏览器 import 模块时成功识别)
  2. 路径便捷处理:对路径的处理上可以直接使用 .vite/deps,方便路径重写
  3. 性能提升:网络多包传输的性能问题(也是原生 ESM 规范不敢支持node_modules的原因之一),有了依赖预构建以后无论依赖有多少 export 和 import,vite都会尽可能的集成为一个或几个模块。

根据上述三点,我们可以得出结论:依赖预构建是为了保障模块的兼容性,也是为了提升性能

思维导图

预构建究竟是怎么样的过程?我们先来看一幅关于依赖预构建的思维导图

深入浅出 Vite5 中依赖预构建'

  • 启动开发服务器: 当执行 npm run dev 启动 Vite 的开发服务器时,Vite 默认会检索项目目录下的所有 .html 文件(忽略了 node_modulesbuild.outDir__tests__coverage)以确定需要预构建的依赖项。这一过程在没有明确指定 build.rollupOptions.input 或 optimizeDeps.entries 的情况下自动进行。

  • 分析入口文件: Vite 会分析项目的入口 HTML 文件,检测其中的 <script> 标签,以识别引入的 JavaScriptTypeScript 资源。例如,通过分析 /src/main.ts,Vite 能够识别项目的主要入口。

  • 分析模块依赖: 进一步分析 /src/main.ts 中的模块依赖,包括第三方依赖和项目内源代码。对于第三方依赖,Vite 记录其入口文件地址,并将其标记为外部模块,不进行深度递归扫描。对于项目内源代码,Vite 递归扫描所有的导入语句,建立模块之间的依赖关系。

  • 递归分析非第三方模块: 在分析主模块后,Vite 进一步递归分析非第三方模块中的依赖引用,例如 /src/App.tsx。这个过程重复第三步骤,构建完整的依赖关系图。

  • 生产依赖预构建: 基于建立的依赖关系图,Vite 利用 EsBuild 对扫描出的所有第三方依赖入口文件进行打包。生成的产物存放在 node_modules/.vite/deps 文件夹中,例如,源码中导入的 antd 最终会被构建为一个单独的 antd.js 文件。

这个过程的优势在于提高开发服务器的启动速度,同时通过按需预构建依赖,加速了整体的开发体验。

源代码

由于 Vite 的项目结构为 monorepo 结构,这里我们仅仅关心 vite 目录即可。

首先,Vite 目录下的 /pakcages/vite/bin/vite.js 文件是作为项目 cli 入口文件。
实际当运行 vite 命令时会执行该文件,执行该文件会经过以下调用链:

执行 /vite/src/node/cli.ts 文件处理一系列命令行参数。

处理完毕后再次调用 /vite/src/node/server/index.ts 创建开发服务器。

创建服务关键函数便是 createServer 函数,其中不乏一些配置函数比如 resolveConfigresolveChokidarOptions等等。

依赖预构建

在 _createServer 下半部分我们能发现以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (!middlewareMode && httpServer) {
// overwrite listen to init optimizer before server start
const listen = httpServer.listen.bind(httpServer);
httpServer.listen = (async (port: number, ...args: any[]) => {
try {
// ensure ws server started
hot.listen();
await initServer();
} catch (e) {
httpServer.emit("error", e);
return;
}
return listen(port, ...args);
}) as any;
} else {
if (options.hotListen) {
hot.listen();
}
await initServer();
}

initServer 执行之前保证 WebSocket 服务器成功启动和连接,这与 HMR 相关。

Hot Module Replacement,HMR 允许在运行时更新应用程序的一部分而无需进行完全刷新。为了实现 HMR,通常会使用 WebSocket 来在开发服务器和客户端之间建立实时的双向通信通道。这个通道用于传递模块变更的信息,以便在应用程序运行时进行相应的更新。

接下来看核心函数 initServer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// httpServer.listen can be called multiple times
// when port when using next port number
// this code is to avoid calling buildStart multiple times
let initingServer: Promise<void> | undefined;
let serverInited = false;
const initServer = async () => {
if (serverInited) return;
if (initingServer) return initingServer;

initingServer = (async function () {
await container.buildStart({});
// start deps optimizer after all container plugins are ready
if (isDepsOptimizerEnabled(config, false)) {
await initDepsOptimizer(config, server);
}
warmupFiles(server);
initingServer = undefined;
serverInited = true;
})();
return initingServer;
};

buildStart

首先是两个 if 是为了防止 buildStart 多次调用产生性能问题,
container.buildStart({}) 主要负责触发并行执行构建过程中插件的 buildStart 钩子,确保插件在构建开始阶段得到调用。这对于插件执行一些在构建开始前的准备工作非常重要,例如一些初始化或预处理工作。

initDepsOptimizer

initDepsOptimizer 函数下的 createDepsOptimizer 便是 pre-bundled 的主要实现。

初始

1
2
3
4
5
6
async function createDepsOptimizer(
config: ResolvedConfig,
server?: ViteDevServer
): Promise<void> {
// ...
}

在初始化阶段,该函数接收两个参数:config 是已解析的 Vite 配置,而 server 是 ViteDevServer 的实例。它还定义了一系列变量,包括日志记录器、是否是 SSR 模式、时间戳等。

加载缓存和设置状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr);
let debounceProcessingHandle: NodeJS.Timeout | undefined;
let closed = false;
let metadata =
cachedMetadata || initDepsOptimizerMetadata(config, ssr, sessionTimestamp);
const depsOptimizer: DepsOptimizer = {
/* ... */
};
depsOptimizerMap.set(config, depsOptimizer);
let newDepsDiscovered = false;
let newDepsToLog: string[] = [];
let newDepsToLogHandle: NodeJS.Timeout | undefined;
const logNewlyDiscoveredDeps = () => {
/* ... */
};
let depOptimizationProcessing = promiseWithResolvers<void>();
let depOptimizationProcessingQueue: PromiseWithResolvers<void>[] = [];
const resolveEnqueuedProcessingPromises = () => {
/* ... */
};
let enqueuedRerun: (() => void) | undefined;
let currentlyProcessing = false;
let firstRunCalled = !!cachedMetadata;
let crawlEndFinder: CrawlEndFinder | undefined;

// ...

这一部分主要用于加载缓存的优化元数据,初始化一些状态变量,并设置一些用于处理优化过程的工具函数(比如生成_metadata文件,创建 depsOptimizer 对象等等)。其中,depsOptimizer 是一个关键的对象,它包含了优化元数据、处理新依赖的函数等。

启动优化器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function onCrawlEnd() {
// ...
await depsOptimizer.scanProcessing;
if (optimizationResult && !config.optimizeDeps.noDiscovery) {
// ...
} else {
const crawlDeps = Object.keys(metadata.discovered);
currentlyProcessing = false;
if (crawlDeps.length === 0) {
// ...
}
debouncedProcessing(0);
}
}

在静态导入结束后,触发 onCrawlEnd 函数。根据是否有优化结果,决定是使用之前的结果进行优化,还是执行新的优化操作。这部分逻辑保证了 Vite 在启动阶段能够正确处理静态导入和优化依赖。

注册缺失的导入

1
2
3
4
function registerMissingImport(id: string, resolved: string): OptimizedDepInfo {
// ...
return missing;
}

registerMissingImport 函数用于注册缺失的导入,它负责将缺失的依赖添加到优化元数据中,以及触发后续的优化操作。这个函数是整个优化过程中一个关键的环节。

优化器运行

1
2
3
4
5
6
7
8
9
10
// 优化运行成功后,将创建所有当前和发现的依赖项的新捆绑版本
// 在缓存目录中,并将新的元数据信息分配给 _metadata。仅在之前捆绑的依赖项发生更改时才会发出 fullReload。

// 如果重新运行失败,_metadata 保持不变,当前发现的依赖项被清除,并发出 fullReload。

// 所有依赖项,包括之前已知的和新发现的,都会被重新捆绑
// 保持插入顺序以保持元数据文件稳定
async function runOptimizer(preRunResult?: DepOptimizationResult) {
// ...
}

runOptimizer 函数是整个优化过程的核心。它包含了处理新依赖的逻辑、触发优化运行、处理优化结果的流程。这里通过比对哈希值等判断是否需要执行全页面刷新,以达到

提交优化

1
2
3
4
5
6
7
8
9
10
11
async function commitProcessing() {
// ...
}

if (!needsReload) {
await commitProcessing();
// ...
} else {
await commitProcessing();
// ...
}

commitProcessing 函数中:

  • 如果不需要页面刷新 (needsReload 为 false),则会执行 await commitProcessing(),并记录日志或启动 debounced 日志记录。
  • 如果需要页面刷新 (needsReload 为 true),也会执行 await commitProcessing(),然后记录日志或启动 debounced 日志记录,并执行 fullReload 函数,发起全页面刷新。

其他功能

此外,还有一些其他功能,如处理延迟启动优化器(delayDepsOptimizerUntil)、注册新依赖(registerMissingImport)、触发重新运行(rerun)等等。这些功能在整个优化过程中起到了辅助作用。

最后

createDepsOptimizer 函数是 Vite 中实现依赖优化的核心。通过对其代码的深入解析,我们理解了其初始化过程、静态导入处理、优化运行和结果处理等关键步骤。这个函数的设计巧妙,保证了 Vite 在开发过程中对依赖的快速响应和高效优化。

The End

本文简单聊了聊构建工具和vite 的核心之一依赖预构建 Dependency Pre-Bundling,后续我会继续更新 vite 其他进阶内容,比如 HMR、 build过程等等。

$The\,End$