初识 vite 原理,vite 是如何启动项目的

我们使用 vite 的时候,只需要在 package.json 中定义一个简单的命令,就可以启动项目,那么这个简单的命令,是如何启动 vite 项目的呢,下面我们来详细介绍一下

1"scripts": { 2 "dev": "vite" 3},

执行命令行脚本

在执行 pnpm install 命令的时候,node_modules/.bin 下会创建多个命令行脚本。当执行 vite 命令的时候,就会找到 .bin 目录的下的 vite 脚本,从 vite 脚本中可以看到,执行的是 vite/bin/vite.js 文件

1689678960545.png

vite.js 中的核心内容就是执行了 start 方法,动态引入了 ../dist/node/cli.js ,这个地址是打包后的地址,在 vite 源码中,脚本地址在 packages/vite/src/node/cli.ts

1function start() { 2 return import('../dist/node/cli.js') 3} 4 5start()

cli.ts 的核心功能是解析命令行参数并启动本地项目,解析命令行参数通过 cac 库,这里我们主要看启动本地项目的命令。在 cac 库中,通过 command 定义基础命令,通过 alias 方法定于命令别名,通过 option 方法定义命令行参数,最后通过 action 方法执行具体的操作

1const cli = cac('vite') 2 3cli 4 .command('[root]', 'start dev server') // default command 5 .alias('serve') // the command is called 'serve' in Vite's API 6 .alias('dev') // alias to align with the script name 7 .option('--host [host]', `[string] specify hostname`) 8 .option('--port <port>', `[number] specify port`) 9 .option('--https', `[boolean] use TLS + HTTP/2`) 10 .option('--open [path]', `[boolean | string] open browser on startup`) 11 .option('--cors', `[boolean] enable CORS`) 12 .option('--strictPort', `[boolean] exit if specified port is already in use`) 13 .option( 14 '--force', 15 `[boolean] force the optimizer to ignore the cache and re-bundle` 16 ) 17 .action()

action 方法中,最核心的部分就是引入了 createServer 方法,通过 listen 启动本地 server

