Vite 源码(二)vite启动流程以及如何获取config配置当在控制台输入yarn run dev时,Vite做了什
当在控制台输入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)))
转载自:https://juejin.cn/post/7044086324306903048