前言
笔者几个月前已经发表了两篇关于Vite原理的文章:《Vite2.0原理解析之import路径重写机制》和《Vite原理解析系列之基于原生 ESM 的 HMR 实现》。本文将继续对 Vite 中一些核心模块的原理进行解析,让大家对 Vite 有一个更深入的理解。这次我们将对 Vite 启动流程进行详细解析,通过了解 Vite 的启动流程,我们可以初窥到 Vite 的‘全貌’。
Vite 启动时做了什么,实现了哪些服务
别看 Vite 启动非常快,其实它启动时做的事还真不少。这里我在下面给大家简要的列一下其启动时做了啥:
- 用户配置解析
- 执行依赖预构建(第一次启动或有依赖变更时)
- 启动文件监听,实现 HMR
- 挂载必要中间件和插件,实现一些增强功能(CSS 预处理器,TS转译等)
- 启动 node http server 服务器,提供基础静态文件服务
最终,一个Vite服务实现的功能如下所示:
为什么需要了解启动流程
Vite 作为一种新型的前端构建工具,其官网首推的最具吸引力的特性如下:
Vite 最具吸引力的特性之一就是它令人惊叹的服务启动速度,大多数时候从输入启动命令回车到 dev server ready用时一般都不到 2s。如果想要了解 Vite 启动快的秘诀,解析其启动流程也是最直接有效的方法。另外如果你了解 webpack 的启动流程,那么你会发现 Vite 的启动流程相比于 webpack 还是有着显著的区别的,还是非常值得一探的。最后在了解了Vite的启动流程之后,今后在使用 Vite 时遇到与其启动有关的问题也可以快速明确问题的原因并解决它。
Vite启动流程解析
启动流程总览
下面是 Vite 启动流程,当我们在命令行中输入npm run dev
时,一个 Vite 服务就开始了,总体流程如下图所示:
这块逻辑实际就执行了两个操作,创建 server 对象并执行它的 listen 方法。后面我会按照这两个操作分别进行解析。详细实现如下所示,这部分完整代码传送门 cli.ts:
// dev
cli
.command('[root]') // default command
···
.option(
'--force',
`[boolean] force the optimizer to ignore the cache and re-bundle`
)
.action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
// output structure is preserved even after bundling so require()
// is ok here
const { createServer } = await import('./server')
try {
// 创建server对象
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
server: cleanOptions(options) as ServerOptions
})
// 启动服务
await server.listen()
} catch (e) {
···
}
})
详细流程解析
一、创建server实例
首先,脚手架会先执行用于创建server实例的 createServer 方法,这块也是 Vite 启动流程的重头戏。下面我会按照其功能逻辑分为6步,逐一进行解析。
1.配置解析汇总
在 Vite 启动流程的第一步,是将用户定义的配置、Vite 默认的配置以及命令行定义的配置进行合并汇总,最终获得一个合并后的完整的 config 对象,后续的大多数操作都会基于这个 config 进行。具体实现代码如下:
const config = await resolveConfig(inlineConfig, 'serve', 'development')
const root = config.root //项目根路经
const serverConfig = config.server //server相关配置
const httpsOptions = await resolveHttpsConfig(config) // 获取 https.createServer() 的选项对象
这块的核心逻辑都在 resolveConfig 方法中,这个方法会找到用户的配置文件读取配置,并判断当前的 command 是 serve 还是 build 来最终合并汇总出最终的 Vite 的配置,具体是实现逻辑这里就不进行详细展开了,有兴趣的同学可以参考 resolveConfig 源码 config.ts。具体的 Vite 配置项可以参考Vite官方配置文档。为了方便大家对生成的配置对象有一个直观的概念,我这里放一个生成的 config 对象的一个 demo 给大家参考下:
{
plugins: [
{
name: 'vite:pre-alias',
configureServer: [Function: configureServer],
resolveId: [Function: resolveId]
},
···
{
name: 'vite:import-analysis',
configureServer: [Function: configureServer],
transform: [AsyncFunction: transform]
}
],
server: {
port: 7001,
fsServe: {
root: '/Users/jerry/github-project/xxx-admin',
strict: false
}
},
css: {
preprocessorOptions: { less: [Object] },
modules: { scopeBehaviour: 'local' }
},
build: {
target: [ 'es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1' ],
polyfillDynamicImport: false,
outDir: 'dist',
assetsDir: 'assets',
assetsInlineLimit: 4096,
cssCodeSplit: false,
sourcemap: false,
rollupOptions: { output: [Object] },
commonjsOptions: { include: [Array], extensions: [Array] },
minify: 'esbuild',
terserOptions: {},
cleanCssOptions: {},
write: true,
emptyOutDir: true,
manifest: false,
lib: false,
ssr: false,
ssrManifest: false,
brotliSize: true,
chunkSizeWarningLimit: 800,
watch: null
},
optimizeDeps: { esbuildOptions: { keepNames: undefined } },
resolve: { dedupe: undefined, alias: [ [Object], [Object] ] },
configFile: '/Users/jerry/github-project/xxx-admin/vite.config.ts',
configFileDependencies: [ 'vite.config.ts' ],
inlineConfig: {
root: undefined,
base: undefined,
mode: undefined,
configFile: undefined,
logLevel: undefined,
clearScreen: undefined,
server: {}
},
root: '/Users/jerry/github-project/xxx-admin',
base: '/',
publicDir: '/Users/jerry/github-project/xxx-admin/public',
cacheDir: '/Users/jerry/github-project/xxx-admin/node_modules/.vite',
command: 'serve',
mode: 'development',
isProduction: false,
env: { BASE_URL: '/', MODE: 'development', DEV: true, PROD: false },
assetsInclude: [Function: assetsInclude],
logger: {
hasWarned: false,
info: [Function: info],
warn: [Function: warn],
warnOnce: [Function: warnOnce],
error: [Function: error],
clearScreen: [Function: clearScreen]
},
createResolver: [Function: createResolver]
}
2.定义并初始化ViteDevServer对象以及相关的各类基础实例(ws、connect、watcher等)
上一步我们有了配置之后,接下来就是各种实例的初始化操作。这一步我们会先初始化中间件、node http server实例,web socket实例、chokidar 监听器实例等,同时还会创建 Vite 插件容器用于承载其功能强大的内置插件,具体逻辑如下:
// 生成 Connect 框架存储中间件的对象
const middlewares = connect() as Connect.Server
// 判断是否为中间件模式,新建 http server 服务对象
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)
// 根据配置创建websocket服务
const ws = createWebSocketServer(httpServer, config, httpsOptions)
// 根据配置生成watcher实现对文件系统的监听
const { ignored = [], ...watchOptions } = serverConfig.watch || {}
const watcher = chokidar.watch(path.resolve(root), {
ignored: ['**/node_modules/**', '**/.git/**', ...ignored],
ignoreInitial: true,
ignorePermissionErrors: true,
disableGlobbing: true,
...watchOptions
}) as FSWatcher
// 新建插件容器、模块图等vite dev server必备模块
const plugins = config.plugins
const container = await createPluginContainer(config, watcher)
const moduleGraph = new ModuleGraph(container)
const closeHttpServer = createServerCloseFn(httpServer)
当上面的这些 Vite dev server 必备的实例对象初始化完毕之后,就会输出到 Vite dev server 对象上的相应字段上,最终构建出一个完整的 Vite dev server对象。从 Vite 官方文档中可以获取到 Vite dev server 的详细 API 说明。
下面是在Vite源码中,一个完整的 Vite dev server对象:
const server: ViteDevServer = {
/**
* 被解析的 vite 配置对象
*/
config: config,
/**
* 一个 connect 应用实例
* - 可以用于将自定义中间件附加到开发服务器。
* - 还可以用作自定义http服务器的处理函数。
或作为中间件用于任何 connect 风格的 Node.js 框架。
*
* https://github.com/senchalabs/connect#use-middleware
*/
middlewares,
get app() {
config.logger.warn(
`ViteDevServer.app is deprecated. Use ViteDevServer.middlewares instead.`
)
return middlewares
},
/**
* 本机 node http 服务器实例
*/
httpServer,
/**
* chokidar 监听器实例
* https://github.com/paulmillr/chokidar#api
*/
watcher,
/**
* Rollup 插件容器,可以针对给定文件运行插件钩子。
*/
pluginContainer: container,
/**
* web socket 服务器,带有 `send(payload)` 方法。
*/
ws,
/**
* 跟踪导入关系、url 到文件映射和 hmr 状态的模块图。
*/
moduleGraph,
/**
* 使用 esbuild 转换一个文件的工具函数
* 对某些特定插件十分有用
*/
transformWithEsbuild,
/**
* 以代码方式解析、加载和转换 url 并获取结果
* 而不需要通过 http 请求管道。
*/
transformRequest(url, options) {
return transformRequest(url, server, options)
},
/**
* 应用 vite 内建 HTML 转换和任意插件 HTML 转换
*/
transformIndexHtml: null as any,
/**
* 加载一个给定的 URL 作为 SSR 的实例化模块
*/
ssrLoadModule(url) {
if (!server._ssrExternals) {
server._ssrExternals = resolveSSRExternal(
config,
server._optimizeDepsMetadata
? Object.keys(server._optimizeDepsMetadata.optimized)
: []
)
}
return ssrLoadModule(url, server)
},
/**
* 解决 ssr 错误堆栈信息
*/
ssrFixStacktrace(e) {
if (e.stack) {
e.stack = ssrRewriteStacktrace(e.stack, moduleGraph)
}
},
/**
* 启动服务器
*/
listen(port?: number, isRestart?: boolean) {
return startServer(server, port, isRestart)
},
/**
* 停止服务器
*/
async close() {
process.off('SIGTERM', exitProcess)
if (!process.stdin.isTTY) {
process.stdin.off('end', exitProcess)
}
await Promise.all([
watcher.close(),
ws.close(),
container.close(),
closeHttpServer()
])
},
_optimizeDepsMetadata: null,
_ssrExternals: null,
_globImporters: {},
_isRunningOptimizer: false,
_registerMissingImport: null,
_pendingReload: null
}
3.绑定必要监听事件,支持HMR服务
接下来,开始进行一些监听事件的绑定,以实现HMR功能,这里的核心逻辑就是在触发change事件时,更新 moduleGraph 中的缓存,触发热更新方法 handleHMRUpdate 实现模块热更新。关于Vite热更新实现原理,这里就不进行详细展开说明了,后续会有文章进行专门的解析。这块的具体实现如下:
// 绑定监听事件
watcher.on('change', async (file) => {
file = normalizePath(file)
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
if (serverConfig.hmr !== false) {
try {
// 执行热更新逻辑
await handleHMRUpdate(file, server)
} catch (err) {
ws.send({
type: 'error',
err: prepareError(err)
})
}
}
})
watcher.on('add', (file) => {
handleFileAddUnlink(normalizePath(file), server)
})
watcher.on('unlink', (file) => {
handleFileAddUnlink(normalizePath(file), server, true)
})
4.执行插件列表中每个插件的 configureServer 钩子
从 Vite 官网,我们了解到 configureServer 是 Vite 插件独有的一个钩子,主要被用于注入自定义中间件以及存储 vite dev server对象,详情可以参考这里。值得注意的是,这个方法是在 Vite 挂载内置中间件之前执行的。为了能够实现注入后置中间件的功能,这里定义了一个 postHooks 数组用于存储方法返回值,这个数组会在内置中间件挂载完成之后进行遍历,当返回值是一个函数时,执行该函数。具体实现代码如下:
// apply server configuration hooks from plugins
const postHooks: ((() => void) | void)[] = []
for (const plugin of plugins) {
if (plugin.configureServer) {
postHooks.push(await plugin.configureServer(server))
}
}
// 第5步逻辑--挂载中间件
...
postHooks.forEach((fn) => fn && fn())
5.挂载内置中间件
这一步主要是对Vite的一些内置的中间件进行挂载。此处挂载的 Vite 内置中间件如下:
这些中间件命名都十分规范,基本都可以通过其名称知道其大致的用途,这里就不对每个中间件进行详细展开了,后续会有相关文章对一些核心的中间件进行解读。这一步主要就是使用之前调用 connect() 方法返回的 middlewares 实例,根据配置进行中间件的 use 操作,其具体逻辑如下:
// cors (enabled by default)
const { cors } = serverConfig
if (cors !== false) {
middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
}
// proxy
const { proxy } = serverConfig
if (proxy) {
middlewares.use(proxyMiddleware(httpServer, config))
}
// base
if (config.base !== '/') {
middlewares.use(baseMiddleware(server))
}
// open in editor support
middlewares.use('/__open-in-editor', launchEditorMiddleware())
// hmr reconnect ping
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
middlewares.use('/__vite_ping', function viteHMRPingMiddleware(_, res) {
res.end('pong')
})
//decode request url
middlewares.use(decodeURIMiddleware())
// serve static files under /public
// this applies before the transform middleware so that these files are served
// as-is without transforms.
if (config.publicDir) {
middlewares.use(servePublicMiddleware(config.publicDir))
}
// main transform middleware
middlewares.use(transformMiddleware(server))
// serve static files
middlewares.use(serveRawFsMiddleware(config))
middlewares.use(serveStaticMiddleware(root, config))
// spa fallback
if (!middlewareMode || middlewareMode === 'html') {
middlewares.use(
history({
logger: createDebugger('vite:spa-fallback'),
// support /dir/ without explicit index.html
rewrites: [
{
from: //$/,
to({ parsedUrl }: any) {
const rewritten = parsedUrl.pathname + 'index.html'
if (fs.existsSync(path.join(root, rewritten))) {
return rewritten
} else {
return `/index.html`
}
}
}
]
})
)
}
if (!middlewareMode || middlewareMode === 'html') {
// transform index.html
middlewares.use(indexHtmlMiddleware(server))
// handle 404s
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
middlewares.use(function vite404Middleware(_, res) {
res.statusCode = 404
res.end()
})
}
// error handler
middlewares.use(errorMiddleware(server, !!middlewareMode))
6.绑定预构建方法和插件的buildStart钩子
最后,将执行预构建的方法 runOptimize() 和插件的 buildStart 钩子绑定到 httpServer.listen 上,让预构建和插件的 buildStart 钩子能够在服务器启动之前执行。具体实现逻辑如下:
// 执行预构建
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
// 判断是否为中间件模式
if (!middlewareMode && httpServer) {
// overwrite listen to run optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
try {
// 执行插件钩子和预构建
await container.buildStart({})
await runOptimize()
} catch (e) {
httpServer.emit('error', e)
return
}
return listen(port, ...args)
}) as any
httpServer.once('listening', () => {
// update actual port since this may be different from initial value
serverConfig.port = (httpServer.address() as AddressInfo).port
})
} else {
await container.buildStart({})
await runOptimize()
}
return server
关于预构建
上面提到了'预构建',或许一些同学不太了解何谓’预构建‘,这里做一个简单的解释:
Vite的依赖预构建又称依赖预优化,它主要干了两件事,一是将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM,这是因为在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。二是将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。后续会有文章专门对 Vite 依赖预构建的原理信息解读,大家可以关注下。
关于middlewareMode
上面解析流程中多次出现了 middlewareMode ,如果这个字段设置为非false的值,将会以中间件模式创建 Vite 服务器。这个模式主要用于SSR场景,目前还处于实验阶段,本文暂不作展开。
二、启动服务监听
在服务对象创建完毕之后,就会调用 server.listen() 由于在上面的步骤中绑定了执行预构建的方法 runOptimize和 buildStart,因此调用 listen 方法时,会先执行Vite插件 buildStart 钩子和依赖预构建方法,然后再启动服务。
下面是 Vite 服务启动的逻辑:
// 服务启动方法
async function startServer(
server: ViteDevServer,
inlinePort?: number,
isRestart: boolean = false
): Promise<ViteDevServer> {
const httpServer = server.httpServer
if (!httpServer) {
throw new Error('Cannot call server.listen in middleware mode.')
}
// 获取必要配置参数
const options = server.config.server
let port = inlinePort || options.port || 3000
const hostname = resolveHostname(options.host)
const protocol = options.https ? 'https' : 'http'
const info = server.config.logger.info
const base = server.config.base
return new Promise((resolve, reject) => {
// 端口占用错误反馈
const onError = (e: Error & { code?: string }) => {
if (e.code === 'EADDRINUSE') {
if (options.strictPort) {
httpServer.removeListener('error', onError)
reject(new Error(`Port ${port} is already in use`))
} else {
info(`Port ${port} is in use, trying another one...`)
httpServer.listen(++port, hostname.host)
}
} else {
httpServer.removeListener('error', onError)
reject(e)
}
}
httpServer.on('error', onError)
httpServer.listen(port, hostname.host, () => {
httpServer.removeListener('error', onError)
// 打印服务器地址、端口号等
info(
chalk.cyan(`\n vite v${require('vite/package.json').version}`) +
chalk.green(` dev server running at:\n`),
{
clear: !server.config.logger.hasWarned
}
)
printServerUrls(hostname, protocol, port, base, info)
// 打印服务启动时间
// @ts-ignore
if (global.__vite_start_time) {
info(
chalk.cyan(
// @ts-ignore
`\n ready in ${Date.now() - global.__vite_start_time}ms.\n`
)
)
}
// @ts-ignore
const profileSession = global.__vite_profile_session
if (profileSession) {
profileSession.post('Profiler.stop', (err: any, { profile }: any) => {
// Write profile to disk, upload, etc.
if (!err) {
const outPath = path.resolve('./vite-profile.cpuprofile')
fs.writeFileSync(outPath, JSON.stringify(profile))
info(
chalk.yellow(
` CPU profile written to ${chalk.white.dim(outPath)}\n`
)
)
} else {
throw err
}
})
}
// 自动打开浏览器逻辑
if (options.open && !isRestart) {
const path = typeof options.open === 'string' ? options.open : base
openBrowser(
`${protocol}://${hostname.name}:${port}${path}`,
true,
server.config.logger
)
}
resolve(server)
})
})
}
总结
从上面的流程解析中,相信大家可以很明显的发现,Vite 在启动流程中并没有任何与编译打包有关的步骤,相较于传统类构建工具(webpack)大大节省了启动时间,这或许就是其快的秘诀之一。Vite 代表的不只是一个框架,更是一种趋势。笔者在写本文时,已经实现了一个中后台系统的 Vite 落地,明确了在实际项目中应用 Vite 构建工具,确实能够很大的提升开发体验和研发效率。相信在不远的未来,前端构建工具一定会经历一次’改朝换代‘。