Vite 源码(二)vite启动流程以及如何获取config配置
当在控制台输入yarn run dev
时,执行对应源码的位置是node/cli.ts
。
import { cac } from 'cac' // 创建CLI实例,'vite'表示,在 help 和 version 命令中显示的名称 const cli = cac('vite') // 添加命令项 cli .option('-c, --config <file>', `[string] use specified config file`) // ... // dev cli .command('[root]') // default command .alias('serve') // 设置命令别名 .option('--host [host]', `[string] specify hostname`) // ... .option('--cors', `[boolean] enable CORS`) // 使用 CORS // 如果指定端口号则退出 .option('--strictPort', `[boolean] exit if specified port is already in use`) .option( '--force', `[boolean] force the optimizer to ignore the cache and re-bundle` ) // 忽略预构建缓存,重新构建 // 当命令与用户输入匹配时,调用这个回调函数 .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {}) cli.help() cli.version(require('../../package.json').version) cli.parse() 复制代码
Vite 使用 cscjs搭建的命令
当执行这个命令会调用action
的回调函数,看下主要代码
// root: 如果执行的是 yarn run dev -- test 或者 npx vite test,则 root 参数为 'test' // options:命令行中的参数 .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => { const { createServer } = await import('./server') try { const server = await createServer({ root, // 命令名 base: options.base, // 公共基础路径 // 环境模式,development | production mode: options.mode, configFile: options.config, // 配置文件目录 // 调整控制台输出的级别,默认为 'info',可选值 'info' | 'warn' | 'error' | 'silent' logLevel: options.logLevel, clearScreen: options.clearScreen, // 是否清空终端打印的信息 // 开发服务器配置,比如 host、port、open、https、cors、strictPort、force server: cleanOptions(options) }) await server.listen() }) 复制代码
createServer
createServer
函数定义在src/node/server/index.ts
里面,由于createServer
函数代码比较多,这里只捡重要的说
// 删减版,包含主要流程 export async function createServer(inlineConfig) { // 获取config配置 const config = await resolveConfig(inlineConfig, 'serve', 'development') // 获取项目根路径 const root = config.root // 获取本地服务器相关的配置 const serverConfig = config.server // 创建中间件实例 const middlewares = connect() as Connect.Server // 创建 http 服务器 const httpServer = await resolveHttpServer( serverConfig, middlewares, httpsOptions ) // 创建 WebSocket 服务器 const ws = createWebSocketServer(httpServer, config, httpsOptions) // ignored:忽略监听的文件;watchOptions:对应 server.watch 配置,传递给 chokidar 的文件系统监视器选项 const { ignored = [], ...watchOptions } = serverConfig.watch || {} // 通过 chokidar 监听文件 const watcher = chokidar.watch(path.resolve(root), { ignored: [ '**/node_modules/**', '**/.git/**', ...(Array.isArray(ignored) ? ignored : [ignored]), ], ignoreInitial: true, ignorePermissionErrors: true, disableGlobbing: true, ...watchOptions, }) as FSWatcher // 获取 所有插件 const plugins = config.plugins // 创建插件容器,是一个对象,对象的属性是 vite 支持的 rollup 的钩子函数,后面会介绍 // 比如 options、resolveId、load、transform const container = await createPluginContainer(config, watcher) // 创建Vite 的 ModuleGraph 实例,后面也会介绍 const moduleGraph = new ModuleGraph(container) // 声明 server 对象 const server: ViteDevServer = { config, // 包含命令行传入的配置 和 配置文件的配置 middlewares, get app() { return middlewares }, httpServer, // http 服务器 watcher, // 通过 chokidar 监听文件 pluginContainer: container, // vite 支持的 rollup 的钩子函数 ws, // WebSocket 服务器 moduleGraph, // ModuleGraph 实例 transformWithEsbuild, transformRequest(url, options) {}, listen(port?: number, isRestart?: boolean) {}, _optimizeDepsMetadata: null, _isRunningOptimizer: false, _registerMissingImport: null, _pendingReload: null, _pendingRequests: Object.create(null), } // 被监听文件发生变化时触发 watcher.on('change', async (file) => {}) // 添加文件时触发 watcher.on('add', (file) => {}) watcher.on('unlink', (file) => {}) // 执行插件中的 configureServer 钩子函数 // configureServer:https://vitejs.cn/guide/api-plugin.html#configureserver const postHooks: ((() => void) | void)[] = [] for (const plugin of plugins) { if (plugin.configureServer) { // configureServer 可以注册前置中间件,就是在内部中间件之前执行;也可以注册后置中间件 // 如果configureServer 返回一个函数,这个函数内部就是注册后置中间件,并将这些函数收集到 postHooks 中 postHooks.push(await plugin.configureServer(server)) } } // 接下来就是注册中间件 // base if (config.base !== '/') { middlewares.use(baseMiddleware(server)) } // ... // 主要转换中间件 middlewares.use(transformMiddleware(server)) // ... // 如果请求路径是 /结尾,则将路径修改为 /index.html if (!middlewareMode || middlewareMode === 'html') { middlewares.use(spaFallbackMiddleware(root)) } // 调用用户定义的后置中间件 postHooks.forEach((fn) => fn && fn()) if (!middlewareMode || middlewareMode === 'html') { // 如果请求的url是 html 则调用插件中所有的 transformIndexHtml 钩子函数,转换html,并将转换后的 html 代码发送给客户端 middlewares.use(indexHtmlMiddleware(server)) // handle 404s middlewares.use(function vite404Middleware(_, res) { res.statusCode = 404 res.end() }) } if (!middlewareMode && httpServer) { // 重写 httpServer.listen,在服务器启动前预构建 const listen = httpServer.listen.bind(httpServer) httpServer.listen = (async (port: number, ...args: any[]) => {}) as any } else {} return server } 复制代码
createServer
函数的大体流程如下
获取
config
配置创建 http 服务器
httpServer
创建 WebSocket 服务器
ws
通过 chokidar 创建监听器
watcher
创建一个兼容rollup钩子函数的对象
container
创建模块图谱实例
moduleGraph
声明
server
对象注册
watcher
回调执行插件中的
configureServer
钩子函数(注册用户定义的前置中间件),并收集用户定义的后置中间件注册中间件
注册用户定义的后置中间件
注册转换
html
文件的中间件和未找到文件的404中间件重写
httpServer.listen
返回
server
对象
整体逻辑就是,调用createServer
函数,拿到返回值后调用server.listen()
,在这个过程中会对预构建依赖包并开启本地开发服务器。
小结
Vite 冷启动为什么快
Vite 运行 Dev 命令后只做了两件事情,一是启动了本地服务器并注册了一些中间件;二是使用 ESbuild 预构建模块。之后就一直躺着,直到浏览器以 http 方式发来 ESM 规范的模块请求时,Vite 才开始“「按需编译」”被请求的模块。
相对于 Webpack
Webpack 启动后会做一堆事情,经历一条很长的编译打包链条,从入口开始需要逐步经历语法解析、依赖收集、代码转译、打包合并、代码优化,最终将高版本的、离散的源码编译打包成低版本、高兼容性的产物代码,在 Node 运行时下性能必然有问题。
继续
调用server.listen()
的逻辑在预构建一节中会详细介绍。回到createServer
函数,接下来会详细分析下面几个点
如何获取
config
配置创建一个兼容 rollup 钩子函数的对象
container
,这个是一个什么对象模块图谱实例
moduleGraph
是什么样子的
如何获取config
配置
在createServer
函数中,调用resolveConfig
获取config
配置
// inlineConfig 命令行传入的配置 const config = await resolveConfig(inlineConfig, 'serve', 'development') 复制代码
resolveConfig
函数也有很多内容,我们分块来看。
export async function resolveConfig( inlineConfig: InlineConfig, command: 'build' | 'serve', // 命令 defaultMode = 'development' // 环境 ): Promise<ResolvedConfig> { let config = inlineConfig // 命令行中的配置项 let configFileDependencies: string[] = [] let mode = inlineConfig.mode || defaultMode const configEnv = { mode, // 环境,开发环境下是 development command // 命令,开发环境下是 serve } let { configFile } = config // 配置文件路径 // ... 复制代码
刚进入函数,定义了 5 个变量,后面会用
Vite 是怎么找到配置文件的
继续向下
if (configFile !== false) { // 查找配置文件并获取配置文件中的 config 配置 const loadResult = await loadConfigFromFile( configEnv, configFile, // 命令行传入的配置文件路径 config.root, config.logLevel ) if (loadResult) { // 合并配置 config = mergeConfig(loadResult.config, config) // 获取配置文件绝对路径 configFile = loadResult.path // 获取 vite.config.js 中导入的非第三方文件列表(比如自定义插件、方法文件等) configFileDependencies = loadResult.dependencies } } 复制代码
loadConfigFromFile
函数中,也分为两步
查找、校验配置文件的路径,并判断文件类型(是不是
ts
、是不是遵循ESM规范)根据路径和类型获取文件内容
获取配置文件路径
// loadConfigFromFile 函数内 let resolvedPath: string | undefined let isTS = false let isMjs = false // 是不是 ESM 规范的文件 let dependencies: string[] = [] try { // 如果 package.json 中的 type 属性是 module,则说明配置文件遵循 ESM 规范 const pkg = lookupFile(configRoot, ['package.json']) if (pkg && JSON.parse(pkg).type === 'module') { isMjs = true } } catch (e) {} // 如果命令行中指定了配置文件路径,则获取此路径的绝对路径,并判断是不是 ts文件或者遵循 ESM 规范的文件 if (configFile) { resolvedPath = path.resolve(configFile) isTS = configFile.endsWith('.ts') if (configFile.endsWith('.mjs')) { isMjs = true } } else { // 此时没有指定配置文件路径 // 在项目根路径上查找 vite.config.js const jsconfigFile = path.resolve(configRoot, 'vite.config.js') // 如果存在,则将路径赋值给 resolvedPath if (fs.existsSync(jsconfigFile)) { resolvedPath = jsconfigFile } // 和上述逻辑相同,在项目根路径上查找 vite.config.mjs // 如果找到了,将路径赋值给 resolvedPath。并将 isMjs 置为 true if (!resolvedPath) { /* ... */ } // 和上述逻辑相同,在项目根路径上查找 vite.config.ts // 如果找到了,将路径赋值给 resolvedPath。并将 isTS 置为 true if (!resolvedPath) { /* ... */ } } // 如果没有,抛出异常 if (!resolvedPath) { debug('no config file found.') return null } 复制代码
查找过程很简单
从
package.json
中判断配置文件是否遵循 ESM 规范如果指定了配置文件路径,则校验该路径并判断文件类型
如果没有指定配置文件,从项目根目录按顺序查找
vite.config.js
、vite.config.mjs
、vite.config.ts
;并判断文件类型
找到配置文件后,开始获取配置文件内容
获取配置文件内容
let userConfig: UserConfigExport | undefined // 如果遵循 ESM 规范 if (isMjs) { const fileUrl = require('url').pathToFileURL(resolvedPath) // 如果是 ts 文件 if (isTS) { // 通过 ESbuild 打包文件,第二个参数表示打包后的文件类型,true是遵循ESM规范 const bundled = await bundleConfigFile(resolvedPath, true) // bundleConfigFile内调用的esbuild 的配置中设置了 metafile: true 用于生成依赖关系 // 并且手写了一个 esbuild 的 plugin,不会将第三方库打包在 bundle 中,即生成的依赖也不会包含第三方库 // 所以这里的 dependencies 内容,只包含用户自己写的文件 dependencies = bundled.dependencies // 新建 js 文件,并将打包后的代码写入文件中 fs.writeFileSync(resolvedPath + '.js', bundled.code) // 通过 import() 动态加载刚创建的 js 文件,并获取导出内容 userConfig = (await dynamicImport(`${fileUrl}.js?t=${Date.now()}`)) .default // 删除刚创建的文件 fs.unlinkSync(resolvedPath + '.js') } else { // 直接动态加载该文件,并获取导出内容 userConfig = (await dynamicImport(`${fileUrl}?t=${Date.now()}`)).default } } 复制代码
如果明确知道配置文件遵循ESM规范,则通过import()
的方式加载文件获取导出内容。对于ts文件通过 ESbuild 打包文件。
还有一种情况就是配置文件是js
文件,并且package.json
中没有明确指出type: "module"
,此时这个js
文件要么遵循ESM
,要么遵循CommonJS
。继续看代码
try { let userConfig: UserConfigExport | undefined if (isMjs) { const fileUrl = require('url').pathToFileURL(resolvedPath) if (isTS) {} else {} } // 如果 userConfig 为空,先尝试直接加载文件,假设遵循commonjs if (!userConfig && !isTS && !isMjs) { try { // 清空 require 中的缓存 delete require.cache[require.resolve(resolvedPath)] // 重新 require userConfig = require(resolvedPath) } catch (e) {} } // 如果 userConfig 依然没有 // 说明配置文件有几种可能,ts文件、遵循ESM、package.json中设置type是module但是配置文件遵循CommonJS规范 if (!userConfig) { // 通过 esbuild 打包成 CommonJS,因为不确定该配置文件到底是遵循什么规范 const bundled = await bundleConfigFile(resolvedPath) // 获取依赖信息 dependencies = bundled.dependencies // 获取配置 // 这里用这个函数的主要作用是即可以获取遵循ESM规范的导出,又可以获取遵循CommonJS规范的导出 userConfig = await loadConfigFromBundledFile(resolvedPath, bundled.code) } // 如果 配置文件导出的是一个函数,则执行该函数 const config = await( typeof userConfig === 'function' ? userConfig(configEnv) : userConfig ) if (!isObject(config)) { throw new Error(`config must export or return an object.`) } // 返回 配置文件路径、配置文件导出内容、以及用户自定义导入 return { path: normalizePath(resolvedPath), config, dependencies, // 自定义组件列表 } } catch (e) {} 复制代码
这里就不过多解释了,注释已经很清楚了。最后就是获取到了配置文件的导出内容,并返回一个对象。要注意下对象内的属性
path: 配置文件路径, config: 配置文件导出内容, dependencies: 非第三方导入(包含用户自定义的插件) 复制代码
到此,已经拿到了配置文件内容,接下来就是需要合并喝规范化配置项,方便后续使用
合并和规范配置项
// 查找配置文件并获取配置文件中的 config 配置 const loadResult = await loadConfigFromFile(/* ... */) if (loadResult) { // 合并配置 config = mergeConfig(loadResult.config, config) // 获取配置文件绝对路径 configFile = loadResult.path // 非第三方导入的文件(比如自定义插件) configFileDependencies = loadResult.dependencies } 复制代码
调用mergeConfig
函数合并命令行配置和vite.config.js
的配置。配置合并完成之后,就开始处理配置项
处理配置
plugins
// 获取打包环境 development、production mode = inlineConfig.mode || config.mode || mode configEnv.mode = mode // 根据 apply 属性将当前环境不支持的 plugins 过滤掉 const rawUserPlugins = (config.plugins || []).flat().filter((p) => { if (!p) { return false } else if (!p.apply) { return true } else if (typeof p.apply === 'function') { return p.apply({ ...config, mode }, configEnv) } else { return p.apply === command } }) as Plugin[] 复制代码
自定义插件的apply
属性表示在什么环境下执行。上面这段代码的意思是
如果没有
apply
,表示在开发、生产环境下都会添加到rawUserPlugins
等待执行如果
apply
是一个函数,函数返回值是true
,添加到rawUserPlugins
等待执行如果
apply
的属性值等于当前环境字符串(serve
、build
),则添加到rawUserPlugins
等待执行
过滤完之后,对rawUserPlugins
中所有插件分类,根据enforce
的属性值分类,代码如下
// /** * 属性值为 pre:表示提前执行的插件,放到 prePlugins 中 * 属性值为 post:表示最后执行的插件,放到 postPlugins 中 * 没有设置或者设置的是其他属性值:表示正常执行的插件,放到 normalPlugins 中 */ const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawUserPlugins) export function sortUserPlugins(plugins) { const prePlugins: Plugin[] = [] const postPlugins: Plugin[] = [] const normalPlugins: Plugin[] = [] if (plugins) { plugins.flat().forEach((p) => { if (p.enforce === 'pre') prePlugins.push(p) else if (p.enforce === 'post') postPlugins.push(p) else normalPlugins.push(p) }) } return [prePlugins, normalPlugins, postPlugins] } 复制代码
接下来就是执行所有自定义插件的config
钩子函数
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins] for (const p of userPlugins) { // 执行所有自定义插件的 config 钩子函数 // 并传入vite的配置项和 configEnv // configEnv(对象内部: mode: 'development'|'production', command: 'serve'|'build' ) if (p.config) { const res = await p.config(config, configEnv) // 也就是说 config 钩子函数可以修改配置项,并返回新的配置项 // 拿到新的配置项之后,让新的配置项和老的配置项合并 if (res) { config = mergeConfig(config, res) } } } 复制代码
这里有一个需要注意的点就是,config
钩子函数可以修改配置项并返回新的配置项,拿到新配置项之后会合并新老配置项
最后会将 Vite 自带的插件和用户自定义的插件合并,后面会说
root 处理
const resolvedRoot = normalizePath( config.root ? path.resolve(config.root) : process.cwd() ) 复制代码
获取config.root
配置项的绝对路径或当前node命令执行时所在的文件夹目录
注意区分一下
process.cwd()
和__dirname
process.cwd()
:指当前node命令执行时所在的文件夹目录
__dirname
是指被执行js文件所在的文件夹目录
alias
// 创建新的alias // /^[\/]?@vite\/env/ 替换成 'vite/dist/client/env.mjs' // /^[\/]?@vite\/client/ 替换成 'vite/dist/client/client.mjs' const clientAlias = [ { find: /^[\/]?@vite\/env/, replacement: () => ENV_ENTRY }, { find: /^[\/]?@vite\/client/, replacement: () => CLIENT_ENTRY }, ] // 将 clientAlias 和 配置项中的 alias 合并并返回 const resolvedAlias = mergeAlias( clientAlias, config.resolve?.alias || config.alias || [] ) 复制代码
合并完成之后的alias
数据结构和上面的clientAlias
一致。
resolve
// 获取 resolve 所有配置项 const resolveOptions: ResolvedConfig['resolve'] = { dedupe: config.dedupe, ...config.resolve, alias: resolvedAlias, } 复制代码
拼接 resolve
配置
.env 文件
// 如果没有设置 config.envDir 则获取项目根路径 const envDir = config.envDir ? normalizePath(path.resolve(resolvedRoot, config.envDir)) : resolvedRoot // 获取所有.env 文件中的属性 const userEnv = inlineConfig.envFile !== false && loadEnv(mode, envDir, resolveEnvPrefix(config)) 复制代码
loadEnv
函数根据envDir
依次查找下面4个文件
.env.development.local
.env.development
.env.local
.env
如果找到了调用dotenv
解析该.env
文件。
如果设置了config.envPrefix
则只获取config.envPrefix
前缀的变量。如果没设置config.envPrefix
则获取VITE_
开头的变量。
其他配置
// 解析 base const BASE_URL = resolveBaseUrl(config.base, command === 'build', logger) // 生产环境相关 const resolvedBuildOptions = resolveBuildOptions(config.build) // 获取 package.json 路径 const pkgPath = lookupFile(resolvedRoot, [`package.json`], true /* pathOnly */) // 获取/设置缓存目录,默认是 node_modules/.vite const cacheDir = config.cacheDir ? path.resolve(resolvedRoot, config.cacheDir) : pkgPath && path.join(path.dirname(pkgPath), `node_modules/.vite`) // 指定其他文件类型作为静态资源处理(这样导入它们就会返回解析后的 URL) const assetsFilter = config.assetsInclude ? createFilter(config.assetsInclude) // 构造一个过滤函数,该函数可用于确定是否应该对某些模块进行操作 : () => false // 创建在特殊场景中使用的内部解析器 // 比如,预构建时,用于解析路径 const createResolver: ResolvedConfig['createResolver'] = (options) => { let aliasContainer: PluginContainer | undefined let resolverContainer: PluginContainer | undefined return async (id, importer, aliasOnly, ssr) => {} } const { publicDir } = config // 获取静态资源地址 const resolvedPublicDir = publicDir !== false && publicDir !== '' ? path.resolve( resolvedRoot, typeof publicDir === 'string' ? publicDir : 'public' ) : '' 复制代码
上述配置处理完成之后,创建resolved
对象,并拼接配置
resolved对象
const resolved: ResolvedConfig = { ...config, configFile: configFile ? normalizePath(configFile) : undefined, // 配置文件路径 configFileDependencies, // vite.config.js 中非第三方包的导入,比如自定义插件 inlineConfig, // 命令行中的配置 root: resolvedRoot, // 项目根目录 base: BASE_URL, // 公共基础路径, /my-app/index.html resolve: resolveOptions, // 文件解析时的相关配置 publicDir: resolvedPublicDir, // 静态资源服务的文件夹 cacheDir, // 缓存目录,默认 node_modules/.vite command, // serve | build mode, // development | production isProduction, // 是否是生产环境 plugins: userPlugins, // 自定义 plugins server: resolveServerOptions(resolvedRoot, config.server), build: resolvedBuildOptions, env: { ...userEnv, // .env 文件 BASE_URL, MODE: mode, DEV: !isProduction, PROD: isProduction, }, assetsInclude(file: string) { // 一个函数,用于获取传入的 file 是否能作为静态资源处理,如果能,导入它们就会返回解析后的 URL return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file) }, logger, createResolver, // 特殊场景中使用的内部解析器,预构建文件中会说 optimizeDeps: { ...config.optimizeDeps, esbuildOptions: { // esbuild 配置 keepNames: config.optimizeDeps?.keepNames, preserveSymlinks: config.resolve?.preserveSymlinks, ...config.optimizeDeps?.esbuildOptions, }, }, } 复制代码
这个resolved
对象就是最后处理完的所有配置。最后resolveConfig
函数也会返回这个resolved
对象。
从上面resolved.optimizeDeps.esbuildOptions
可以看出,如果想配置esbuild的配置项,可以通过下面的方式
{ resolve: { preserveSymlinks: boolean } optimizeDeps: { keepNames: boolean esbuildOptions: {} } } 复制代码
Vite 自带的插件和用户自定义的插件合并
处理插件的时候说过最后还会整合Vite自带的插件,代码如下
// 将vite自带插件和用户定义插件安顺序组合,并返回 ;(resolved.plugins as Plugin[]) = await resolvePlugins( resolved, prePlugins, normalPlugins, postPlugins ) // 只包含开发环境中使用的插件 export async function resolvePlugins( config: ResolvedConfig, prePlugins: Plugin[], normalPlugins: Plugin[], postPlugins: Plugin[] ): Promise<Plugin[]> { return [ preAliasPlugin(), aliasPlugin({ entries: config.resolve.alias }), ...prePlugins, // 自定义前置插件 config.build.polyfillModulePreload ? modulePreloadPolyfillPlugin(config) : null, resolvePlugin({ ...config.resolve, root: config.root, isProduction: config.isProduction, ssrConfig: config.ssr, asSrc: true, }), htmlInlineScriptProxyPlugin(config), cssPlugin(config), config.esbuild !== false ? esbuildPlugin(config.esbuild) : null, jsonPlugin( { namedExports: true, ...config.json, } ), wasmPlugin(config), webWorkerPlugin(config), assetPlugin(config), ...normalPlugins, // 自定义插件 definePlugin(config), cssPostPlugin(config), ...postPlugins // 自定义后置插件 } 复制代码
所有插件拼接好之后,调用所有自定义插件的configResolved
钩子函数
await Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)))
作者:zygg不含糖
链接:https://juejin.cn/post/7044086324306903048