本文基于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 nodeconst 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 responsesvar compression = require('compression');app.use(compression());// respond to all requestsapp.use(function(req, res){ res.end('Hello from Connect!\n');});//create node.js http server and listen on porthttp.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.jsconst { 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 =======startfunction 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 =======endfunction 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最终调用的就是getSortedPluginsByHookfunction 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模式导入?
在https://cn.vitejs.dev/guide/dep-pre-bundling.html官网文档中,应用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.jsvar 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.jsvar 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代码
上面剖析内容大部分都参考https://github.com/evanw/esbuild/issues/506 和 https://github.com/vitejs/vite/issues/3409
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"
的语句- 其它代码: 热更新代码和其它运行时代码
参考文章
- Vite源码剖析,是时候弄清楚Vite的原理了
- Vite原理及源码解析
- vite2 源码剖析(一) — 启动 vite
- Vite原理剖析