1cli.action(async (/* 入参数 */) => { 2 filterDuplicateOptions(options) 3 // 核心:启动本地 server 4 const { createServer } = await import('./server') 5 try { 6 const server = await createServer({ 7 root, 8 base: options.base, 9 mode: options.mode, 10 configFile: options.config, 11 logLevel: options.logLevel, 12 clearScreen: options.clearScreen, 13 optimizeDeps: { force: options.force }, 14 server: cleanOptions(options), 15 }) 16 17 await server.listen() 18 19 // 控制台输出本地 server 启动结果 20 const info = server.config.logger.info 21 22 server.printUrls() 23 // 定义控制台的操作快捷键 24 bindShortcuts(server, {}) 25 } catch (e) { 26 process.exit(1) 27 } 28})

这里我们小结一下,在输入命令启动项目时,通过寻找 node_module/.bin 目录下的 vite 脚本,再执行 cli.ts 文件中引入的 createServer 方法启动本地项目

接下来我们来分析核心的 createServer 方法

createServer

createServer 方法有八个核心步骤,如下图所示,每个步骤展开来都很可以很深入的分析,所以我计划在这篇文章中先整体介绍实现过程,在后续的文章中再深入的分析几个重要步骤的实现原理

1689982737902.png

第一步:配置参数解析

参数解析涉及三个部分

  1. resolveConfig 方法解析 vite 核心配置,包括来自命令行、vite.config 文件的配置参数
  2. resolveHttpsConfig 方法解析 https 相关的配置,用来在开发环境模拟 https
  3. resolveChokidarOptions 方法解析 chokidar 相关配置,主要和监听文件变动相关
1const config = await resolveConfig(inlineConfig, 'serve') 2const { root, server: serverConfig } = config 3const httpsOptions = await resolveHttpsConfig(config.server.https) 4const { middlewareMode } = serverConfig 5 6const resolvedWatchOptions = resolveChokidarOptions(config, { 7 disableGlobbing: true, 8 ...serverConfig.watch, 9})

第二步:创建 HTTP 和 WebSocket server

这一步通过 createHttpServer 创建一个 HTTP 服务器实例,根目录的 index.html 就是服务器的入口

通过 createWebSocketServer 创建 WebSocket 服务器,主要是用于实现热更新(HMR),当代码发生变化时,服务器通过 WebSocket 向客户端发送更新通知

1const httpServer = middlewareMode 2 ? null 3 : await resolveHttpServer(serverConfig, middlewares, httpsOptions) 4const ws = createWebSocketServer(httpServer, config, httpsOptions) 5 6if (httpServer) { 7 setClientErrorHandler(httpServer, config.logger) 8}

第三步:启动 chokidar 启动监听文件

chokidar 能够创建一个文件监听器,监听文件和目录的变化,实时地响应文件的增删改操作,也是用于实现热更新功能

1const watcher = chokidar.watch( 2 [root, ...config.configFileDependencies, config.envDir], 3 resolvedWatchOptions 4) as FSWatcher 5 6// 文件变化操作 7watcher.on('change', async (file) => { 8 file = normalizePath(file) 9 moduleGraph.onFileChange(file) 10 11 await onHMRUpdate(file, false) 12}) 13 14// 文件新增和删除操作 15watcher.on('add', onFileAddUnlink) 16watcher.on('unlink', onFileAddUnlink)

第四步:创建 ModuleGraph 实例

第四步通过 ModuleGraph class 创建一个模块依赖图实例,模块依赖图主要用于维护各个模块之间的依赖关系,主要有两个用处

  1. 热更新过程中,通过模块依赖图获取所有相关依赖,保证正确完整的实现热更新
  2. 打包过程中,根据模块之间的依赖关系进行优化,比如将多个模块合并为一个请求、按需加载模块等,提高打包速度和加载性能
1const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) => 2 container.resolveId(url, undefined, { ssr }) 3)

第五步:创建插件容器

通过 createPluginContainer 方法创建插件容器,插件容器主要有三个功能

  1. 管理的插件的生命周期
  2. 在插件之间传递上下文对象,上下文对象包含 vite 的内部状态和配置信息,这样插件通过上下文对象就能访问和修改 vite 内部状态
  3. 根据插件的钩子函数,在特定的时机执行插件
1const container = await createPluginContainer(config, moduleGraph, watcher)

第六步:定义 ViteDevServer 对象

ViteDevServer 对象就是 createServer 方法最终返回的对象,主要包含前几步创建的对象实例和启动 server 相关的核心方法

其中比较特殊的是 createDevHtmlTransformFn 方法,这个方法用于在开发环境下转换 index.html 文件,默认注入一段客户端代码 /@vite/client ,用于在客户端创建 WebSocket,接收服务端热更新传递的消息

