引言
大多数同学说到 Vite
,会下意识的反应出 “快”、“noBundle”等关键词。
那么,为什么 Vite 相较于 Webpack、Rollup 之类的会更加快,亦或是大多数同学以为 Vite 是 “noBundle” 又是否正确呢?
接下来,这篇文章和咱们一同来浅显易懂 Vite 中的中心的“预构建”进程。
文章中 vite 版别为最新的
5.0.0-beta.18
。
预构建
概念
既然说到预构建,那么预构建究竟是一个什么样的概念呢?
了解 Vite 的朋友可能清楚,Vite 在开发环境中存在一个优化依靠预构建(Dependency Pre-Bundling)的概念。
简略来说,所谓依靠预构建指的是在 DevServer
发动之前,Vite 会扫描运用到的依靠然后进行构建,之后在代码中每次导入(import
)时会动态地加载构建过的依靠这一进程,
也许大多数同学关于 Vite 的认知更多是 No Bundle,但上述的依靠预构建进程确实像是 Bundle 的进程。
简略来说,Vite 在一开端将运用中的模块区分为依靠和源码两类:
-
依靠部分更多指的是代码中运用到的第三方模块,比方
vue
、lodash
、react
等。Vite 将会运用esbuild在运用发动时关于依靠部分进行预构建依靠。
-
源码部分比方说往常咱们书写的一个一个
js
、jsx
、vue
等文件,这部分代码会在运转时被编译,并不会进行任何打包。Vite 以原生 ESM办法供给源码。这实践上是让浏览器接管了打包程序的部分作业:Vite 只需求在浏览器恳求源码时进行转化并按需供给源码。依据情形动态导入代码,即只在当前屏幕上实践运用时才会被处理。
咱们在文章中接下来要聊到的依靠预构建,其实更多是针关于第三方模块的预构建进程。
什么是预构建
咱们在运用 vite
发动项目时,仔细的同学会发现项目 node_modules
目录下会额定添加一个 node_modules/.vite/deps
的目录:
这个目录便是 vite
在开发环境下预编译的产品。
项目中的依靠部分: ahooks
、antd
、react
等部分会被预编译成为一个一个 .js
文件。
一同,.vite/deps
目录下还会存在一个 _metadata.json
:
_metadata.json
中存在一些特别特点:
hash
browserHash
optimized
chunks
简略来说 vite
在预编译时会关于项目中运用到的第三方依靠进行依靠预构建,将构建后的产品存放在 node_modules/.vite/deps
目录中,比方 ahooks.js
、react.js
等。
一同,预编译阶段也会生成一个 _metadata.json
的文件用来保存预编译阶段生成文件的映射联系(optimized 字段),方便在开发环境运转时重写依靠途径。
上边的概念咱们也不需求过于在意,现在不清楚也没联系。咱们只需求清楚,依靠预构建的进程简略来说便是生成 node_modules/deps
文件即可。
为什么需求预构建
那么为什么需求预构建呢?
首要第一点,咱们都清楚 Vite 是依据浏览器 Esmodule
进行模块加载的办法。
那么,关于一些非 ESM
模块规范的第三方库,比方 react
。在开发阶段,咱们需求凭借预构建的进程将这部分非 esm
模块的依靠模块转化为 esm
模块。然后在浏览器中进行 import
这部分模块时也能够正确识别该模块语法。
别的一个方面,相同是因为 Vite 是依据 Esmodule
这一特性。在浏览器中每一次 import
都会发送一次恳求,部分第三方依靠包中可能会存在许多个文件的拆分然后导致发起多次 import
恳求。
比方 lodash-es
中存在超过 600 个内置模块,当咱们履行 import { debounce } from 'lodash'
时,假如不进行预构建浏览器会一同发出 600 多个 HTTP 恳求,这无疑会让页面加载变得显着缓慢。
正式经过依靠预构建,将 lodash-es
预构建成为单个模块后仅需求一个 HTTP 恳求就能够处理上述的问题。
依据上述两点,Vite 中正是为了模块兼容性以及功用这两方面大的原因,所以需求进行依靠预构建。
思路导图
那么,预构建究竟是怎样样的进程?咱们先来看一幅关于依靠预构建的思维导图
在开端后续的内容之前,咱们先来简略和咱们聊聊这张图中描述的各个关键进程。
- 调用
npm run dev
(vite) 发动开发服务器。 首要,当咱们在 vite 项目中首次发动开发服务器时,默许状况下(未指定build.rollupOptions.input
/optimizeDeps.entries
状况下),Vite 抓取项目目录下的一切的(config.root
).html
文件来检测需求预构建的依靠项(疏忽了node_modules
、build.outDir
、__tests__
和coverage
)。
通常状况下,单个项目咱们仅会运用单个
index.html
作为进口文件。
-
剖析 index.html 进口文件内容。 其次,当首次运转发动指令后。Vite 会寻觅到进口 HTML 文件后会剖析该进口文件中的
<script>
标签寻觅引进的 js/ts 资源(图中为/src/main.ts
)。 -
剖析
/src/main.ts
模块依靠 之后,会进入/src/main.ts
代码中进行扫描,扫描该模块中的一切import
导入语句。这一步主要会将依靠分为两种类型然后进行不同的处理办法:- 关于源码中引进的第三方依靠模块,比方
lodash
、react
等第三方模块。Vite 会在这个阶段将导入的第三方依靠的进口文件地址记录到内存中,简略来说比方当碰到import antd from 'antd'
时 Vite 会记录{ antd: '/Users/19Qingfeng/Desktop/vite/vite-use/node_modules/antd/es/index.js' }
,一同会将第三方依靠当作外部(external
)进行处理(并不会递归进入第三方依靠进行扫描)。 - 关于模块源代码,就比方咱们在项目中编写的源代码。Vite 会顺次扫描模块中一切的引进,关于非第三方依靠模块会再次递归进入扫描。
- 关于源码中引进的第三方依靠模块,比方
-
递归剖析非第三方模块中的依靠引用 一同,在扫描完结
/src/main.ts
后,Vite 会关于该模块中的源码模块进行递归剖析。这一步会从头进行第三进程,仅有不同的是扫描的为/src/App.tsx
。终究,经过上述进程 Vite 会从进口文件动身扫描出项目中一切依靠的第三方依靠,一同会存在一份类似于如下的映射联系表:
{ "antd": { // key 为引进的第三方依靠名称,value 为该包的进口文件地址 "src": "/Users/19Qingfeng/Desktop/vite/vite-use/node_modules/antd/es/index.js" }, // ... }
-
出产依靠预构建产品
经过上述的进程,咱们现已生成了一份源码中一切关于第三方导入的依靠映射表。 终究,Vite 会依据这份映射表调用 EsBuild 关于扫描出的一切第三方依靠进口文件进行打包。将打包后的产品存放在
node_modules/.vite/deps
文件中。 比方,源码中导入的antd
终究会被构建为一个单独的antd.js
文件存放在node_modules/.vite/deps/antd.js
中。
简略来说,上述的 5 个进程便是 Vite
依靠预构建的进程。
有些同学可能会猎奇,预构建生成这样的文件怎样运用呢?
这个问题其实和这篇文章联系并不是很大,本篇文章中着重点更多是和让咱们了解预构建是在做什么以及是怎样完成的进程。
简略来说,预构建关于第三方依靠生成 node_modules/.vite/deps
资源后。在开发环境下 vite
会“阻拦”一切的 ESM 恳求,将源码中关于第三方依靠的恳求地址重写为咱们预构建之后的资源产品,比方咱们在源码中编写的 antd 导入:
终究在开发环境下 Vite 会将关于第三方模块的导入途径从头为:
其实 import { Button } from '/node_modules/.vite/deps/antd.js?v=09d70271'
这个地址正是咱们将 antd
在预构建阶段经过 Esbuild 在 /node_modules/.vite/deps
生成的产品。
至于 Vite 在开发环境下是怎么重写这部分第三方导入的地址这件事,咱们会在下一篇关于完成 Vite 的文章会和咱们详细解说。
简略完成
上边的进程咱们关于 Vite 中的预构建进行了简略的流程整理。
经过上述的章节咱们了解了预构建的概念,以及预构建究竟的大致进程。
接下来,我会用最简略的代码来和咱们一同完成 Vite 中预构建这一进程。
因为源码的分支 case 比较繁琐,简略扰乱咱们的思路。所以,咱们先完成一个精简版的 Vite 开端入手巩固咱们的思路,终究咱们在循序渐进一步一步阅览源码。
搭建开发环境
工欲善其事,必先利其器。在着手开发之前,让咱们先花费几分钟来略微整理一下开发目录。
这儿,我创立了一个 vite
的简略目录结构:
.
├── README.md Reamdme 说明文件
├── bin
│ └── vite 环境变量脚本文件
├── package.json
└── src 源码目录
├── config.js 读取装备文件
└── server 服务文件目录
├── index.js 服务进口文件
└── middleware 中间件目录文件夹
创立了一个简略的目录文件,一同在 bin/vite
与 package.json
中的 bin
字段进行相关:
#!/usr/bin/env node
console.log('hello custom-vite!');
{
"name": "custom-vite",
// ...
"bin": {
"custom-vite": "./bin/vite"
},
// ...
}
关于 bin
字段的效果这儿我就不再赘述了,此刻当咱们在本地运转 npm link
后,在控制台履行 custom-vite
就会输出 hello custom-vite!
:
编写开发服务器
接下来,让咱们按照思维导图的次序一步一步来。
在运转 vite
指令后需求发动一个开发服务器用来承载运用项目(发动目录下)的 index.html
文件作为进口文件,那么咱们就从编译一个开发服务器开端。
首要,让咱们先来修正 Vite 指令的进口文件 /bin/vite
:
#!/usr/bin/env node
import { createServer } from '../src/server';
(async function () {
const server = await createServer();
server.listen('9999', () => {
console.log('start server');
});
})();
上边的 /bin/vite
文件中,咱们从 /src/server
中引进了一个 createServer
创立开发服务器的办法。
随后,运用了一个自履行的函数调用该 createServer
办法,一同调用 server.listen
办法将开发服务器发动到 9999
端口。
// /src/server/index.js
import connect from 'connect';
import http from 'node:http';
import staticMiddleware from './middleware/staticMiddleware.js';
import resolveConfig from '../config.js';
/**
* 创立开发服务器
*/
async function createServer() {
const app = connect(); // 创立 connect 实例
const config = await resolveConfig(); // 模仿装备清单 (类似于 vite.config.js)
app.use(staticMiddleware(config)); // 运用静态资源中间件
const server = {
async listen(port, callback) {
// 发动服务
http.createServer(app).listen(port, callback);
}
};
return server;
}
export { createServer }
咱们 /src/server/index.js
中界说了一个创立根服务器的办法: createServer
。
createServer
中首要咱们经过 connect
模块装备 nodejs http
模块创立了一个支撑中间件体系的运用服务。
connect
为nodejs http
模块供给了中间件的扩展支撑,Express 4.0 之前的中间件模块便是依据 connect 来完成的。
之后,咱们在 createServer
办法中经过 resolveConfig
办法来模仿读取一些必要的装备特点(该办法类似于从运用自身获取 vite.config.js
中的装备):
// src/utils.js
/**
* windows 下途径适配(将 windows 下途径的 // 变为 /)
* @param {*} path
* @returns
*/
function normalizePath(path) {
return path.replace(/\/g, '/');
}
export { normalizePath };
// /src/config.js
import { normalizePath } from './utils.js';
/**
* 加载 vite 装备文件
* (模仿)
*/
async function resolveConfig() {
const config = {
root: normalizePath(process.cwd()) // 仅界说一个项目根目录的回来
};
return config;
}
export default resolveConfig;
能够看到在 resolveConfig
中咱们模仿了一个 config
目标进行回来,此刻 config
目标是一个固定的途径:为调用 custom-vite
指令的 pwd 途径。
关于 root 装备项的效果,能够参考 Vite Doc Config,咱们接下来会用该字段匹配的途径来寻觅项目根进口文件(index.html
)的地点地址。
初始化装备文件后,咱们再次调用 app.use(staticMiddleware(config));
为服务运用了静态资源目录的中间件,保证运用 custom-vite 的目录下的静态资源在服务上的可拜访性。
import serveStatic from 'serve-static';
function staticMiddleware({ root }) {
return serveStatic(root);
}
export default staticMiddleware;
上边咱们运用了 serve-static 作为中间件来供给创立服务的静态资源功用。
此刻,当咱们在恣意项目中运用 custom-vite
指令时 terminal 中打印出:
一同,咱们在浏览器中输入 localhost:9999
即可拜访到咱们依据运用到的项目创立的服务。
这一步,咱们经过自己编写的 custom-vite
现已拥有一键发动开发环境的功用。
寻觅/解析 HTML 文件
在调用 custom-vite
指令现已能够发动一个简略的开发服务器后,接下来咱们就要开端为发动的开发服务器来填充对应的功用了。
了解过 Vite 的朋友都清楚,Vite 中的进口文件和其他沟通工具不同的是:vite 中是以 html
文件作为进口文件的。比方,咱们新建一个简略的项目:
.
├── index.html
├── main.js
├── ./module.js
└── package.json
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
Hello vite use
<script type="module" src="/main.js"></script>
</body>
</html>
{
"name": "custom-vite-use",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"test": "echo "Error: no test specified" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"react": "^18.2.0",
"vite": "^5.0.4"
}
}
const a = '1';
export { a };
import react from 'react';
import { a } from './module.js';
console.log(a);
console.log(react, 'react');
我已在该项目中安装了 react
和 vite
,咱们先来看看关于上边这个简略的项目原始的 vite 表现怎么。
此刻咱们在该项目目录下运转 npm run dev
指令,等待服务发动后拜访 localhost:5173
:
页面上会展现 index.html
的内容:
一同,浏览器控制台中会打印:
当然,也会打印
1
。因为module.js
是我在后续为了满足递归流程补上来的模块所以这儿的图我就不弥补了,咱们了解即可~
一同咱们观察浏览器 network 恳求:
network 中的恳求次序别离为 index.html
=> main.js
=> react.js
,这儿咱们先专心预构建进程疏忽其他的恳求以及 react.js
后边的查询参数。
当咱们翻开 main.js
查看 sourceCode 时,会发现这个文件中关于 react 的引进现已彻底更换了一个途径:
很奇特比照,前边咱们说过 vite 在发动开发服务器时关于第三方依靠会进行预构建的进程。这儿,/node_modules/.vite/deps/react.js
正是发动开发服务时 react 的预构建产品。
咱们来翻开源码目录查看下:
一切都和咱们上述说到过的进程看上去是那么的类似对吧。
发动开发服务器时,会首要依据 index.html
中的脚本剖析模块依靠,将一切项目中引进的第三方依靠(这儿为 react
) 进行预构建。
将构建后的产品存储在 .vite/deps
目录中,一同将映射联系保存在 .vite/deps/_metadata.json
中,其中 optimized
目标中的 react
表明原始依靠的进口文件而 file
则表明经过预构建后生成的产品(两者皆为相对途径)。
之后,简略来说咱们只要在开发环境下判别假如恳求的文件名命中 optimized
目标的 key
时(这儿为 react
)则直接预构建进程中生成的文件 (file
字段对应的文件途径即可)。
接下来,咱们就测验在咱们自己的 custom-vite
中来完成这一进程。
首要,让咱们从寻觅 index.html
中动身:
// /src/config.js
import { normalizePath } from './utils.js';
import path from 'path';
/**
* 加载 vite 装备文件
* (模仿)
*/
async function resolveConfig() {
const config = {
root: normalizePath(process.cwd()),
entryPoints: [path.resolve('index.html')] // 添加一个 entryPoints 文件
};
return config;
}
export default resolveConfig;
首要,咱们来修正下之前的 /src/config.js
为模仿的装备文件添加一个 entryPoints
进口文件,该文件表明 custom-vite 进行构建时的进口文件,即项目中的 index.html
文件。
// /src/server/index.js
import connect from 'connect';
import http from 'node:http';
import staticMiddleware from './middleware/staticMiddleware.js';
import resolveConfig from '../config.js';
import { createOptimizeDepsRun } from '../optimizer/index.js';
/**
* 创立开发服务器
*/
async function createServer() {
const app = connect();
const config = await resolveConfig();
app.use(staticMiddleware(config));
const server = {
async listen(port, callback) {
// 发动服务之前进行预构建
await runOptimize(config);
http.createServer(app).listen(port, callback);
}
};
return server;
}
/**
* 预构建
* @param {*} config
*/
async function runOptimize(config) {
await createOptimizeDepsRun(config);
}
export { createServer };
上边咱们关于 /src/server/index.js
中 createServer
办法进行了修正,在 listen
发动服务之前添加了 runOptimize
办法的调用。
所谓 runOptimize
办法正是在发动服务之前的预构建函数。能够看到在 runOptimize
中递归调用了一个 createOptimizeDepsRun
办法。
接下来,咱们要完成这个 createOptimizeDepsRun
办法。这个办法的中心思路正是咱们期望凭借 Esbuild 在发动开发服务器前关于整个项目进行扫描,寻觅出项目中一切的第三方依靠进行预构建。
让咱们新建一个 /src/optimizer/index.js
文件:
import { scanImports } from './scan.js';
/**
* 剖析项目中的第三方依靠
* @param {*} config
*/
async function createOptimizeDepsRun(config) {
// 经过 scanImports 办法寻觅项目中的一切需求预构建的模块
const deps = await scanImports(config);
console.log(deps, 'deps');
}
export { createOptimizeDepsRun };
// /src/optimizer/scan.js
import { build } from 'esbuild';
import { esbuildScanPlugin } from './scanPlugin.js';
/**
* 剖析项目中的 Import
* @param {*} config
*/
async function scanImports(config) {
// 保存扫描到的依靠(咱们暂时还未用到)
const desImports = {};
// 创立 Esbuild 扫描插件(这一步是中心)
const scanPlugin = await esbuildScanPlugin();
// 凭借 EsBuild 进行依靠预构建
await build({
absWorkingDir: config.root, // esbuild 当前作业目录
entryPoints: config.entryPoints, // 进口文件
bundle: true, // 是否需求打包第三方依靠,默许 Esbuild 并不会,这儿咱们声明为 true 表明需求
format: 'esm', // 打包后的格式为 esm
write: false, // 不需求将打包的成果写入硬盘中
plugins: [scanPlugin] // 自界说的 scan 插件
});
// 之后的内容咱们略微在讲,咱们先专心于上述的逻辑
}
export { scanImports };
能够看到在 /src/optimizer/scan.js
的 scanImports
办法终究调用了 esbuild build
的 build 办法进行构建。
正是在这一部分构建中,咱们运用了自己界说的 scanPlugin
Esbuild Plugin 进行扫描项目依靠,那么 esbuildScanPlugin
又是怎么完成的呢?
// /src/optimizer/scanPlugin.js
import nodePath from 'path';
import fs from 'fs-extra';
const htmlTypesRe = /(.html)$/;
const scriptModuleRe = /<scripts+type="module"s+src="(.+?)">/;
function esbuildScanPlugin() {
return {
name: 'ScanPlugin',
setup(build) {
// 引进时处理 HTML 进口文件
build.onResolve({ filter: htmlTypesRe }, async ({ path, importer }) => {
// 将传入的途径转化为肯定途径 这儿简略先写成 path.resolve 办法
const resolved = await nodePath.resolve(path);
if (resolved) {
return {
path: resolved?.id || resolved,
namespace: 'html'
};
}
});
// 当加载命名空间为 html 的文件时
build.onLoad({ filter: htmlTypesRe, namespace: 'html' }, async ({ path }) => {
// 将 HTML 文件转化为 js 进口文件
const htmlContent = fs.readFileSync(path, 'utf-8');
console.log(htmlContent, 'htmlContent'); // htmlContent 为读取的 html 字符串
const [, src] = htmlContent.match(scriptModuleRe);
console.log('匹配到的 src 内容', src); // 获取匹配到的 src 途径:/main.js
const jsContent = `import ${JSON.stringify(src)}`;
return {
contents: jsContent,
loader: 'js'
};
});
}
};
}
export { esbuildScanPlugin };
简略来说,Esbuild 在进行构建时会对每一次 import
匹配插件的 build.onResolve
钩子,匹配的规矩中心为两个参数,别离为:
-
filter
: 该字段能够传入一个正则表达式,Esbuild 会为每一次导入的途径与该正则进行匹配,假如共同则以为经过,不然则不会进行该钩子。 -
namespace
: 每个模块都有一个相关的命名空间,默许每个模块的命名空间为 file (表明文件体系),咱们能够显现声明命名空间规矩进行匹配,假如共同则以为经过,不然则不会进行该钩子。
不了解 Esbuild 相关装备和 Plugin 开发的同学能够优先移步 Esbuild 官网手册进行简略的查阅。
上述的 scanPlugin
的中心思路为:
- 当运转
build
办法时,首要进口文件地址会进入ScanPlugn
的onResolve
钩子。
此刻,因为 filter
的正则匹配为后缀为 .html
,并不存在 namespace(默以为 file
)。则此刻,index.html
会进入 ScanPlugin
的 onResolve
钩子中。
在 build.onResolve
中,咱们先将传入的 path
转化为磁盘上的肯定途径,将 html 的肯定途径进行回来,一同修正进口 html 的 namespace
为自界说的 html
。
需求留意的是假如同一个 import (导入)假如存在多个
onResolve
的话,会按照代码编写的次序进行次序匹配,假如某一个onResolve
存在回来值,那么此刻就不会往下持续履行其他onResolve
而是会进行到下一个阶段(onLoad
),Esbuild 中其他 hook 也同理。
- 之后,因为咱们在
build.onResolve
中关于进口html
文件进行了阻拦处理,在onLoad
钩子中仍然进行匹配。
onLoad
钩子中咱们的 filter
规矩相同为 htmlTypesRe
,一同添加了匹配 namespace
为 html
的导入。
此刻,咱们在上一个 onResove
回来的 namspace
为 html
的进口文件会进行该 onLoad
钩子。
build.onLoad 该钩子的主要效果加载对应模块内容,假如 onResolve 中回来
contents
内容,则 Esbuild 会将回来的contents
作为内容进行后续解析(并不会对该模块进行默许加载行为解析),不然默许会为namespace
为file
的文件进行 IO 读取文件内容。
咱们在 build.onlod
钩子中,首要依据传入的 path
读取进口文件的 html
字符串内容取得 htmlContent
。
之后,咱们依据正则关于 htmlContent
进行了截取,获取 <script type="module" src="https://juejin.im/main.js />"
中引进的 js 资源 /main.js
。
此刻,虽然咱们的进口文件为 html
文件,但是咱们经过 EsbuildPlugin 的办法从 html 进口文件中截取到了需求引进的 js 文件。
之后,咱们拼装了一个 import "/main.js"
的 jsContent
在 onLoad
钩子函数中进行回来,一同声明该资源类型为 js
。
简略来说 Esbuild 中内置部分文件类型,咱们在
plugin
的onLoad
钩子中经过回来的loader
关键字来告知 Esbuild 接下来运用哪种办法来识别这些文件。
此刻,Esbuil 会关于回来的 import "/main.js"
当作 JavaScript 文件进行递归处理,这样也就达成了咱们解析 HTML 文件的意图。
咱们来回过头略微总结下,之所以 Vite 中能够将 HTML 文件作为进口文件。
其实正是凭借了 Esbuild 插件的办法,在发动项目时运用 Esbuild 运用 HTML 作为进口文件之后运用 Plugin 截取 HTML 文件中的 script 脚本地址回来,然后寻觅到了项目真实的进口 js
资源进行递归剖析。
递归解析 js/ts 文件
上边的章节,咱们在 ScanPlugin
中别离编写了 onResolve
以及 onLoad
钩子来剖析进口 html 文件。
其实,ScanPlugin
的效果并不只是如此。这部分,咱们会持续完善 ScanPlugin
的功用。
咱们现已能够经过 HTML 文件寻觅到引进的 /main.js
了,那么接下来天然咱们需求对 js 文件进行递归剖析寻觅项目中需求被依靠预构建的一切模块。
递归寻觅需求被预构建的模块的思路相同也是经过 Esbuild 中的 Plugin 机制来完成,简略来说咱们会依据上一步转化得到的 import "/main.js"
导入来进行递归剖析。
关于 /main.js
的导入语句会分为以下两种状况别离进行不同的处理:
-
关于
/main.js
中的导入的源码部分会进入该部分进行递归剖析,比方/main.js
中假如又引进了另一个源码模块./module.js
那么此刻会持续进入./module.js
递归这一进程。 -
关于
/main.js
中导入的第三方模块会经过 Esbuild 将该模块符号为 external ,然后记录该模块的进口文件地址以及导入的模块名。
比方 /main.js
中存在 import react from 'react'
,此刻首要咱们会经过 Esbuild 疏忽进入该模块的扫描一同咱们也会记录代码中依靠的该模块相关信息。
符号为 external 后,esbuild 会以为该模块是一个外部依靠不需求被打包,所以就不会进入该模块进行任何扫描,换句话到碰到第三方模块时并不会进入该模块进行依靠剖析。
解析来咱们首要来一步一步来晚上上边的代码:
// src/optimizer/scan.js
/**
* 剖析项目中的 Import
* @param {*} config
*/
async function scanImports(config) {
// 保存依靠
const depImports = {};
// 创立 Esbuild 扫描插件
const scanPlugin = await esbuildScanPlugin(config, depImports);
// 凭借 EsBuild 进行依靠预构建
await build({
absWorkingDir: config.root,
entryPoints: config.entryPoints,
bundle: true,
format: 'esm',
write: false,
plugins: [scanPlugin]
});
return depImports;
}
首要,咱们先为 scanImports
办法添加一个 depImports 的回来值。
之后,咱们持续来完善 esbuildScanPlugin
办法:
import fs from 'fs-extra';
import { createPluginContainer } from './pluginContainer.js';
import resolvePlugin from '../plugins/resolve.js';
const htmlTypesRe = /(.html)$/;
const scriptModuleRe = /<scripts+type="module"s+src="(.+?)">/;
async function esbuildScanPlugin(config, desImports) {
// 1. Vite 插件容器体系
const container = await createPluginContainer({
plugins: [resolvePlugin({ root: config.root })],
root: config.root
});
const resolveId = async (path, importer) => {
return await container.resolveId(path, importer);
};
return {
name: 'ScanPlugin',
setup(build) {
// 引进时处理 HTML 进口文件
build.onResolve({ filter: htmlTypesRe }, async ({ path, importer }) => {
// 将传入的途径转化为肯定途径
const resolved = await resolveId(path, importer);
if (resolved) {
return {
path: resolved?.id || resolved,
namespace: 'html'
};
}
});
// 2. 额定添加一个 onResolve 办法来处理其他模块(非html,比方 js 引进)
build.onResolve({ filter: /.*/ }, async ({ path, importer }) => {
const resolved = await resolveId(path, importer);
if (resolved) {
const id = resolved.id || resolved;
if (id.includes('node_modules')) {
desImports[path] = id;
return {
path: id,
external: true
};
}
return {
path: id
};
}
});
// 当加载命名空间为 html 的文件时
build.onLoad(
{ filter: htmlTypesRe, namespace: 'html' },
async ({ path }) => {
// 将 HTML 文件转化为 js 进口文件
const htmlContent = fs.readFileSync(path, 'utf-8');
const [, src] = htmlContent.match(scriptModuleRe);
const jsContent = `import ${JSON.stringify(src)}`;
return {
contents: jsContent,
loader: 'js'
};
}
);
}
};
}
export { esbuildScanPlugin };
esbuildScanPlugin 办法新增了 createPluginContainer
和 resolvePlugin
两个办法的引进:
// src/optimizer/pluginContainer.js
import { normalizePath } from '../utils.js';
/**
* 创立 Vite 插件容器
* Vite 中正是自己完成了一套所谓的插件体系,能够完美的在 Vite 中运用 RollupPlugin。
* 简略来说,插件容器更多像是完成了一个所谓的 Adaptor,这也便是为什么 VitePlugin 和 RollupPlugin 能够相互兼容的原因
* @param plugin 插件数组
* @param root 项目根目录
*/
async function createPluginContainer({ plugins }) {
const container = {
/**
* ResolveId 插件容器办法
* @param {*} path
* @param {*} importer
* @returns
*/
async resolveId(path, importer) {
let resolved = path;
for (const plugin of plugins) {
if (plugin.resolveId) {
const result = await plugin.resolveId(resolved, importer);
if (result) {
resolved = result.id || result;
break;
}
}
}
return {
id: normalizePath(resolved)
};
}
};
return container;
}
export { createPluginContainer };
// src/plugins/resolve.js
import os from 'os';
import path from 'path';
import resolve from 'resolve';
import fs from 'fs';
const windowsDrivePathPrefixRE = /^[A-Za-z]:[/\]/;
const isWindows = os.platform() === 'win32';
// 裸包导入的正则
const bareImportRE = /^(?![a-zA-Z]:)[w@](?!.*://)/;
/**
* 这个函数的效果便是寻觅模块的进口文件
* 这块咱们简略写,源码中多了 exports、imports、main、module、yarn pnp 等等之类的判别
* @param {*} id
* @param {*} importer
*/
function tryNodeResolve(id, importer, root) {
const pkgDir = resolve.sync(`${id}/package.json`, {
basedir: root
});
const pkg = JSON.parse(fs.readFileSync(pkgDir, 'utf-8'));
const entryPoint = pkg.module ?? pkg.main;
const entryPointsPath = path.join(path.dirname(pkgDir), entryPoint);
return {
id: entryPointsPath
};
}
function withTrailingSlash(path) {
if (path[path.length - 1] !== '/') {
return `${path}/`;
}
return path;
}
/**
* path.isAbsolute also returns true for drive relative paths on windows (e.g. /something)
* this function returns false for them but true for absolute paths (e.g. C:/something)
*/
export const isNonDriveRelativeAbsolutePath = (p) => {
if (!isWindows) return p[0] === '/';
return windowsDrivePathPrefixRE.test(p);
};
/**
* 寻觅模块地点肯定途径的插件
* 既是一个 vite 插件,也是一个 Rollup 插件
* @param {*} param0
* @returns
*/
function resolvePlugin({ root }) {
// 相对途径
// window 下的 /
// 肯定途径
return {
name: 'vite:resolvePlugin',
async resolveId(id, importer) {
// 假如是 / 开头的肯定途径,一同前缀并不是在该项目(root) 中,那么 vite 会将该途径当作肯定的 url 来处理(拼接项目地点前缀)
// /foo -> /fs-root/foo
if (id[0] === '/' && !id.startsWith(withTrailingSlash(root))) {
const fsPath = path.resolve(root, id.slice(1));
return fsPath;
}
// 相对途径
if (id.startsWith('.')) {
const basedir = importer ? path.dirname(importer) : process.cwd();
const fsPath = path.resolve(basedir, id);
return {
id: fsPath
};
}
// drive relative fs paths (only windows)
if (isWindows && id.startsWith('/')) {
// 相同为相对途径
const basedir = importer ? path.dirname(importer) : process.cwd();
const fsPath = path.resolve(basedir, id);
return {
id: fsPath
};
}
// 肯定途径
if (isNonDriveRelativeAbsolutePath(id)) {
return {
id
};
}
// bare package imports, perform node resolve
if (bareImportRE.test(id)) {
// 寻觅包地点的途径地址
const res = tryNodeResolve(id, importer, root);
return res;
}
}
};
}
export default resolvePlugin;
这儿咱们来一步一步剖析上述添加的代码逻辑。
首要,咱们为 esbuildScanPlugin
额定添加了一个 build.onResolve
来匹配恣意途径文件。
关于进口的 html 文件,他会匹配咱们最开端 filter 为 htmlTypesRe
的 onResolve 勾子来处理。而关于上一步咱们从 html 文件中处理完结后的进口 js 文件(/main.js
),以及 /main.js
中的其他引进,比方 ./module.js
文件并不会匹配 htmlTypesRe
的 onResolve 钩子则会持续走到咱们新增的 /.*/
的 onResolve 钩子匹配中。
仔细的朋友们会留意到上边代码中,咱们把之前 onResolve
钩子中的 path.resolve
办法变成了 resolveId(path, importer)
办法。
所谓的 resolveId 则是经过在 esbuildScanPlugin
中首要创立了一个 pluginContainer
容器,之后声明的 resolveId
办法正是调用了咱们创立的 pluginContainer
容器的 resolveId
办法。(src/optimizer/pluginContainer.js
)。
咱们要了解 pluginContainer 的概念,首要要清楚在 Vite 中实践上在开发环境会运用 Esbuild 进行预构建在出产环境上运用 Rollup 进行打包构建。
通常,咱们会在 vite 中运用一些 vite 自身的插件也能够直接运用 rollup 插件,这正是 pluginContainer 的效果。
Vite 中会在进行文件转译时经过创立一个所谓的 pluginContainer 然后在 pluginContainer 中运用一个类似于 Adaptor 的概念。
它会在开发/出产环境下关于文件的导入调用 pluginContainer.resolveId 办法,而 pluginContainer.resolveId 办法则会顺次调用装备的 vite 插件/Rollup 插件的 ResolveId 办法。
其实你会发现 VitePlugin 和 RollupPlugin 的结构是十分类似的,仅有不同的是 VitePlugin 会比 RollupPlugin 多了一些额定的生命周期(钩子)以及相关 context 特点。
当然,开发环境下关于文件的转译(比方 tsx
、vue
等文件的转译)正是经过 pluginContainer 来完结的,这篇文章重点在于预构建的进程所以咱们先不关于其他方面进行拓宽。
上述 esbuildScanPlugin
会回来一个 Esbuild 插件,然后咱们在 Esbuild 插件的 build.onResolve
钩子中实践调用的是 pluginContainer.resolveId
来处理。
其实这便是相当于咱们在 Esbuild 的预构建进程中调用了 VitePlugin。
一同,咱们在调用 createPluginContainer
办法时传入了一个默许的 resolvePlugin
,所谓的 resolvePlugin 留意是一个 Vite 插件。
resolvePlugin(src/plugins/resolve.js
) 的效果便是经过传入的 path
以及 importer
获取去引进模块在磁盘上的肯定途径。
源码中
resolvePlugin
鸿沟处理较多,比方虚拟导入语句的处理,yarn pnp、symbolic link 等一系列鸿沟场景处理,这儿我略微做了简化,咱们清楚该插件是一个内置插件用来寻觅模块肯定途径的即可。
天然,在当调用 custom-vite
指令后:
-
首要会创立
pluginContainer
,这个容器是 vite 内置完成的插件体系。 -
之后,Esbuild 会关于进口 html 文件进行处理调用 scanPlugin 的第一个 onResolve 钩子。
-
在第一个 onResolve 钩子因为 html 会匹配
htmlTypesRe
的正则所以进入该钩子。该 onResolve 办法会调用 Vite 插件容器(pluginContainer
)的resolvedId
办法,经过 Esbuild 插件的 onResolve 来调用 Vite 插件的 ResolveId 办法,然后取得 html 进口文件的肯定途径。 -
之后在 Esbuild 的 onLoad 办法中截取该 html 中的
script
标签上的src
作为模块回来值(js 类型)交给 Esbuild 持续处理(import "/main.js"
)。 -
在之后,Esbuild 会处理
"/main.js"
的引进,因为第一个 onResolve 现已不匹配所以会进入第二个 onResolve 钩子,此刻会进行相同的进程调用 VitePlugin 取得该模块在磁盘上的肯定途径。
咱们会判别回来的途径是否包括 node_modules
,假如包括则以为它是一个第三方模块依靠。
此刻,咱们会经过 esBuild 将该模块符号为 external: true
疏忽进行该模块内部进行剖析,一同在 desImports
中记录该模块的导入名以及肯定途径。
假如为一个非第三方模块,比方 /main.js
中引进的 ./module.js
,那么此刻咱们会经过 onResolve 回来该模块在磁盘上的肯定途径。
Esbuild 会持续进入插件的 onLoad 进行匹配,因为 onLoad 的 filter 以及 namesapce 均为 htmlTypesRe 所以并不匹配,默许 Esbuild 会在文件体系中寻觅该文件地址依据文件后缀名称进行递归剖析。
这样,终究就达到了咱们想要的成果。当咱们在 vite-use
(测验项目中) 调用 custom-vite
指令,会发现控制台中会打印:
此刻
depImports
中现已记录了咱们在源码中引进的第三方依靠。
生成预构建产品
上边的进程咱们凭借 Esbuild 以及 scanPlugin 现已能够在发动 Vite 服务之前完结依靠扫描取得源码中的一切第三方依靠模块。
接下来咱们需求做的,正是关于刚刚获取到的 deps 目标中的第三方模块进行构建输出经过预构建后的文件以及一份财物清单 _metadata.json
文件。
首要,咱们先关于 src/config.js
装备文件进行简略的修正:
import { normalizePath } from './utils.js';
import path from 'path';
import resolve from 'resolve';
/**
* 寻觅地点项目目录(实践源码中该函数是寻觅传入目录地点最近的包相关信息)
* @param {*} basedir
* @returns
*/
function findNearestPackageData(basedir) {
// 原始发动目录
const originalBasedir = basedir;
const pckDir = path.dirname(resolve.sync(`${originalBasedir}/package.json`));
return path.resolve(pckDir, 'node_modules', '.custom-vite');
}
/**
* 加载 vite 装备文件
* (模仿)
*/
async function resolveConfig() {
const config = {
root: normalizePath(process.cwd()),
cacheDir: findNearestPackageData(normalizePath(process.cwd())), // 添加一个 cacheDir 目录
entryPoints: [path.resolve('index.html')]
};
return config;
}
export default resolveConfig;
咱们关于 config.js 中的 config 装备进行了修正,简略添加了一个 cacheDir
的装备目录。
这个目录是用于当生成预构建文件后的存储目录,这儿咱们固定写死为当前项目地点的 node_modules 下的 .custom-vite
目录。
之后,咱们在回到 src/optimizer/index.js
中稍做修正:
// src/optimizer/index.js
import path from 'path';
import fs from 'fs-extra';
import { scanImports } from './scan.js';
import { build } from 'esbuild';
/**
* 剖析项目中的第三方依靠
* @param {*} config
*/
async function createOptimizeDepsRun(config) {
const deps = await scanImports(config);
// 创立缓存目录
const { cacheDir } = config;
const depsCacheDir = path.resolve(cacheDir, 'deps');
// 创立缓存目标 (_metaData.json)
const metadata = {
optimized: {}
};
for (const dep in deps) {
// 获取需求被依靠预构建的目录
const entry = deps[dep];
metadata.optimized[dep] = {
src: entry, // 依靠模块进口文件(相对途径)
file: path.resolve(depsCacheDir, dep + '.js') // 预编译后的文件(肯定途径)
};
}
// 将缓存文件写入文件体系中
await fs.ensureDir(depsCacheDir);
await fs.writeFile(
path.resolve(depsCacheDir, '_metadata.json'),
JSON.stringify(
metadata,
(key, value) => {
if (key === 'file' || key === 'src') {
// 留意写入的是相对途径
return path.relative(depsCacheDir, value);
}
return value;
},
2
)
);
// 依靠预构建
await build({
absWorkingDir: process.cwd(),
define: {
'process.env.NODE_ENV': '"development"'
},
entryPoints: Object.keys(deps),
bundle: true,
format: 'esm',
splitting: true,
write: true,
outdir: depsCacheDir
});
}
export { createOptimizeDepsRun };
在 src/optimizer/index.js
中,之前咱们现现已过 scanImports 办法拿到了 deps 目标:
{
react: '/Users/ccsa/Desktop/custom-vite-use/node_modules/react/index.js'
}
然后,咱们冲 config 目标中拿到了 depsCacheDir 拼接上 deps
目录,得到的是存储预构建资源的目录。
一同创立了一个名为 metadata 的目标,遍历生成的 deps 为 metadata.optimize 顺次赋值,经过 for of 循环后一切需求经过依靠预构建的资源悉数存储在 metadata.optimize
目标中,这个目标的结构如下:
{
optimized: {
react: {
src: "/Users/ccsa/Desktop/custom-vite-use/node_modules/react/index.js",
file: "/Users/ccsa/Desktop/custom-vite-use/node_modules/.custom-vite/deps/react.js",
},
},
}
需求留意的是,咱们在内存中存储的
optimize
悉数为肯定途径,而写入硬盘时的途径悉数为相对途径。
之后相同咱们运用 Esbuild 再次对应项目中的一切第三方依靠进行构建打包。不过不同的是这一步咱们符号 write:true
是需求将构建后的文件写入硬盘中的。
完结上述进程后,咱们再次在运用到的项目中 custom-vite-use
中运转 custom-vite
指令:
此刻,咱们现已完成了一个简易版别的 Vite 预构建进程。
之后,发动开发服务器后 Vite 实践会在开发服务器中关于第三方模块的恳求进行阻拦然后回来预构建后的资源。
至于 Vite 是怎么阻拦第三方资源以及在是怎么在 ESM 源生模块下是怎么处理 .vue/.ts/.tsx
等等之类的模块转译我会在后续的 Vite 文章中和咱们持续进行揭密。
文章中的代码你能够在这儿找到。
Vite 源码
上边的章节中咱们现已自己完成了一个简易的 Vite 预构建进程,接下来我会用上述预构建的进程和源码进行逐个对照。
Cli 指令文件
Vite 源码结构为 monorepo
结构,这儿咱们只是关怀 vite 目录即可。
首要,Vite 目录下的 /pakcages/vite/bin/vite.js 文件是作为项目 cli 进口文件。
实践当运转 vite 指令时会履行该文件,履行该文件会经过以下调用链:
-
履行
/vite/src/node/cli.ts
文件处理一系列指令行参数。 -
处理结束后再次调用
/vite/src/node/server/index.ts
创立开发服务器。
createServer 办法
当运转一次 Vite 指令后会履行到 /vite/src/node/server/index.ts
中的 createServer
办法。
实践 createServer 就和我咱们上述的 createServer 代表的含义是共同的,都是在开发环境下发动开发服务器。
实践上大多数流程和咱们上述的代码思路是共同的,比方
resolveConfig
以及serveStaticMiddleware
之类。
依靠预构建
在 createServer 办法的下半部分中,咱们能够看到:
- container.buildStart
所谓 container.buildStart 正是咱们之前说到过的 vite 内部有一套自己的插件容器。vite 正是经过这一套插件容器来处理开发形式和出产形式的区别。
container 插件容器会完成一套和 Rollup 如出一辙的插件 API,所以 Rollup Plugin 相同也能够经过 container Api 在开发形式下调用。
天然,出产形式下自身就运用 Rollup 进行构建,所以能够完成出产百分百的插件兼容。
- initDepsOptimizer
initDepsOptimizer 正是在发动开发服务器之前进行依靠预构建的中心办法。
initDepsOptimizer
initDepsOptimizer 会调用 createDepsOptimizer 办法。
createDepsOptimizer 办法在开发形式下(!isBuild):
discoverProjectDependencies 正如姓名那样,这个办法是发现项目中的第三方依靠(依靠扫描)。
discoverProjectDependencies 内部会调用 scanImports 办法:
编辑器左边部分为 scanImports 办法,他会回来 prepareEsbuildScanner 办法的回来值。
而 prepareEsbuildScanner 正是和咱们上述思路共同的依靠扫描:凭借 Esbuild 以及 esbuildScanPlugin 扫描项目中的第三方依靠。
终究 createDepsOptimizer 办法中会用 deps
保存 discoverProjectDependencies 办法的项目中扫描到的一切第三方依靠。
这儿有两点需求留意。
-
首要 discoverProjectDependencies 寻觅到的 react 实践地址是一个
"/Users/ccsa/Desktop/custom-vite-use/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js"
的值,这是因为安装依靠时我运用的是 pnpm ,而Vite 中关于 Symbolic link 有处理,而咱们上边的代码比较简易并没有处理 Symbolic link。 -
下图中能够看到 prepareEsbuildScanner 办法又创立了一个 pluginContainer。
Vite 中的 pluginContianer 并不是一个单例,Vite 中会多次调用 createPluginContainer 创立多个插件容器。
在 prepareEsbuildScanner 在与预构建进程相同会创立一个插件容器,这正是咱们上述简易版 Vite 中创立的插件容器。
这儿咱们只要理解 pluginContainer 在 vite 中不是一个单例即可,后续在编译文件的文章中咱们会着重来学习 pluginContainer 的概念。
runOptimizeDeps
上述的进程 Vite 现已能够经过 discoverProjectDependencies 拿到项目中的需求进行预构建的文件。
之后,createDepsOptimizer 办法中会运用 prepareKnownDeps
办法处理拿到的依靠(添加 hash 等):
然后将 prepareKnownDeps 回来的 knownDeps 交给 runoptimizeDeps 进行处理:
runOptimizeDeps 办法内部会调用 prepareEsbuildOptimizerRun。
prepareEsbuildOptimizerRun 办法正是运用 EsBuild 关于前一步扫描生成的依靠进行预构建的办法:
当 context 准备结束后,prepareEsbuildOptimizerRun 会调用 rebuild
办法进行打包(生成预构建产品):
当 rebuild 运转结束后,咱们会发现 node_modules 下的预构建文件也会生成了:
Vite 源码中关于鸿沟处理的 case 特别说,实话说笔者也并没有逐行阅览。
这儿的源码部分更多是想起到一个抛砖引玉的效果,期望咱们能够在了解预构建的基础思路后能够跟从源码自己手动 debugger 调试一下。
结尾
Vite 中依靠预构建截止这儿现已给咱们共享结束了,期望文章中的内容能够帮助到咱们。
之后我仍会在专栏中共享关于 Vite 中其他进阶内容,比方 Vite 开发环境下的文件转译、热重载以及怎么在出产环境下的调用进程。
有爱好的小伙伴能够重视我的专栏浅显易懂 Vite,咱们假如关于文章中的内容有疑惑咱们也能够在评论区持续沟通。