theme: cyanosis
highlight: monokai
当在控制台输入yarn run dev
时,执行对应源码的位置是node/cli.ts
。
``typescript 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
的回调函数,看下主要代码 typescript // 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
函数代码比较多,这里只捡重要的说 ```typescript // 删减版,包含主要流程 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
函数,接下来会详细分析下面几个点 1. 如何获取config
配置 2. 创建一个兼容 rollup 钩子函数的对象container
,这个是一个什么对象 3. 模块图谱实例moduleGraph
是什么样子的
如何获取config
配置
在createServer
函数中,调用resolveConfig
获取config
配置 typescript // inlineConfig 命令行传入的配置 const config = await resolveConfig(inlineConfig, 'serve', 'development')
resolveConfig
函数也有很多内容,我们分块来看。
```typescript export async function resolveConfig( inlineConfig: InlineConfig, command: 'build' | 'serve', // 命令 defaultMode = 'development' // 环境 ): Promise { let config = inlineConfig // 命令行中的配置项 let configFileDependencies: string[] = [] let mode = inlineConfig.mode || defaultMode
const configEnv = {
mode, // 环境,开发环境下是 development
command // 命令,开发环境下是 serve
}
let { configFile } = config // 配置文件路径
// ...
``` 刚进入函数,定义了 5 个变量,后面会用
Vite 是怎么找到配置文件的
继续向下 typescript 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规范) - 根据路径和类型获取文件内容
获取配置文件路径
```typescript // 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`;并判断文件类型
找到配置文件后,开始获取配置文件内容
获取配置文件内容
```typescript 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
。继续看代码 typescript 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) {}
这里就不过多解释了,注释已经很清楚了。最后就是获取到了配置文件的导出内容,并返回一个对象。要注意下对象内的属性 bash path: 配置文件路径, config: 配置文件导出内容, dependencies: 非第三方导入(包含用户自定义的插件)
到此,已经拿到了配置文件内容,接下来就是需要合并喝规范化配置项,方便后续使用
合并和规范配置项
typescript // 查找配置文件并获取配置文件中的 config 配置 const loadResult = await loadConfigFromFile(/* ... */) if (loadResult) { // 合并配置 config = mergeConfig(loadResult.config, config) // 获取配置文件绝对路径 configFile = loadResult.path // 非第三方导入的文件(比如自定义插件) configFileDependencies = loadResult.dependencies }
调用mergeConfig
函数合并命令行配置和vite.config.js
的配置。配置合并完成之后,就开始处理配置项
处理配置
plugins
typescript // 获取打包环境 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
的属性值分类,代码如下 ```typescript // /** * 属性值为 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`钩子函数
typescript 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 处理
typescript const resolvedRoot = normalizePath( config.root ? path.resolve(config.root) : process.cwd() )
获取config.root
配置项的绝对路径或当前node命令执行时所在的文件夹目录
注意区分一下
process.cwd()
和__dirname
process.cwd()
:指当前node命令执行时所在的文件夹目录__dirname
是指被执行js文件所在的文件夹目录
alias
```typescript // 创建新的alias // /^[\/]?@vite\/env/ 替换成 'vite/dist/client/env.mjs' // /^[\/]?@vite\/client/ 替换成 'vite/dist/client/client.mjs' const clientAlias = [ { find: /^[\/]?@vite\/env/, replacement: () => ENVENTRY }, { find: /^[\/]?@vite\/client/, replacement: () => CLIENTENTRY }, ]
// 将 clientAlias 和 配置项中的 alias 合并并返回 const resolvedAlias = mergeAlias( clientAlias, config.resolve?.alias || config.alias || [] ) `` 合并完成之后的
alias数据结构和上面的
clientAlias`一致。
resolve
typescript // 获取 resolve 所有配置项 const resolveOptions: ResolvedConfig['resolve'] = { dedupe: config.dedupe, ...config.resolve, alias: resolvedAlias, }
拼接 resolve
配置
.env 文件
typescript // 如果没有设置 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_
开头的变量。
其他配置
```typescript // 解析 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对象
typescript 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的配置项,可以通过下面的方式 bash { resolve: { preserveSymlinks: boolean } optimizeDeps: { keepNames: boolean esbuildOptions: {} } }
Vite 自带的插件和用户自定义的插件合并
处理插件的时候说过最后还会整合Vite自带的插件,代码如下 typescript // 将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
钩子函数 typescript await Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)))