本文基于vite 4.3.0-beta.1
版本的源码进行分析
文章内容
vite
本地服务器的创建流程分析vite
预构建流程分析vite
middlewares拦截请求资源分析vite
热更新HMR流程分析
1. 入口npm run dev
在项目的package.json
中注册对应的scripts
命令,当我们运行npm run dev
时,本质就是运行了vite
{
"scripts": {
"dev": "vite",
}
}
而vite
命令是在哪里注册的呢?
在node_modules/vite/package.json
中
{
"bin": {
"vite": "bin/vite.js"
}
}
在node_modules/vite/bin/vite.js
中
#!/usr/bin/env node
const profileIndex = process.argv.indexOf('--profile')
function start() {
return import('../dist/node/cli.js')
}
if (profileIndex > 0) {
//...
} else {
start()
}
最终调用的是打包后的dist/node/cli.js
文件
处理用户的输入后,调用./chunks/dep-f365bad6.js
的createServer()
方法,如下面所示,最终调用server.listen()
const { createServer } = await import('./chunks/dep-f365bad6.js').then(function (n) { return n.G; });
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
optimizeDeps: { force: options.force },
server: cleanOptions(options),
});
await server.listen();
createServer()
async function createServer(inlineConfig = {}) {
const config = await resolveConfig(inlineConfig, 'serve');
if (isDepsOptimizerEnabled(config, false)) {
// start optimizer in the background, we still need to await the setup
await initDepsOptimizer(config);
}
const { root, server: serverConfig } = config;
const httpsOptions = await resolveHttpsConfig(config.server.https);
const { middlewareMode } = serverConfig;
const resolvedWatchOptions = resolveChokidarOptions(config, {
disableGlobbing: true,
...serverConfig.watch,
});
const middlewares = connect();
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions);
const ws = createWebSocketServer(httpServer, config, httpsOptions);
const watcher = chokidar.watch(
// config file dependencies and env file might be outside of root
[root, ...config.configFileDependencies, path$o.join(config.envDir, '.env*')], resolvedWatchOptions);
const moduleGraph = new ModuleGraph((url, ssr) => container.resolveId(url, undefined, { ssr }));
const server = {
config,
middlewares,
httpServer,
watcher,
pluginContainer: container,
ws,
moduleGraph,
...
};
const initServer = async () => {
if (serverInited)
return;
if (initingServer)
return initingServer;
initingServer = (async function () {
await container.buildStart({});
initingServer = undefined;
serverInited = true;
})();
return initingServer;
};
if (!middlewareMode && httpServer) {
// overwrite listen to init optimizer before server start
const listen = httpServer.listen.bind(httpServer);
httpServer.listen = (async (port, ...args) => {
try {
await initServer();
}
catch (e) {
httpServer.emit('error', e);
return;
}
return listen(port, ...args);
});
}
else {
await initServer();
}
return server;
}
createServer()
源码太长,下面将分为多个小点进行分析,对于一些不是该点分析的代码将直接省略:
- 创建本地node服务器
- 预构建
- 请求资源拦截
- 热更新HMR
createServe思维导图
2. 创建本地node服务器
// 只保留本地node服务器的相关代码
async function createServer(inlineConfig = {}) {
// 创建http请求
const middlewares = connect();
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions);
const server = {
config,
middlewares,
httpServer,
watcher,
pluginContainer: container
...,
async listen(port, isRestart) {
await startServer(server, port);
if (httpServer) {
server.resolvedUrls = await resolveServerUrls(httpServer, config.server, config);
if (!isRestart && config.server.open)
server.openBrowser();
}
return server;
}
}
const initServer = async () => {
if (serverInited)
return;
if (initingServer)
return initingServer;
initingServer = (async function () {
await container.buildStart({});
initingServer = undefined;
serverInited = true;
})();
return initingServer;
};
//...
await initServer();
return server;
}
上面代码蕴含着多个知识点,我们下面将展开分析
2.1 connect()创建http服务器
Connect模块介绍
Connect是一个Node.js
的可扩展HTTP服务框架,用于将各种"middleware"
粘合在一起以处理请求
var app = connect();
app.use(function middleware1(req, res, next) {
// middleware 1
next();
});
app.use(function middleware2(req, res, next) {
// middleware 2
next();
});
var connect = require('connect');
var http = require('http');
var app = connect();
// gzip/deflate outgoing responses
var compression = require('compression');
app.use(compression());
// respond to all requests
app.use(function(req, res){
res.end('Hello from Connect!\n');
});
//create node.js http server and listen on port
http.createServer(app).listen(3000);
源码分析
会先使用connect()
创建middlewares,然后将middlewares作为app属性名传入到resolveHttpServer()
中
最终也是使用Node.js
的Http
模块创建本地服务器
// 只保留本地node服务器的相关代码
async function createServer(inlineConfig = {}) {
const middlewares = connect();
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions);
//...
}
async function resolveHttpServer({ proxy }, app, httpsOptions) {
if (!httpsOptions) {
const { createServer } = await import('node:http');
return createServer(app);
}
// #484 fallback to http1 when proxy is needed.
if (proxy) {
const { createServer } = await import('node:https');
return createServer(httpsOptions, app);
}else {
const { createSecureServer } = await import('node:http2');
return createSecureServer({
// Manually increase the session memory to prevent 502 ENHANCE_YOUR_CALM
// errors on large numbers of requests
maxSessionMemory: 1000,
...httpsOptions,
allowHTTP1: true,
},
// @ts-expect-error TODO: is this correct?
app);
}
}
2.2 启动http服务器
在dist/node/cli.js
文件的分析中,我们知道
在创建server
完成后,我们会调用server.listen()
// dist/node/cli.js
const { createServer } = await import('./chunks/dep-f365bad6.js').then(function (n) { return n.G; });
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
optimizeDeps: { force: options.force },
server: cleanOptions(options),
});
await server.listen();
而server.listen()
最终调用的也是Node.js
的Http
模块的监听方法,即上面Connect
模块介绍示例中的http.createServer(app).listen(3000)
async function createServer(inlineConfig = {}) {
const middlewares = connect();
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions);
const server = {
httpServer,
//...
async listen(port, isRestart) {
await startServer(server, port);
if (httpServer) {
server.resolvedUrls = await resolveServerUrls(httpServer, config.server, config);
if (!isRestart && config.server.open)
server.openBrowser();
}
return server;
}
};
}
async function startServer(server, inlinePort) {
const httpServer = server.httpServer;
//...
await httpServerStart(httpServer, {
port,
strictPort: options.strictPort,
host: hostname.host,
logger: server.config.logger,
});
}
async function httpServerStart(httpServer, serverOptions) {
let { port, strictPort, host, logger } = serverOptions;
return new Promise((resolve, reject) => {
httpServer.listen(port, host, () => {
httpServer.removeListener('error', onError);
resolve(port);
});
});
}
3. 预构建
3.1 预构建的原因
CommonJS 和 UMD 兼容性
在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。
在转换 CommonJS 依赖项时,Vite 会进行智能导入分析,这样即使模块的导出是动态分配的(例如 React),具名导入(named imports)也能正常工作:
// 符合预期
import React, { useState } from 'react'
性能
为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。
有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es
有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es'
时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。
通过将 lodash-es
预构建成单个模块,现在我们只需要一个HTTP请求!
注意
依赖预构建仅适用于开发模式,并使用esbuild
将依赖项转换为 ES 模块。在生产构建中,将使用@rollup/plugin-commonjs
3.2 预构建整体流程(流程图)
接下来会根据流程图的核心流程进行源码分析
3.3 预构建整体流程(源码整体概述)
Vite 会将预构建的依赖缓存到 node_modules/.vite。它根据几个源来决定是否需要重新运行预构建步骤:
- 包管理器的 lockfile 内容,例如 package-lock.json,yarn.lock,pnpm-lock.yaml,或者 bun.lockb
- 补丁文件夹的修改时间
- 可能在 vite.config.js 相关字段中配置过的
- NODE_ENV 中的值
只有在上述其中一项发生更改时,才需要重新运行预构建。
我们会先检测是否有预构建的缓存,如果没有缓存,则开始预构建:发现文件依赖并存放于deps
,然后将deps
打包到node_modules/.vite
中
async function createServer(inlineConfig = {}) {
const config = await resolveConfig(inlineConfig, 'serve');
if (isDepsOptimizerEnabled(config, false)) {
// start optimizer in the background, we still need to await the setup
await initDepsOptimizer(config);
}
//...
}
async function initDepsOptimizer(config, server) {
if (!getDepsOptimizer(config, ssr)) {
await createDepsOptimizer(config, server);
}
}
async function createDepsOptimizer(config, server) {
// 第一步:3.4获取缓存
const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr);
// 第二步:3.5没有缓存时进行依赖扫描
const deps = {};
discover = discoverProjectDependencies(config);
const deps = await discover.result;
// 第三步:3.6没有缓存时进行依赖扫描,然后进行依赖打包到node_modules/.vite
optimizationResult = runOptimizeDeps(config, knownDeps);
}
3.4 获取缓存loadCachedDepOptimizationMetadata
async function createDepsOptimizer(config, server) {
// 第一步:3.4获取缓存
const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr);
if (!cachedMetadata) {
// 第二步:3.5没有缓存时进行依赖扫描
discover = discoverProjectDependencies(config);
const deps = await discover.result;
// 第三步:3.6依赖扫描后进行打包runOptimizeDeps(),存储到node_modules/.vite
optimizationResult = runOptimizeDeps(config, knownDeps);
}
}
async function loadCachedDepOptimizationMetadata(config, ssr, force = config.optimizeDeps.force, asCommand = false) {
const depsCacheDir = getDepsCacheDir(config, ssr);
if (!force) {
// 3.4.1 获取_metadata.json文件数据
let cachedMetadata;
const cachedMetadataPath = path$o.join(depsCacheDir, '_metadata.json');
cachedMetadata = parseDepsOptimizerMetadata(await fsp.readFile(cachedMetadataPath, 'utf-8'), depsCacheDir);
// 3.4.2 比对hash值
if (cachedMetadata && cachedMetadata.hash === getDepHash(config, ssr)) {
return cachedMetadata;
}
}
// 3.4.3 清空缓存
await fsp.rm(depsCacheDir, { recursive: true, force: true });
}
3.4.1 获取_metadata.json文件数据
通过getDepsCacheDir()
获取node_modules/.vite/deps
的缓存目录,然后拼接_metadata.json
数据,读取文件并且进行简单的整理parseDepsOptimizerMetadata()
后形成校验缓存是否过期的数据
const depsCacheDir = getDepsCacheDir(config, ssr);
let cachedMetadata;
const cachedMetadataPath = path$o.join(depsCacheDir, '_metadata.json');
cachedMetadata = parseDepsOptimizerMetadata(await fsp.readFile(cachedMetadataPath, 'utf-8'), depsCacheDir);
下面metadata
的数据结构是在_metadata.json
数据结构的基础上叠加一些数据
function parseDepsOptimizerMetadata(jsonMetadata, depsCacheDir) {
const { hash, browserHash, optimized, chunks } = JSON.parse(jsonMetadata, (key, value) => {
if (key === 'file' || key === 'src') {
return normalizePath$3(path$o.resolve(depsCacheDir, value));
}
return value;
});
if (!chunks ||
Object.values(optimized).some((depInfo) => !depInfo.fileHash)) {
// outdated _metadata.json version, ignore
return;
}
const metadata = {
hash,
browserHash,
optimized: {},
discovered: {},
chunks: {},
depInfoList: [],
};
//...处理metadata
return metadata;
}
3.4.2 比对hash值
if (cachedMetadata && cachedMetadata.hash === getDepHash(config, ssr)) {
return cachedMetadata;
}
最终生成预构建缓存时,_metadata.json
中的hash
是如何计算的?是根据什么文件得到的hash值?
getDepHash()
的逻辑也不复杂,主要的流程为:
- 先进行
lockfileFormats[i]
文件是否存在的检测,比如存在yarn.lock
,那么就直接返回yarn.lock
,赋值给content
- 检测是否存在
patches
文件夹,进行content += stat.mtimeMs.toString()
- 将一些配置数据进行
JSON.stringify()
添加到content
的后面 - 最终使用
content
形成对应的hash
值,返回该hash
getDepHash()
的逻辑总结下来就是:
- 包管理器的锁文件内容,例如 package-lock.json,yarn.lock,pnpm-lock.yaml,或者 bun.lockb
- 补丁文件夹的修改时间
- vite.config.js 中的相关字段
- NODE_ENV 的值
只有在上述其中一项发生更改时,
hash
才会发生变化,才需要重新运行预构建
const lockfileFormats = [
{ name: 'package-lock.json', checkPatches: true },
{ name: 'yarn.lock', checkPatches: true },
{ name: 'pnpm-lock.yaml', checkPatches: false },
{ name: 'bun.lockb', checkPatches: true },
];
const lockfileNames = lockfileFormats.map((l) => l.name);
function getDepHash(config, ssr) {
// 第一部分:获取配置文件初始化content
const lockfilePath = lookupFile(config.root, lockfileNames);
let content = lockfilePath ? fs$l.readFileSync(lockfilePath, 'utf-8') : '';
// 第二部分:检测是否存在patches文件夹,增加content的内容
if (lockfilePath) {
//...
const fullPath = path$o.join(path$o.dirname(lockfilePath), 'patches');
const stat = tryStatSync(fullPath);
if (stat?.isDirectory()) {
content += stat.mtimeMs.toString();
}
}
// 第三部分:将配置添加到content的后面
const optimizeDeps = getDepOptimizationConfig(config, ssr);
content += JSON.stringify({
mode: process.env.NODE_ENV || config.mode,
//...
});
return getHash(content);
}
function getHash(text) {
return createHash$2('sha256').update(text).digest('hex').substring(0, 8);
}
拿到getDepHash()
计算得到的hash
,跟目前node_modules/.vite/deps/_metadata.json
的hash
属性进行比对,如果一样说明预构建缓存没有任何改变,无需重新预构建,直接使用上次预构建缓存即可
下面是_metadata.json
的示例
{
"hash": "2b04a957",
"browserHash": "485313cf",
"optimized": {
"lodash-es": {
"src": "../../lodash-es/lodash.js",
"file": "lodash-es.js",
"fileHash": "d69f60c8",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "98c38b51",
"needsInterop": false
}
},
"chunks": {}
}
3.4.3 清空缓存
如果缓存过期或者带了force=true
参数,代表缓存不可用,使用fsp.rm
清空缓存文件夹
"dev": "vite --force"
代表不使用缓存
await fsp.rm(depsCacheDir, { recursive: true, force: true });
3.5 没有缓存时进行依赖扫描discoverProjectDependencies()
async function createDepsOptimizer(config, server) {
// 第一步:3.4获取缓存
const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr);
if (!cachedMetadata) {
// 第二步:3.5没有缓存时进行依赖扫描
discover = discoverProjectDependencies(config);
const deps = await discover.result;
// 第三步:3.6依赖扫描后进行打包runOptimizeDeps(),存储到node_modules/.vite
optimizationResult = runOptimizeDeps(config, knownDeps);
}
}
function discoverProjectDependencies(config) {
const { cancel, result } = scanImports(config);
return {
cancel,
result: result.then(({ deps, missing }) => {
const missingIds = Object.keys(missing);
return deps;
}),
};
}
discoverProjectDependencies
实际调用就是scanImports
function scanImports(config) {
// 3.5.1 计算入口文件computeEntries
const esbuildContext = computeEntries(config).then((computedEntries) => {
entries = computedEntries;
// 3.5.2 打包入口文件esbuild插件初始化
return prepareEsbuildScanner(config, entries, deps, missing, scanContext);
});
// 3.5.3 开始打包
const result = esbuildContext
.then((context) => {...}
return {result, cancel}
}
3.5.1 计算入口文件computeEntries()
由官方文档关于optimizeDeps.entries可以知道,
- 默认情况下,Vite 会抓取你的
index.html
来检测需要预构建的依赖项(忽略node_modules
、build.outDir
、__tests__
和coverage
) - 如果指定了
build.rollupOptions?.input
,即在vite.config.js
中配置rollupOptions
参数,指定了入口文件,Vite 将转而去抓取这些入口点 - 如果这两者都不合你意,则可以使用
optimizeDeps.entries
指定自定义条目——该值需要遵循 fast-glob 模式 ,或者是相对于 Vite 项目根目录的匹配模式数组,可以简单理解为入口文件匹配的正则表达式,可以进行多个文件类型的匹配
如果使用
optimizeDeps.entries
,注意默认只有node_modules
和build.outDir
文件夹会被忽略。如果还需忽略其他文件夹,你可以在模式列表中使用以 ! 为前缀的、用来匹配忽略项的模式optimizeDeps.entries
具体的示例如下所示,详细可以参考 fast-glob 模式
- file-{1..3}.js — matches files: file-1.js, file-2.js, file-3.js.
- file-(1|2) — matches files: file-1.js, file-2.js.
本文中我们将直接使用默认的模式,也就是globEntries('**/*.html', config)
进行分析,会直接匹配到index.html
入口文件
function computeEntries(config) {
let entries = [];
const explicitEntryPatterns = config.optimizeDeps.entries;
const buildInput = config.build.rollupOptions?.input;
if (explicitEntryPatterns) {
entries = await globEntries(explicitEntryPatterns, config);
} else if (buildInput) {
const resolvePath = (p) => path$o.resolve(config.root, p);
if (typeof buildInput === 'string') {
entries = [resolvePath(buildInput)];
} else if (Array.isArray(buildInput)) {
entries = buildInput.map(resolvePath);
} else if (isObject$2(buildInput)) {
entries = Object.values(buildInput).map(resolvePath);
}
} else {
entries = await globEntries('**/*.html', config);
}
entries = entries.filter((entry) => isScannable(entry) && fs$l.existsSync(entry));
return entries;
}
3.5.2 打包入口文件esbuild插件初始化prepareEsbuildScanner
在上面的分析中,我们执行完3.5.1
步骤的computeEntries()
后,会执行prepareEsbuildScanner()
的插件准备工作
function scanImports(config) {
// 3.5.1 计算入口文件computeEntries
const esbuildContext = computeEntries(config).then((computedEntries) => {
entries = computedEntries;
// 3.5.2 打包入口文件esbuild插件初始化
return prepareEsbuildScanner(config, entries, deps, missing, scanContext);
});
// 3.5.3 开始打包
const result = esbuildContext
.then((context) => {...}
return {result, cancel}
}
下面将会prepareEsbuildScanner()
的流程展开分析
在计算出入口文件后,后面就是启动esbuild
插件进行打包,由于打包流程涉及的流程比较复杂,我们在3.5的分析中,只会分析预构建相关的流程部分:
- 先进行了
vite
插件的初始化:container = createPluginContainer()
- 然后将
vite
插件container
作为参数传递到esbuild
插件中,后续逻辑需要使用container
提供的一些能力 - 最终进行
esbuild
打包的初始化,使用3.5.1 计算入口文件computeEntries
拿到的入口文件作为stdin
,即esbuild
的input
,然后将刚刚注册的plugin
放入到plugins
属性中
esbuild相关知识点可以参考【基础】esbuild使用详解或者官方文档
async function prepareEsbuildScanner(config, entries, deps, missing, scanContext) {
// 第一部分: container初始化
const container = await createPluginContainer(config);
if (scanContext?.cancelled)
return;
// 第二部分: esbuildScanPlugin()
const plugin = esbuildScanPlugin(config, container, deps, missing, entries);
const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {};
return await esbuild.context({
absWorkingDir: process.cwd(),
write: false,
stdin: {
contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
loader: 'js',
},
bundle: true,
format: 'esm',
logLevel: 'silent',
plugins: [...plugins, plugin],
...esbuildOptions,
});
}
第一部分createPluginContainer
插件管理类container初始化
async function createServer(inlineConfig = {}) {
const config = await resolveConfig(inlineConfig, 'serve');
// config包含了plugins这个属性
const container = await createPluginContainer(config, moduleGraph, watcher);
}
// ======= 初始化所有plugins =======start
function resolveConfig() {
resolved.plugins = await resolvePlugins(resolved, prePlugins, normalPlugins, postPlugins);
return resolved;
}
async function resolvePlugins(config, prePlugins, normalPlugins, postPlugins) {
return [
resolvePlugin({ ...}),
htmlInlineProxyPlugin(config),
cssPlugin(config),
...
].filter(Boolean);
}
// ======= 初始化所有plugins =======end
function createPluginContainer(config, moduleGraph, watcher) {
const { plugins, logger, root, build: { rollupOptions }, } = config;
const { getSortedPluginHooks, getSortedPlugins } = createPluginHookUtils(plugins);
const container = {
async resolveId() {
//...使用了getSortedPlugins()这个方法,这个方法里有plugins
}
}
return container;
}
function createPluginHookUtils(plugins) {
function getSortedPlugins(hookName) {
if (sortedPluginsCache.has(hookName))
return sortedPluginsCache.get(hookName);
// 根据hookName,即对象属性名,拼接对应的key-value的plugin
const sorted = getSortedPluginsByHook(hookName, plugins);
sortedPluginsCache.set(hookName, sorted);
return sorted;
}
}
function getSortedPluginsByHook(hookName, plugins) {
const pre = [];
const normal = [];
const post = [];
for (const plugin of plugins) {
const hook = plugin[hookName];
if (hook) {
//...pre.push(plugin)
//...normal.push(plugin)
//...post.push(plugin)
}
}
return [...pre, ...normal, ...post];
}
如上面代码所示,在createServer()
->resolveConfig()
->resolvePlugins()
的流程中,会进行vite
插件的注册
vite
插件具体有什么呢?
所有的插件都放在vite
源码的src/node/plugins/**
中,每一个插件都会有对应的name
,比如下面这个插件vite:css
常用方法container.resolveId()
从上面插件初始化的分析中,我们可以知道,getSortedPlugins('resolveId')
就是检测该插件是否有resolveId
这个属性,如果有,则添加到返回的数组集合中,比如有10个插件中有5个插件具有resolveId
属性,那么最终getSortedPlugins('resolveId')
拿到的就是这5个插件的Array数据
因此container.resolveId()
中运行插件的个数不止一个,但并不是每一个插件都能返回对应的结果result
,即const result = await handler.call(...)
可能为undefined
当有插件处理后result
不为undefined
时,会直接执行break
,然后返回container.resolveId()
的结果
//getSortedPlugins最终调用的就是getSortedPluginsByHook
function getSortedPluginsByHook(hookName, plugins) {
const pre = [];
const normal = [];
const post = [];
for (const plugin of plugins) {
const hook = plugin[hookName];
if (hook) {
//...pre.push(plugin)
//...normal.push(plugin)
//...post.push(plugin)
}
}
return [...pre, ...normal, ...post];
}
async resolveId(rawId, importer = join$2(root, 'index.html'), options) {
for (const plugin of getSortedPlugins('resolveId')) {
if (!plugin.resolveId)
continue;
if (skip?.has(plugin))
continue;
const handler = 'handler' in plugin.resolveId
? plugin.resolveId.handler
: plugin.resolveId;
const result = await handler.call(...);
if (!result)
continue;
if (typeof result === 'string') {
id = result;
} else {
id = result.id;
Object.assign(partial, result);
}
break;
}
if (id) {
partial.id = isExternalUrl(id) ? id : normalizePath$3(id);
return partial;
} else {
return null;
}
}
第二部分初始化dep-scan的vite插件
esbuild
插件中,提供了两种方法onResolve
和onLoad
onResolve
和onLoad
的第1个参数为filter
(必填)和namespaces
(可选)
钩子函数必须提供过滤器filter
正则表达式,但也可以选择提供namespaces
以进一步限制匹配的路径
按照esbuild
插件的onResolve()
和onLoad()
流程进行一系列处理
async function prepareEsbuildScanner(...) {
const container = await createPluginContainer(config);
if (scanContext?.cancelled)
return;
const plugin = esbuildScanPlugin(...);
//...省略esbuild打包配置
}
const htmlTypesRE = /\.(html|vue|svelte|astro|imba)$/;
function esbuildScanPlugin(config, container, depImports, missing, entries) {
const resolve = async (id, importer, options) => {
// 第一部分内容:container.resolveId()
const resolved = await container.resolveId();
const res = resolved?.id;
return res;
};
return {
name: 'vite:dep-scan',
setup(build) {
// 第二个部分内容:插件执行流程
build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
const resolved = await resolve(path, importer);
return {
path: resolved,
namespace: 'html',
};
});
build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => {...});
//....
}
}
}
在
esbuildScanPlugin()
的执行逻辑中,如上面代码块注释所示,分为两部分内容:
container.resolveId()
的具体逻辑,涉及到container
的初始化,具体的插件执行等逻辑build.onResolve
和build.onLoad
的具体逻辑下面将使用简单的具体实例按照这两部分内容展开分析:
从index.html
->main.js
->import vue
的流程进行分析
index.html文件触发container.resolveId()
当我们执行入口index.html
文件的打包解析时,我们通过调试可以知道,我们最终会命中插件:vite:resolve
的处理,接下来我们将针对这个插件展开分析
传入参数id
就是路径,比如"/Users/wcbbcc/blog/Frontend-Articles/vite-debugger/index.html"
或者"/src/main.js"
传入参数importer
就是引用它的模块,比如"stdin"
或者"/Users/wcbbcc/blog/Frontend-Articles/vite-debugger/index.html"
而container.resolveId()
逻辑就是根据目前路径的类型,比如是绝对路径、相对路径、模块路或者其他路径类型,然后进行不同的处理,最终返回拼凑好的完整路径
return {
name: 'vite:resolve',
async resolveId(id, importer, resolveOpts) {
//...
// URL
// /foo -> /fs-root/foo
if (asSrc && id[0] === '/' && (rootInRoot || !id.startsWith(root))) {...}
// relative
if (id[0] === '.' ||
((preferRelative || importer?.endsWith('.html')) &&
startsWithWordCharRE.test(id))) {...}
// drive relative fs paths (only windows)
if (isWindows$4 && id[0] === '/') {...}
// absolute fs paths
if (isNonDriveRelativeAbsolutePath(id) &&
(res = tryFsResolve(id, options))) {...}
// external
if (isExternalUrl(id)) {...}
// data uri: pass through (this only happens during build and will be
// handled by dedicated plugin)
if (isDataUrl(id)) {
return null;
}
// bare package imports, perform node resolve
if (bareImportRE.test(id)) {...}
}
}
index.html文件触发onResolve和onLoad
一开始我们打包index.html
入口文件时
- 触发
filter: htmlTypesRE
的筛选,命中onResolve()
的处理逻辑,返回namespace: 'html'
和整理好的路径path
传递给下一个阶段 - 触发
filter: htmlTypesRE
和namespace: 'html'
的筛选条件,命中onLoad()
的处理逻辑,使用regex.exec(raw)
匹配出index.html
中的标签,拿出里面对应的
src
的值,最终返回content:"import '/src/main.js' \n export default {}"
每个未标记为external:true
的唯一路径/命名空间的文件加载完成会触发onLoad()
回调,onLoad()
的工作是返回模块的内容并告诉 esbuild 如何解释它
return {
name: 'vite:dep-scan',
setup(build) {
// html types: extract script contents -----------------------------------
build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
const resolved = await resolve(path, importer);
return {
path: resolved,
namespace: 'html',
};
});
// extract scripts inside HTML-like files and treat it as a js module
build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => {
let raw = await fsp.readFile(path, 'utf-8');
const isHtml = path.endsWith('.html');
//scriptModuleRE = /(
一些原始的文件,比如main.js
、main.css
、Index.vue
则直接使用readFile
进行内容的读取
而一些需要插件进行获取的文件数据,比如Index.vue
文件经过transform
流程解析得到的数据、解析
得到的数据,都需要特定的插件进行处理获取数据
可以理解为,如果单纯读取文件内容,直接使用readFile()
即可,如果还需要对内容进行加工或者改造,则需要走插件进行处理
第二部分初始化缓存:moduleGraph.ensureEntryFromUrl
简单的逻辑,根据url
创建对应的new ModuleNode()
缓存对象,等待pluginContainer.transform
返回result
后,将result
存入到mod
中
async function loadAndTransform(id, url, server, options, timestamp) {
//...
// 第二部分:创建moduleGraph缓存,将下面transform结果存入mod中
const mod = await moduleGraph.ensureEntryFromUrl(url, ssr);
ensureWatchedFile(watcher, mod.file, root);
// 第三部分:transform转化文件内容
const transformStart = isDebug$3 ? performance$1.now() : 0;
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
ssr,
});
if (timestamp > mod.lastInvalidationTimestamp) {
if (ssr)
mod.ssrTransformResult = result;
else
mod.transformResult = result;
}
}
async ensureEntryFromUrl(rawUrl, ssr, setIsSelfAccepting = true) {
const [url, resolvedId, meta] = await this.resolveUrl(rawUrl, ssr);
let mod = this.idToModuleMap.get(resolvedId);
if (!mod) {
mod = new ModuleNode(url, setIsSelfAccepting);
this.urlToModuleMap.set(url, mod);
mod.id = resolvedId;
this.idToModuleMap.set(resolvedId, mod);
const file = (mod.file = cleanUrl(resolvedId));
let fileMappedModules = this.fileToModulesMap.get(file);
if (!fileMappedModules) {
fileMappedModules = new Set();
this.fileToModulesMap.set(file, fileMappedModules);
}
fileMappedModules.add(mod);
} else if (!this.urlToModuleMap.has(url)) {
this.urlToModuleMap.set(url, mod);
}
return mod;
}
第三部分转化文件内容:pluginContainer.transform
pluginContainer.transform()
跟之前分析的pluginContainer.resolveId()
类似,都是去遍历所有注册的插件,然后返回结果,而根据不同的文件类型,会调用不同类型的插件进行transform()
处理,比如:
vite:css
:css
编译插件,下面4.3.6
步骤解析.vue
文件得到的形成的语句最终会调用
vite:css
插件进行解析vite:esbuild
:.ts
、.jsx
和.tsx
转化.js
的插件,用来代替传统的tsc
转化功能
async transform(code, id, options) {
for (const plugin of getSortedPlugins('transform')) {
if (!plugin.transform)
continue;
let result;
const handler = 'handler' in plugin.transform
? plugin.transform.handler
: plugin.transform;
result = await handler.call(ctx, code, id, { ssr });
if (!result)
continue;
//...将result赋值给code
}
return {
code,
map: ctx._getCombinedSourcemap(),
};
}
4.1.7 小结
插件流程
在上面的transformMiddleware
的分析流程中,我们涉及到多个插件的resolveId()
、load()
、transform()
流程,这本质是一套规范的rollup
插件流程
比如transform()
流程,见下面分析内容
transform
是rollup插件规定的Build Hooks,具体可以参考Rollup 插件文档
rollup插件整体的构建流程如下所示:
middleware处理流程跟预构建流程的差别
预构建也有路径resolveId()处理,middleware处理流程也有resolveId()路径处理,这两方面有什么差别?
预构建有获取内容,middleware处理流程也有获取内容,这两方面有什么差别呢?
预构建的路径resolveId()
,是为了能够得到完整的路径,然后进行readFile()
读取文件内容,最终根据内容找到依赖的其它文件,然后触发其它依赖文件执行相关的build.onResolve()
和build.onLoad()
,从而遍历完所有的文件,进行预构建node_modules
相关文件的依赖收集
最终收集完成输出deps
数据,根据deps
数据进行预构建:esbuild打包到node_modeuls/.vite/xxx
文件中
而middleware
处理流程的resolveId()
流程,涉及到node_modules
相关路径的获取,会根据预构建得到的depsOptimizer
拿到对应的路径数据,其它路径的获取则跟预构建流程一致,最终获取到绝对路径
然后触发对应的load()
->transform()
插件流程,这个时候不同类型的数据会根据不同的插件进行处理,比如.scss
文件交由vite:css
文件进行转化为css
数据,.vue
文件交由vite:vue
进行单页面的解析成为多个部分进行数据的获取,最终形成浏览器可以识别的js
数据内容,然后返回给浏览器进行执行和显示
在4.1.5
步骤中,我们简单分析了loadAndTransform()
的整体流程,但是涉及到的一些插件没有具体展开分析,下面我们将使用具体的例子,将涉及到的插件简单进行分析
4.2 常见插件源码分析
4.2.1 vite:import-analysis分析
当浏览器请求main.js
时,由4.3.5
的第一部分
的分析中,我们知道pluginContainer.load()
返回结果为空,会直接使用readFile()
读取文件内容
然后触发pluginContainer.transform("main.js")
,此时会触发插件vite:import-analysis
的transform()
方法
如下面代码块所示,在这个方法中,我们会提取出所有import
的数据,然后进行遍历,在遍历过程中
- 使用
normalizeUrl()
去掉rootDir
的前缀,调用pluginContainer.resolveId()
进行路径的重写 - 添加到
staticImportedUrls
,提前触发transformRequest()
进行import
文件的转化
name: 'vite:import-analysis',
async transform(source, importer, options) {
let imports;
let exports;
[imports, exports] = parse$e(source);
for (let index = 0; index < imports.length; index++) {
const { s: start, e: end, ss: expStart, se: expEnd, d: dynamicIndex,
n: specifier, a: assertIndex, } = imports[index];
// resolvedId="/Users/wcbbcc/blog/Frontend-Articles/vite-debugger/node_modules/.vite/deps/vue.js?v=da0b3f8b"
// url="/node_modules/.vite/deps/vue.js?v=da0b3f8b"
const [url, resolvedId] = await normalizeUrl(specifier, start);
if (!isDynamicImport) {
// for pre-transforming
staticImportedUrls.add({ url: hmrUrl, id: resolvedId });
}
}
if (config.server.preTransformRequests && staticImportedUrls.size) {
staticImportedUrls.forEach(({ url }) => {
url = removeImportQuery(url);
transformRequest(url, server, { ssr }).catch((e) => {
});
});
}
}
const normalizeUrl = async (url, pos, forceSkipImportAnalysis = false) => {
const resolved = await this.resolve(url, importerFile);
if (resolved.id.startsWith(root + '/')) {
url = resolved.id.slice(root.length);
}
//url="/node_modules/.vite/deps/vue.js?v=c1e0320d"
return [url, resolved.id];
};
pluginContainer.resolveId()逻辑
跟上面预构建的流程相同,都是触发插件vite:resolve
的执行,但是此时的depsOptimizer
已经存在,因此会直接从depsOptimizer
中获取对应的路径数据,返回路径node_modules/.vite/deps/xxx
的数据
name: "vite:resolve"
async resolveId() {
if (bareImportRE.test(id)) {
const external = options.shouldExternalize?.(id);
if (!external &&
asSrc &&
depsOptimizer &&
!options.scan &&
(res = await tryOptimizedResolve(depsOptimizer, id, importer))) {
return res;
}
if ((res = tryNodeResolve(id, importer, options, targetWeb, depsOptimizer, ssr, external))) {
return res;
}
}
}
vite:import-analysis小结
vite:import-analysis
插件重写了import
语句的路径,比如import {createApp} from "vue"
重写为import {createApp} from "/node_modules/.vite/deps/vue.js?v=da0b3f8b"
- 除了替换了文件内容
code
中那些导入模块import
的路径,还提前触发这些路径的transformRequest()
调用
4.2.2 vite:vue分析
借助@vitejs/plugin-vue
独立的插件,可以进行.vue
文件的解析
当浏览器请求普通结构的Index.vue
时,会触发vite:vue
方法的解析,然后触发transformMain()
方法解析
name: 'vite:vue',
async transform(code, id, opt) {
//...
if (!query.vue) {
return transformMain(
code,
filename,
options,
this,
ssr,
customElementFilter(filename)
);
} else {
//...
}
}
在这个插件中,会进行、
、
三种标签的数据解析
其中stylesCode
会解析得到"import 'xxxxx/vite-debugger/src/Index.vue?vue&type=style&index=0&scoped=3d84b2a7&lang.css' "
之后会触发插件"vite:css"
进行transform()
的转化
然后使用output.join("\n")
拼成数据返回
async function transformMain(code, filename, options, pluginContext, ssr, asCustomElement) {
const { code: scriptCode, map: scriptMap } = await genScriptCode(
descriptor,
options,
pluginContext,
ssr
);
const hasTemplateImport = descriptor.template && !isUseInlineTemplate(descriptor, !devServer);
if (hasTemplateImport) {
({ code: templateCode, map: templateMap } = await genTemplateCode(
descriptor,
options,
pluginContext,
ssr
));
}
const stylesCode = await genStyleCode(
descriptor,
pluginContext,
asCustomElement,
attachedProps
);
const output = [
scriptCode,
templateCode,
stylesCode,
customBlocksCode
];
if (!attachedProps.length) {
output.push(`export default _sfc_main`);
} else {
output.push(
`import _export_sfc from '${EXPORT_HELPER_ID}'`,
`export default /*#__PURE__*/_export_sfc(_sfc_main, [${attachedProps.map(([key, val]) => `['${key}',${val}]`).join(",")}])`
);
}
let resolvedCode = output.join("\n");
return {
code: resolvedCode
};
}
Index.vue
的代码如下所示:
这是Index.vue
Index.vue
经过vite:vue
的transform()
转化后的output
如下图所示,一共分为4个部分:
: 转化为
createElement()
编译后的语句:
export default
转化为const _sfc_main=
语句: 转化为
import "xxx.vue?vue&lang.css"
的语句- 其它代码: 热更新代码和其它运行时代码
5. 热更新HMR
由于篇幅原因,接下来的分析请看下一篇文章「vite4源码」dev模式整体流程浅析(二)