我正在参加「掘金·启航计划」
本文基于
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 预构建整体流程(流程图)
由于掘金限制,只能使用png图片,由于本文源码分析都是基于流程图,如果下面png图片看不清,可以点击查看预构建整体流程.svg
接下来会根据流程图的核心流程进行源码分析
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
中的<script>
标签,拿出里面对应的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 = /(<script\b[^>]+type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gis;
//scriptRE = /(<script(?:\s[^>]*>|>))(.*?)<\/script>/gis;
const regex = isHtml ? scriptModuleRE : scriptRE;
while ((match = regex.exec(raw))) {
const [, openTag, content] = match;
let loader = 'js';
if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
loader = lang;
} else if (path.endsWith('.astro')) {
loader = 'ts';
}
//srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i;
const srcMatch = openTag.match(srcRE);
if (srcMatch) {
const src = srcMatch[1] || srcMatch[2] || srcMatch[3];
js += `import ${JSON.stringify(src)}\n`;
} else if (content.trim()) {
//...
}
}
return {
loader: 'js',
contents: js,
};
});
}
}
onLoad()
返回content:"import '/src/main.js' \n export default {}"
后再度触发onResolve()
的执行
main.js文件触发container.resolveId()
从上面index.html
对于插件vite:resolve
的resolveId()
分析,我们知道会进行多种模式路径的处理,而src/main.js
则触发/foo -> /fs-root/foo
的处理,逻辑就是合并root
,然后形成最终的路径返回
name: 'vite:resolve',
async resolveId(id, importer, resolveOpts) {
//...
// /foo -> /fs-root/foo
if (asSrc && id[0] === '/' && (rootInRoot || !id.startsWith(root))) {
const fsPath = path$o.resolve(root, id.slice(1));
if ((res = tryFsResolve(fsPath, options))) {
return ensureVersionQuery(res, id, options, depsOptimizer);
}
}
}
main.js文件触发onResolve和onLoad
此时id
="/src/main.js"
,经过resolve()
后拼接成完整的路径resolved
="xxxxx/vite-debugger/src/main.js"
,最终返回path
="xxxxx/vite-debugger/src/main.js"
,然后触发onLoad()
解析main.js
的内容进行返回
build.onResolve({
filter: /.*/,
}, async ({ path: id, importer, pluginData }) => {
// id="/src/main.js"->"xxxxx/vite-debugger/src/main.js"
const resolved = await resolve(id, importer, ...);
if (resolved) {
//...
return {
path: path$o.resolve(cleanUrl(resolved)),
namespace,
};
}
});
build.onLoad({ filter: JS_TYPES_RE }, async ({ path: id }) => {
let ext = path$o.extname(id).slice(1);
let contents = await fsp.readFile(id, 'utf-8');
const loader = config.optimizeDeps?.esbuildOptions?.loader?.[`.${ext}`] ||
ext;
return {
loader,
contents,
};
});
onLoad()
解析main.js
的内容如下所示,本质也就是读取对应main.js
本身的内容
return {
loader: "js",
content: `import { createApp } from "vue";
import App from "./App";
import { toString, toArray } from "lodash-es";
console.log(toString(123));
console.log(toArray([]));
createApp(App).mount("#app");`
}
onLoad()
返回content
中包含了多个import
语句,再度触发onResolve()
的执行
.vue文件触发container.resolveId()
此时id
="vue"
,会触发vite:resolve
插件的resolveId()
方法处理,最终触发了tryNodeResolve()
的处理,由于代码非常繁多,精简过后的代码如下所示:
resolvePackageData()
: 获取"vue"
所在的package.json
数据resolvePackageEntry()
: 根据package.json
数据的字段去获取对应的入口
name:"vite:resolve"
async resolveId() {
if (bareImportRE.test(id)) {
if ((res = tryNodeResolve(id, importer, options, targetWeb, depsOptimizer, ssr, external))) {
return res;
}
}
}
function tryNodeResolve(id, ...) {
const { root, ... } = options;
let basedir;
//...省略一系列条件
basedir = root;
const pkg = resolvePackageData(pkgId, basedir, preserveSymlinks, packageCache);
const resolveId = deepMatch ? resolveDeepImport : resolvePackageEntry;
const unresolvedId = deepMatch ? '.' + id.slice(pkgId.length) : pkgId;
let resolved = resolveId(unresolvedId, pkg, targetWeb, options);
if (!options.ssrOptimizeCheck &&
(!isInNodeModules(resolved) || // linked
!depsOptimizer || // resolving before listening to the server
options.scan) // initial esbuild scan phase
) {
return { id: resolved };
}
}
function resolvePackageData(pkgName, basedir, preserveSymlinks = false, packageCache) {
while (basedir) {
const pkg = path$o.join(basedir, 'node_modules', pkgName, 'package.json');
if (fs$l.existsSync(pkg)) {
const pkgPath = preserveSymlinks ? pkg : safeRealpathSync(pkg);
const pkgData = loadPackageData(pkgPath);
return pkgData;
}
const nextBasedir = path$o.dirname(basedir);
if (nextBasedir === basedir)
break;
basedir = nextBasedir;
}
return null;
}
resolvePackageEntry()
的代码逻辑非常繁多,遍历了package.json
多个字段,找对应的入口,主要经历了:
exports
browser/module
field
main
最终找到对应的entry
,跟dir
进行合并,形成最终的路径,此时
dir
="/Users/wcbbcc/blog/Frontend-Articles/vite-debugger/node_modules/vue"
entry
="./dist/vue.runtime.esm-bundler.js"
function resolvePackageEntry(id, { dir, data, setResolvedCache, getResolvedCache }, targetWeb, options) {
let entryPoint;
if (data.exports) {
entryPoint = resolveExportsOrImports(data, '.', options, targetWeb, 'exports');
}
//...省略browser/module、field的逻辑判断
entryPoint || (entryPoint = data.main);
const entryPoints = entryPoint
? [entryPoint]
: ['index.js', 'index.json', 'index.node'];
for (let entry of entryPoints) {
//...
const entryPointPath = path$o.join(dir, entry);
const resolvedEntryPoint = tryFsResolve(entryPointPath, options, true, true, skipPackageJson);
if (resolvedEntryPoint) {
return resolvedEntryPoint;
}
}
}
.vue文件触发onResolve
此时id
="vue"
,经过resolve()
后拼接成完整的路径resolved
="xxx/vite-debugger/node_modules/vue/dist/vue.runtime.esm-bundler.js"
最终进行depImports["vue"]
="xxx/vite-debugger/node_modules/vue/dist/vue.runtime.esm-bundler.js"
最终返回externalUnlessEntry({ path: id })
={external: true,path: "vue"}
,由于external
为true
,因此不会触发onLoad()
的执行
// bare imports: record and externalize ----------------------------------
build.onResolve({
// avoid matching windows volume
filter: /^[\w@][^:]/,
}, async ({ path: id, importer, pluginData }) => {
const resolved = await resolve(id, importer, { ...});
if (resolved) {
if (isInNodeModules(resolved) || include?.includes(id)) {
if (isOptimizable(resolved, config.optimizeDeps)) {
depImports[id] = resolved;
}
return externalUnlessEntry({ path: id });
}
//...
}
});
同理
"./App"
的解析可以参考"main.js"
的流程分析:先进行路径的整理,然后获取具体的source内容,再度触发解析"lodash-es"
的解析可以参考"vue"
的流程分析:先进行路径的整理,然后存放在depImports
中,最后结束流程
第三部分返回esbuild插件打包代码
esbuild.context()
是esbuild
提供的API,相比较esbuild.build()
,使用给定context
完成的所有构建共享相同的构建选项,并且后续构建是增量完成的(即它们重用以前构建的一些工作以提高性能)
直接返回return await esbuild.context({})
,本质就是返回一个Promise
async function prepareEsbuildScanner(...) {
const container = await createPluginContainer(config);
if (scanContext?.cancelled)
return;
const plugin = esbuildScanPlugin(...);
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,
});
}
3.5.3 开始打包
在上面esbuild插件打包: esbuild.context()
的分析中,我们知道,esbuildContext
本质就是await esbuild.context()
根据esbuild官方文档的描述,
Rebuild
模式允许您手动调用构建
在经历了3.5.1
和3.5.2
步骤之后,我们开始了context.rebuild
的执行,也就是触发3.5.2
步骤中的esbuild
打包流程
打包过程中,得到所有node_moduels
的依赖放入到deps
对象中,打包完成后,将deps
数据返回
function scanImports(config) {
const deps = {};
// 3.5.1 计算入口文件computeEntries
const esbuildContext = computeEntries(config).then((computedEntries) => {
entries = computedEntries;
// 3.5.2 打包入口文件esbuild插件初始化
return prepareEsbuildScanner(config, entries, deps, missing, scanContext);
});
const result = esbuildContext
.then((context) => {
// 3.5.3 开始打包
return context
.rebuild()
.then(() => {
return {
deps: orderedDependencies(deps),
missing,
};
});
})
return { result, cancel }
}
3.6 依赖扫描后进行打包runOptimizeDeps()
在3.5.2
步骤的分析中,我们知道对于node_modules
的打包,我们会存储到depImports["vue"]
="xxx/vite-debugger/node_modules/vue/dist/vue.runtime.esm-bundler.js"
,然后scanImports()
会返回存储的所有deps
数据
function scanImports(config) {
const deps = {};
// 3.5.1 计算入口文件computeEntries
const esbuildContext = computeEntries(config).then((computedEntries) => {
entries = computedEntries;
// 3.5.2 打包入口文件esbuild插件初始化
return prepareEsbuildScanner(config, entries, deps, missing, scanContext);
});
const result = esbuildContext
.then((context) => {
// 3.5.3 开始打包
return context
.rebuild()
.then(() => {
return {
deps: orderedDependencies(deps),
missing,
};
});
})
return { result, cancel }
}
在3.6
步骤中,我们提取出所有获取到的deps
数据,即discover.result
,然后执行runOptimizeDeps()
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);
}
}
runOptimizeDeps()
的代码中,主要分为两个部分:
- 获取
xxx/node_modules/.vite/deps
的完整路径depsCacheDir
,然后初始化_metadata.json
,往_metadata.json
写入打包的缓存信息 prepareEsbuildOptimizerRun()
执行esbuild
打包预构建的库到.vite/deps
文件夹下面
下面代码已经详细分析了
_metadata.json
每一个属性的写入逻辑,接下来将着重分析下prepareEsbuildOptimizerRun()
的打包逻辑
function runOptimizeDeps() {
// 得到缓存数据的路径,也就是.vite/deps文件夹的完整路径
const depsCacheDir = getDepsCacheDir(resolvedConfig, ssr);
//创建.vite/package.json文件
fs$l.writeFileSync(path$o.resolve(processingCacheDir, 'package.json'), `{\n "type": "module"\n}\n`);
// 初始化_metadata.json
const metadata = initDepsOptimizerMetadata(config, ssr);
//写入_metadata.json的browserHash属性
metadata.browserHash = getOptimizedBrowserHash(metadata.hash, depsFromOptimizedDepInfo(depsInfo));
// esbuild打包初始化
const preparedRun = prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext);
const runResult = preparedRun.then(({ context, idToExports }) => {
// 执行esbuild打包
return context
.rebuild()
.then((result) => {
//...写入_metadata.json的optimized属性
//...写入_metadata.json的chunks属性
});
});
return {
async cancel() {
optimizerContext.cancelled = true;
const { context } = await preparedRun;
await context?.cancel();
cleanUp();
},
result: runResult,
};
}
function initDepsOptimizerMetadata(config, ssr, timestamp) {
const hash = getDepHash(config, ssr);
return {
hash,
browserHash: getOptimizedBrowserHash(hash, {}, timestamp),
optimized: {},
chunks: {},
discovered: {},
depInfoList: [],
};
}
3.6.1 具体打包node_modules库进行到.vite/deps的逻辑
主要是进行打包前的参数准备,其中有几个参数需要注意下:
entryPoints
: 将node_modules
的依赖库的src
平铺成为数组的形式作为esbuild.context()
打包的入口entryPoints
outdir
: 将node_modeuls/.vite/deps
文件夹作为输出的目录bundle
: 设置为true
时,打包一个文件意味着将任何导入的依赖项内联到文件中。这个过程是递归的,因为依赖的依赖(等等)也将被内联。默认情况下,esbuild
将不会打包输入的文件,也就是vue.js
的所有import
依赖都会打包到vue.js
中,而不会使用import
的形式引入其它依赖库format
: 打包文件输出格式为ES Module
format
有三个可能的值:iife
、cjs
与esm
async function prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext) {
for (const id in depsInfo) {
const src = depsInfo[id].src;
const exportsData = await (depsInfo[id].exportsData ??
extractExportsData(src, config, ssr));
flatIdDeps[flatId] = src;
idToExports[id] = exportsData;
}
plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr));
const context = await esbuild.context({
entryPoints: Object.keys(flatIdDeps),
outdir: processingCacheDir,
bundle: true,
format: 'esm',
plugins,
...
});
return { context, idToExports };
}
function runOptimizeDeps() {
// esbuild打包初始化
const preparedRun = prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext);
const runResult = preparedRun.then(({ context, idToExports }) => {
// 执行esbuild打包
return context
.rebuild()
.then((result) => {
//...写入_metadata.json的optimized属性
//...写入_metadata.json的chunks属性
});
})
}
3.7 预构建目的实现原理分析
针对的是node_modules
依赖的预构建,不包括实际的业务代码
3.7.1 CommonJS 和 UMD 兼容性
CommonJS代码如何改造,从而支持ESModule形式导入?
在cn.vitejs.dev/guide/dep-p…官方文档中,使用
React
作为例子,我们直接使用React
作为示例跑起来
将vite
预构建的react.js
进行整理,形成下面的代码块:
var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = function (cb, mod) {
return function __require() {
return mod ||
(0, cb[__getOwnPropNames(cb)[0]])
((mod = { exports: {} }).exports, mod), mod.exports;
};
}
// node_modules/react/cjs/react.development.js
var require_react_development = __commonJS({
"node_modules/react/cjs/react.development.js"(exports, module) {
"use strict";
if (true) {
(function () {
//react的common.js代码
exports.xx = xxx;
exports.xxxx = xxxxx;
})();
}
}
});
// node_modules/react/index.js
var require_react = __commonJS({
"node_modules/react/index.js": function (exports, module) {
if (false) {
module.exports = null;
} else {
module.exports = require_react_development();
}
}
});
export default require_react();
手动创建mod
和mod.exports
传入(exports, module)
中,此时
mod
=module
mod.exports
=exports
在CommonJs
代码中,比如下面示例的cjs/react.development.js
中,会使用传入的exports
进行变量的赋值
最终输出export default module.exports
,即export default {xxx, xxx, xxx, xxx}
从源码层级,是如何实现上面的转化流程?
从源码中,我们可以看到,最终在第二次esbuild
打包中,使用format: 'esm'
,利用esbuild
提供的能力将cjs
格式转化为esm
格式
async function prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext) {
for (const id in depsInfo) {
const src = depsInfo[id].src;
const exportsData = await (depsInfo[id].exportsData ??
extractExportsData(src, config, ssr));
flatIdDeps[flatId] = src;
idToExports[id] = exportsData;
}
plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr));
const context = await esbuild.context({
entryPoints: Object.keys(flatIdDeps),
outdir: processingCacheDir,
bundle: true,
format: 'esm',
plugins,
...
});
return { context, idToExports };
}
function runOptimizeDeps() {
// esbuild打包初始化
const preparedRun = prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext);
const runResult = preparedRun.then(({ context, idToExports }) => {
// 执行esbuild打包
return context
.rebuild()
.then((result) => {
//...写入_metadata.json的optimized属性
//...写入_metadata.json的chunks属性
});
})
}
CommonJS->ESModule需要注意的点
node_moduels
的依赖库如果本身是commonjs
格式,会使用esbuild
自动转化为esm
格式,但是我们自己编写的业务代码,比如:
我们可以发现,虽然我们自己编写的业务代码是完全模仿react.js
的导入模式重新书写了一遍,但是直接运行就是报错
经过上面的源码调试,其实我们已经能够猜到,之所以报错,就是没有转化indexB.js
为esmodule
格式
- 在第一次esbuild打包时,会使用
index.html
作为esbuild打包的input,然后使用build.onResolve()
和build.onLoad()
处理main.js
等文件相关路径以及内容中包含的import
路径,不断触发build.onResolve()
和build.onLoad()
,递归处理所有涉及到的文件的路径,同时收集所有node_modules
到deps
中 - 在第二次esbuild打包中,会使用所有
node_modules
的依赖入口文件作为esbuild打包的input,然后进行打包,此时所有commonjs
的文件会被转化为esmodule
以及内联到同一个文件中
从上面的分析中,我们可以发现,没有转化indexB.js
为esmodule
格式就是因为第二次esbuild打包没有加入indexB.js
那么如果我们强行加入indexB.js
在第二次esbuild打包中,如下图所示,然后我们main.js
直接就使用node_modules/.vite/deps/indexB.js
"react"
会被自动转化为"node_modules/.vite/deps/react.js"
路径
结果如我们预想中一样,正常运行,并且indexB.js
也会转化为esmodule
格式,同时它也被写入到node_modules/.vite/deps/indexB.js
中
从中我们就明白了一个事情,vite
预构建针对的是node_modules
的依赖,而实现CommonJS 和 UMD 兼容性
目的本质借助的是esbuild
的打包能力,借助它format:"esm"
的输出格式转化commonjs
为esmodule
格式
那如果我们想要在业务代码中使用
commonjs
和require语句
,我们该如何做呢?
业务代码中使用CommonJS代码
下面分析内容大部分都参考github.com/evanw/esbui… 和 github.com/vitejs/vite…
vite
使用esbuild
进行预构建,可以将commonjs
转化为esmodule
而
commonjs
转化为esmodule
的原理在上面我们也分析过,就是在commonjs
代码的基础上再注入一些代码,进行export default {xx, xx}
然而esbuild
无法转化require
语句,从esbuild/issues/506也可以看出,就算node_modules
依赖库中有require
语句也无法转化,外部业务代码的require
语句更加无法转化,因为外部的业务代码都没经过第二次esbuild打包转化为esm
格式
而vite
除了esbuild
部分的代码并不支持require
语句,只支持import
语句
因此为了能够在vite
的业务代码中使用commonjs
,将业务代码的require
语句转化为import
语句,我们需要引入对应的plugin
: vite-plugin-commonjs
import { viteCommonjs } from '@originjs/vite-plugin-commonjs'
export default {
plugins: [
viteCommonjs()
]
}
如果需要将node_modules
的require
语句转化为import
语句,需要声明:
import { esbuildCommonjs } from '@originjs/vite-plugin-commonjs'
export default {
optimizeDeps:{
esbuildOptions:{
plugins:[
esbuildCommonjs(['react-calendar','react-date-picker'])
]
}
}
}
3.7.2 性能
为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。 有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,
lodash-es
有超过 600 个内置模块!当我们执行import { debounce } from 'lodash-es'
时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。 通过将lodash-es
预构建成单个模块,现在我们只需要一个HTTP请求!
esbuild
打包时设置bundle:true
,打包一个文件会将任何导入的依赖项内联到文件中
4. 请求资源middlewares
注意:调试
app.use(xxxMiddleware(server))
时需要打开浏览器,例如[http://127.0.0.1:5173/](http://127.0.0.1:5173/)
触发请求发送,才能触发xxxMiddleware
相关逻辑断点,才能调试本地服务器的相关代码
有多种功能的中间件,比如proxyMiddleware
可以在本地开发环境中跨域请求,servePublicMiddleware
处理public
资源,serveStaticMiddleware
和serveRawFsMiddleware
处理静态文件
async function createServer(inlineConfig = {}) {
const middlewares = connect();
// proxy
const { proxy } = serverConfig;
if (proxy) {
middlewares.use(proxyMiddleware(httpServer, proxy, config));
}
// serve static files under /public
if (config.publicDir) {
middlewares.use(servePublicMiddleware(config.publicDir, config.server.headers));
}
// main transform middleware
middlewares.use(transformMiddleware(server));
// serve static files
middlewares.use(serveRawFsMiddleware(server));
middlewares.use(serveStaticMiddleware(root, server));
}
这些中间件中,
transformMiddleware
的功能最为复杂和重要,本文将只针对transformMiddleware
展开分析,其它中间件请参考其它文章
4.1 transform
在初始化createServer()
中,我们会使用middlewares.use(transformMiddleware(server))
进行文件内容的转化
async function createServer(inlineConfig = {}) {
const middlewares = connect();
// main transform middleware
middlewares.use(transformMiddleware(server));
}
4.1.1 transformMiddleware流程图
4.1.2 使用transformMiddleware的原因
- 对于
esmodule
,不支持import xx from "vue"
这种导入的,需要转化为相对路径或者绝对路径的形式 - 浏览器只认识
js
,不支持其它后缀的文件名称,比如.vue
、.ts
,需要进行处理
4.1.3 transformMiddleware整体流程(源码整体概述)
function transformMiddleware(server) {
const { config: { root, logger }, moduleGraph, } = server;
return async function viteTransformMiddleware(req, res, next) {
if (req.method !== 'GET' || knownIgnoreList.has(req.url)) {
return next();
}
let url = decodeURI(removeTimestampQuery(req.url)).replace(NULL_BYTE_PLACEHOLDER, '\0');
//...处理 xxx.js.map的情况
//...处理url是public/xx开头的情况,提示警告
if (isJSRequest(url) ||
isImportRequest(url) ||
isCSSRequest(url) ||
isHTMLProxy(url)) {
if (isCSSRequest(url) &&
!isDirectRequest(url) &&
req.headers.accept?.includes('text/css')) {
url = injectQuery(url, 'direct');
}
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html'),
});
if (result) {
const depsOptimizer = getDepsOptimizer(server.config, false); // non-ssr
const type = isDirectCSSRequest(url) ? 'css' : 'js';
const isDep = DEP_VERSION_RE.test(url) || depsOptimizer?.isOptimizedDepUrl(url);
return send$1(req, res, result.code, type, {
etag: result.etag,
// allow browser to cache npm deps!
cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
headers: server.config.server.headers,
map: result.map,
});
}
}
next();
};
}
4.1.4 transformRequest & doTransform
判断是否有缓存数据,即server.moduleGraph.getModuleByUrl().transformResult
是否存在,如果存在,则直接返回
如果没有缓存数据,则调用pluginContainer.resolveId()
->loadAndTransform()
进行内容的转化
function transformRequest(url, server, options = {}) {
const request = doTransform(url, server, options, timestamp);
return request;
}
async function doTransform(url, server, options, timestamp) {
const { config, pluginContainer } = server;
const module = await server.moduleGraph.getModuleByUrl(url, ssr);
// check if we have a fresh cache
const cached = module && (ssr ? module.ssrTransformResult : module.transformResult);
if (cached) {
return cached;
}
// resolve
const id = (await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url;
const result = loadAndTransform(id, url, server, options, timestamp);
getDepsOptimizer(config, ssr)?.delayDepsOptimizerUntil(id, () => result);
return result;
}
4.1.5 pluginContainer.resolveId
在3.5.2
步骤中分析过,getSortedPlugins('resolveId')
就是检测初始化时注册的插件是否有resolveId
这个属性,如果有,则添加到返回的数组集合中,比如有10个插件中有5个插件具有resolveId
属性,那么最终getSortedPlugins('resolveId')
拿到的就是这5个插件的Array数据
因此container.resolveId()
中运行插件的个数不止一个,但并不是每一个插件都能返回对应的结果result
,即const result = await handler.call(...)
可能为undefined
当有插件处理后result
不为undefined
时,会直接执行break
,然后返回container.resolveId()
的结果
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;
}
}
4.1.6 loadAndTransform
从下面精简后的代码可以知道,主要分为三个部分:
- 第一部分
pluginContainer.load
: 读取文件内容 - 第二部分
moduleGraph.ensureEntryFromUrl
: 创建moduleGraph缓存 - 第三部分
pluginContainer.transform
: 转化文件内容
async function loadAndTransform(id, url, server, options, timestamp) {
//...
// 第一部分:读取文件内容
const loadResult = await pluginContainer.load(id, { ssr });
if (loadResult == null) {
if (options.ssr || isFileServingAllowed(file, server)) {
code = await promises$2.readFile(file, 'utf-8');
}
if (code) {
map = (convertSourceMap.fromSource(code) ||
(await convertSourceMap.fromMapFileSource(code, createConvertSourceMapReadMap(file))))?.toObject();
code = code.replace(convertSourceMap.mapFileCommentRegex, blankReplacer);
}
} else {
if (isObject$2(loadResult)) {
code = loadResult.code;
map = loadResult.map;
} else {
code = loadResult;
}
}
// 第二部分:创建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,
});
//...简单处理下转化后的文件结果
const originalCode = code;
const result = ssr && !server.config.experimental.skipSsrTransform
? await server.ssrTransform(code, map, url, originalCode)
: {
code,
map,
etag: etag_1(code, { weak: true }),
};
if (timestamp > mod.lastInvalidationTimestamp) {
if (ssr)
mod.ssrTransformResult = result;
else
mod.transformResult = result;
}
return result;
}
第一部分读取文件内容:pluginContainer.load
pluginContainer.load()
跟之前分析的pluginContainer.resolveId()
类似,都是去遍历所有注册的插件,然后返回结果,找到满足条件的那个插件
如果没有插件符合题意,即loadResult==null
时,会进行文件读取的方式获取该文件的内容
async function loadAndTransform(id, url, server, options, timestamp) {
//...
// 第一部分:读取文件内容
const loadResult = await pluginContainer.load(id, { ssr });
if (loadResult == null) {
if (options.ssr || isFileServingAllowed(file, server)) {
code = await promises$2.readFile(file, 'utf-8');
}
}
}
async load(id, options) {
for (const plugin of getSortedPlugins('load')) {
if (!plugin.load)
continue;
const handler = 'handler' in plugin.load ? plugin.load.handler : plugin.load;
const result = await handler.call(ctx, id, { ssr });
if (result != null) {
if (isObject$2(result)) {
updateModuleInfo(id, result);
}
return result;
}
}
return null;
}
那什么情况下
loadResult
可以读取到?什么情况下读取不到呢?会触发什么类型的插件执行load()
获取到loadResult
呢?
为了更好地理解pluginContainer.load()
的调用逻辑,我们使用示例Index.vue
进行分析
<template>
<div class="index-wrapper">这是Index.vue</div>
</template>
<script>
export default {
name: "Index",
setup() {
const indexData = "index Data";
return {}
}
}
</script>
<style scoped>
.index-wrapper {
background-color: rebeccapurple;
}
</style>
一些原始的文件,比如main.js
、main.css
、Index.vue
则直接使用readFile
进行内容的读取
而一些需要插件进行获取的文件数据,比如Index.vue
文件经过transform
流程解析<style>
得到的数据、解析<script>
得到的数据,都需要特定的插件进行处理获取数据
可以理解为,如果单纯读取文件内容,直接使用
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
文件得到的<style>
形成的语句最终会调用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 {
//...
}
}
在这个插件中,会进行<script>
、<template>
、<style>
三种标签的数据解析
其中
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
的代码如下所示:
<template>
<div class="index-wrapper">这是Index.vue</div>
</template>
<script>
export default {
name: "Index",
setup() {
const indexData = "index Data";
return {}
}
}
</script>
<style scoped>
.index-wrapper {
background-color: rebeccapurple;
}
</style>
Index.vue
经过vite:vue
的transform()
转化后的output
如下图所示,一共分为4个部分:
<template>
: 转化为createElement()
编译后的语句<script>
:export default
转化为const _sfc_main=
语句<style>
: 转化为import "xxx.vue?vue&lang.css"
的语句- 其它代码: 热更新代码和其它运行时代码
5. 热更新HMR
由于篇幅原因,接下来的分析请看下一篇文章「vite4源码」dev模式整体流程浅析(二)