1const server: ViteDevServer = { 2 // ===== 核心属性 ===== 3 config, // 配置属性 4 middlewares, // 中间件 5 httpServer, // HTTP server 实例 6 watcher, // chokidar 文件监听实例 7 pluginContainer: container, // 插件容器 8 ws, // WebSocket 实例 9 moduleGraph, // 模块依赖图 10 11 // ===== 核心方法 ===== 12 // index.html 转换方法 13 transformIndexHtml: createDevHtmlTransformFn(server), 14 // 启动 server 方法 15 async listen(port?: number, isRestart?: boolean) { 16 ;/.../ 17 }, 18 // 打开浏览器 19 openBrowser() { 20 ;/.../ 21 }, 22 // 关闭 server 23 async close() { 24 ;/.../ 25 }, 26 // 打印 url 27 printUrls() { 28 ;/.../ 29 }, 30 // 重启 server 31 async restart(forceOptimize?: boolean) { 32 ;/.../ 33 }, 34}

第七步:执行 configureServer 定义函数

configureServer 主要用于配置开发服务器,比如在内部 connect 中添加自定义中间件。在这一步,从配置中获取所有 configureServer 钩子并放入 postHooks 钩子中,在内部中间中间件定义好之后,执行 postHooks 钩子

注意到 postHooks 是在处理 index.html 中间件之前执行,目的是为了自定义的中间件能够在返回 index.html 之前处理请求

1const postHooks: ((() => void) | void)[] = [] 2for (const hook of config.getSortedPluginHooks('configureServer')) { 3 postHooks.push(await hook(server)) 4} 5 6// 其他中间件定义... 7 8// 执行 post 插件 9postHooks.forEach((fn) => fn && fn()) 10 11// 处理 index.html 中间件 12middlewares.use(indexHtmlMiddleware(server))

第八步:定义内部中间件

通过 connect 包创建 middlewares 中间件。中间件主要是用来处理 HTTP 请求和响应,通过定义一系列的中间件并且按照一定的顺序执行,每个中间件函数对请求和响应进行处理,然后将处理后的请求和响应传递给下一个中间件函数,直到最后一个中间件函数处理完毕并发送响应

1import connect from 'connect' 2 3const middlewares = connect() as Connect.Server

定义好 middlewares 之后,通过 use 方法添加启动项目阶段需要的中间件,一共有 14 个

1// 计算操作执行时间 2middlewares.use(timeMiddleware(root)) 3 4// 处理是否允许跨域 5middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors)) 6 7// 请求代理 8middlewares.use(proxyMiddleware(httpServer, proxy, config)) 9 10// base 地址处理 11middlewares.use(baseMiddleware(server)) 12 13// 通过 launch-editor-middleware,在代码编辑器打开指定的文件,跳转到指定行号 14middlewares.use('/__open-in-editor', launchEditorMiddleware()) 15 16// 热更新 ping header 处理 17middlewares.use(function viteHMRPingMiddleware(req, res, next) { 18 if (req.headers['accept'] === 'text/x-vite-ping') { 19 res.writeHead(204).end() 20 } else { 21 next() 22 } 23}) 24 25// 处理 public 目录 26middlewares.use(servePublicMiddleware(config.publicDir, config.server.headers)) 27 28// 响应请求之前,对请求的文件进行预处理 29middlewares.use(transformMiddleware(server)) 30 31// 静态文件处理 32middlewares.use(serveRawFsMiddleware(server)) 33middlewares.use(serveStaticMiddleware(root, server)) 34 35// 处理单页应用退回问题 36middlewares.use(htmlFallbackMiddleware(root, config.appType === 'spa')) 37 38// 处理 index.html 39middlewares.use(indexHtmlMiddleware(server)) 40 41// 处理 404 情况 42middlewares.use(function vite404Middleware(_, res) { 43 res.statusCode = 404 44 res.end() 45}) 46 47// 错误处理 48middlewares.use(errorMiddleware(server, middlewareMode))

总结

最后总结一下,在开发过程中,vite 启动命令执行后,实际执行的是 node_module/.bin 目录下的 vite 脚本,在解析命令行参数之后,通过执行 createServer.listen 方法启动 vite

createServer 方法主要有 8 个执行步骤,分别是

  1. 配置参数解析,包括 vite 核心配置、https 配置、chokidar 配置
  2. 创建 HTTP 和 WebSocket server,用于启动开发 server 和热更新通信
  3. 启动 chokidar 文件监听器,监听文件变化,实现热更新
  4. 创建 ModuleGraph 实例,记录模块依赖关系
  5. 创建插件容器,管理插件生命周期、执行过程、插件之间传递上下文
  6. 定义 ViteDevServer 对象,包含核心配置和启动开发 server 核心方法
  7. 执行 configureServer 定义函数,创建自定义中间件
  8. 定义内部中间件

1689982737902.png