Vite项目启动都做了什么?

本文探讨Vite项目启动过程,包括npm run dev启动、服务开启、浏览器访问、热更新的详细步骤。Vite利用esbuild预构建模块并缓存,实现快速加载和按需加载。在热更新中,Vite原生ESM实现模块变更后仅重新加载变更部分,提高开发效率。文章还涉及浏览器缓存优化策略,以及npm包如cac、picocolors、esbuild的作用。

在之前的开发中我们主要运用 vue-cli 来搭建项目,它是基于 webpack 的脚手架工具。随着 vite 的出现,这个可以提供更快、更精简的构建工具,给我们带来了另一种开发体验。在后续的项目中我们也尝试使用 vite 做开发,整个过程十分流畅,因此也想通过对源码的分析,去深入了解 vite 及 vite 插件在项目开发中起到的作用,以及是怎么加载 vue 文件的。

npm init vue@latest 

可以看到在 vite.config.ts 中会有默认的基础配置,其中涉及到 vite 官方插件 @vitejs/plugin-vue ,对于处理浏览器不识别的 .vue 文件,起到关键性作用。

过程分析

我们从 npm run dev 开始,会涉及到项目启动运行的过程、浏览器访问后文件的处理、文件热更新及缓存等,沿着这条思路,去分析下 vite 项目在启动及开发的过程中都做了什么?

运行项目

当我们在终端输入 npm run dev 的时候,npm 会在当前目录的 node_modules/.bin 查找要执行的程序,当使用 npm install 安装 vite 依赖时,在 vite 的package.json 文件中配置有 bin 字段,此时就会在 node_modules 文件夹下面的 .bin 目录中复制 bin 字段链接的执行文件,它其实是命令名到本地文件名的映射,bin 文件中呈现的是 shell 脚本,去执行 vite/bin/vite.js 文件,这样做是因为命令行中并没有 vite 的命令,我们在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。

启动服务

根据 vite/bin/vite.js 路径,在 vite 源码中的 bin 目录可以下找到 vite.js 文件,会调用 start 方法,引入 dist 目录下的服务端文件,该文件为打包后的内容,具体代码可以vite/src/node/cli.ts中查看,用于启动服务端。

cli.ts中,首先通过 cac (一个JavaScript库,用于构建应用的CLI),创建命令行交互,用 picocolors 来修改终端输出字符的样式,它和 chalk 的功能一样,但是体积小,速度快,满足研发过程中的需要,如果你正在开发一个库,可以试一下这两个的组合。

当在终端输入npm run dev时,会执行 cli.action 中的回调函数:

import { cac } from 'cac'
import colors from 'picocolors'

// dev
cli.command('[root]', 'start dev server') // default command.alias('serve') // the command is called 'serve' in Vite's API.alias('dev') // alias to align with the script name....action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {const { createServer } = await import('./server')try {// 创建服务const server = await createServer({root,base: options.base,mode: options.mode,configFile: options.config,logLevel: options.logLevel,clearScreen: options.clearScreen,server: cleanOptions(options)})...await server.listen()...// 输出启动信息server.printUrls()} catch (e) {createLogger(options.logLevel).error(colors.red(`error when starting dev server:\n${e.stack}`),{ error: e })process.exit(1)} 

调用 createServer 函数,启动 server 服务,实现对浏览器请求的响应,在接下来的热更新中还会继续介绍该函数。关于创建服务,在vite@2.x 开始使用 connect 创建服务,而不是 koa。启动后 vite 会先通过 esbuild 对引入的 node_modules 模块及在 vite.config.ts 中配置的 optimizeDeps 内容执行预构建。

  • 因为 vite 需要将代码中 CommonJS / UMD 的依赖项转换为 ES 模块格式,并缓存入当前项目的 node_modules/.vite 中,避免重复构建
  • 页面中引用的 node_modules 模块中在其内部也会有多个模块依赖存在,导致浏览器发起多个请求,影响性能,需要将构建成一个模块,比如,在 main.js 中引入的 vue、vue-router、pinia 等模块:

关于 esbuild 是一款基于 Go 语言开发的 javascript 打包工具,它的构建速度是 webpack 的几十倍。

浏览器访问

由于现代浏览器已原生支持 ES 模块,可以通过 <script type="module"> 的方式加载标准的 ES 模块

<script type="module"> import { createApp } from "/node_modules/.vite/vue.js?v=c260ab7b"; </script> 

但是在浏览器中不支持裸模块的导入 import { createApp } from 'vue',在启动服务器 devServer后,需要重写引入路径,例如 /node_modules/.vite/vue.js?v=c260ab7b,当浏览器请求资源时,劫持浏览器的 http 请求,省去打包编译的过程,只有引入的模块才会加载,其他模块则不会,实现了真正的按需加载。对于浏览器不识别的非 JavaScript 文件(jsx,css 或者 vue/svelte 组件),依靠 vite 官方插件,进行代码转换后,再返回给浏览器。例如 .vue 文件,通过 @vitejs/plugin-vue,将 template、script、style部分,经过插件处理后在文件中 import 引入不同路径,在浏览器中,通过 type 类型区分不同请求:

热更新

在 vite 中,热更新是在原生 ESM 上执行的。当某个模块内容改变时,让浏览器去重新请求该模块,进行分解,返回给浏览器处理,不涉及编译,而 webpack 则会对模块相关依赖进行重新编译,随着项目的增大,其打包速度也会下降。

从源码中了解热更新:

createServer 的时候,通过 WebSocket 创建浏览器和服务器通信,使用 chokidar 监听文件的改变,当模块内容修改是,发送消息通知客户端,只对发生变更的模块重新加载。

export async function createServer( inlineConfig: InlineConfig = {} ): Promise<ViteDevServer> {// 生成所有配置项,包括vite.config.js、命令行参数等const config = await resolveConfig(inlineConfig, 'serve', 'development')// 初始化connect中间件const middlewares = connect() as Connect.Serverconst httpServer = middlewareMode? null: await resolveHttpServer(serverConfig, middlewares, httpsOptions)const ws = createWebSocketServer(httpServer, config, httpsOptions)// 初始化文件监听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 moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>container.resolveId(url, undefined, { ssr }))// 监听修改文件内容watcher.on('change', async (file) => {file = normalizePath(file)if (file.endsWith('/package.json')) {return invalidatePackageDjianata(packageCache, file)}// invalidate module graph cache on file changemoduleGraph.onFileChange(file)if (serverConfig.hmr !== false) {try {// 执行热更新await handleHMRUpdate(file, server)} catch (err) {ws.send({type: 'error',err: prepareError(err)})}}})// 主要中间件,请求文件转换,返回给浏览器可以识别的js文件middlewares.use(transformMiddleware(server))...return server
} 

当监听到文件内容变化 change 时,先执行 moduleGraph.onFileChange(file)

onFileChange(file: string): void {const mods = this.getModulesByFile(file)if (mods) {const seen = new Set<ModuleNode>()mods.forEach((mod) => {this.invalidateModule(mod, seen)})}
}
invalidateModule(mod: ModuleNode,seen: Set<ModuleNode> = new Set(),timestamp: number = Date.now()): void {// 修改时间戳mod.lastInvalidationTimestamp = timestamp// 使转换结果无效,确保下次请求时重新处理该模块mod.transformResult = nullmod.ssrTransformResult = nullinvalidateSSRModule(mod, seen)
} 

接着执行handleHMRUpdate 函数,通过moduleGraph.getModulesByFile(file),获取需要更新的模块,调用updateModules函数,此时会对一些文件特殊处理,比如是 .env 配置文件、html 文件等情况,ws发送full-reload,页面刷新。

在客户端 ws 接收到不同更新类型,执行相应操作:

async function handleMessage(payload: HMRPayload) {switch (payload.type) {// 通信连接case 'connected':...setInterval(() => socket.send('ping'), __HMR_TIMEOUT__)break// 更新部分代码case 'update':payload.updates.forEach((update) => {if (update.type === 'js-update') {queueUpdate(fetchUpdate(update))} else {// css-update...})break// 全更新case 'full-reload':...location.reload()...break...}
} 

在更新部分代码时会调用fetchUpdate函数

async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {...if (isSelfUpdate || qualifiedCallbacks.length > 0) {const dep = acceptedPathconst disposer = disposeMap.get(dep)if (disposer) await disposer(dataMap.get(dep))const [path, query] = dep.split(`?`)try {// import更新文件添加时间戳,浏览器发送get请求,返回新结果const newMod: ModuleNamespace = await import(/* @vite-ignore */base +path.slice(1) +`?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${query ? `&${query}` : ''}`)moduleMap.set(dep, newMod)} catch (e) {warnFailedFetch(e, dep)}}...
} 
思考

在此有个问题,启动服务过程中,怎么利用浏览器缓存来做优化的?

vite 利用 HTTP 头来加速整个页面的重新加载,源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求。

关于浏览器Cache-Control

  • max-age=xxx:缓存内容将在xxx秒后失效
  • no-cache: 客户端缓存内容,是否使用缓存需要经过协商缓存来验证决定

我们来看下在createServer时提到的 transformMiddleware 里面有哪些动作:

export function transformMiddleware(server: ViteDevServer
): Connect.NextHandleFunction {const {config: { root, logger },moduleGraph} = server...// 返回一个执行函数return async function viteTransformMiddleware(req, res, next) {...// 判断url,正则匹配,其中isJSRequest的正则为:// const knownJsSrcRE = /.((j|t)sx?|mjs|vue|marko|svelte|astro)($|?)/if (isJSRequest(url) ||isImportRequest(url) ||isCSSRequest(url) ||isHTMLProxy(url)) {...// http协商缓存:// 通过比对请求头中的if-none-match与文件的etag值,如果未变化返回 304,使用浏览器缓存,// 否则返回一个完整响应内容,在响应头上添加新的etag值const ifNoneMatch = req.headers['if-none-match']// 文件内容未发生变化if (ifNoneMatch &&(await moduleGraph.getModuleByUrl(url, false))?.transformResult?.etag === ifNoneMatch) {isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)res.statusCode = 304return res.end()}// 文件内容发生变化// 依赖vite插件进行解析转换,返回code// 如果是npm依赖,会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求const result = await transformRequest(url, server, {html: req.headers.accept?.includes('text/html')})if (result) {const type = isDirectCSSRequest(url) ? 'css' : 'js'const isDep = DEP_VERSION_RE.test(url) || isOptimizedDepUrl(url)return send(req, res, result.code, type, {etag: result.etag,// 允许浏览器缓存npm依赖cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',headers: server.config.server.headers,map: result.map})}...next()} 

在上一步处理请求时,调用 transformRequest 函数,比如 vue 文件,会命中 @vite/plugin-vue 插件对templatestyle分别做处理

export function transformRequest( url: string,server: ViteDevServer,options: TransformOptions = {} ): Promise<TransformResult | null> {...const request = doTransform(url, server, options, timestamp)...return request
}
async function doTransform( url: string,server: ViteDevServer,options: TransformOptions,timestamp: number ) {...// 插件命中const id =(await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url...// 加载const loadResult = await pluginContainer.load(id, { ssr })...// 转换const transformResult = await pluginContainer.transform(code, id, {inMap: map,ssr})...// 返回处理结果// 启用协商缓存,在响应头中带上etagconst result = ssr? await ssrTransform(code, map as SourceMap, url): ({code,map,etag: getEtag(code, { weak: true })} as TransformResult)return result
} 

总结

主要了解了下关于 vite 启动过程中,文件处理、热更新的执行过程,源代码中有很多细节处理,有兴趣可以去深入研究一下,在此过程中,了解了一些 npm 包及其作用,如cacpicocolorschokidar等,以及http缓存的应用,还有发现源码中会有很多map、set数据结构的使用,包括数据增、删、改、是否存在等,带着问题继续探索。

最后

为大家准备了一个前端资料包。包含54本,2.57G的前端相关电子书,《前端面试宝典(附答案和解析)》,难点、重点知识视频教程(全套)。



有需要的小伙伴,可以点击下方卡片领取,无偿分享

Vite 开发服务器启动非常快,**根本原因在于它采用了“按需编译 + 原生 ES 模块(ESM)”的开发模式**,避免了传统打包工具在启动时对整个项目进行全量打包的过程。 --- ### 一、核心机制:不打包,只提供模块服务 在传统构建工具(如 Webpack)中,开发服务器启动时会: 1. 解析所有入口文件及其依赖。 2. 将所有模块构建成一个或多个 bundle。 3. 启动服务器并提供这些 bundle。 这个过程随着项目增大而变慢。 而 **Vite 在开发阶段完全跳过了“打包”这一步**。它利用现代浏览器原生支持 **ES Module(`import/export`)** 的能力,直接将每个 `.vue`、`.js`、`.ts` 文件当作独立的 ESM 模块来服务。 当浏览器请求某个模块时,Vite 才对该模块进行即时编译并返回。 --- ### 二、关键技术点解释 #### 1. **基于原生 ESM 的模块加载** ```html <!-- 浏览器可以直接 import --> <script type="module"> import { createApp } from '/node_modules/vue/dist/vue.esm-browser.js' import App from '/src/App.vue' createApp(App).mount() </script> ``` Vite 不会把 `App.vue` 和它的依赖打包成一个文件,而是让浏览器通过 `<script type="module">` 自己去请求 `/src/App.vue`。 #### 2. **拦截请求,按需转换** Vite 内置了一个开发服务器,它会拦截浏览器发起的模块请求: - 请求 `/src/App.vue` → Vite 立即读取该文件,将其编译为 JavaScript(比如把 `<template>` 编译成 `render` 函数),然后返回。 - 请求 `/src/components/HelloWorld.vue` → 只有在真正用到时才编译返回。 ✅ 这意味着:**只有被访问的模块才会被处理,其他模块“静默待命”**。 #### 3. **预构建依赖(使用 esbuild)** 对于 `node_modules` 中的第三方库(如 `vue`, `lodash`),它们大多是 CommonJS 或 UMD 格式,无法直接作为 ESM 使用。 Vite启动时会使用 **esbuild**(一个用 Go 编写的超快打包器)将这些依赖**预构建为 ESM 格式**,这个过程比 Webpack 快 10-100 倍。 ```js // 示例:vite 预构建后,你可以这样 import import vue from 'vue' // 实际指向预构建后的 /node_modules/.vite/deps/vue.js ``` ⚠️ 注意:这个预构建是**一次性**的,且速度极快,不会成为性能瓶颈。 #### 4. **文件系统监听与 HMR** Vite 使用高效的文件监听机制(如 `chokidar` 或原生 FS events),一旦文件修改,仅重新编译该文件,并通知浏览器通过 WebSocket 更新模块(HMR),无需刷新页面。 --- ### 三、对比示意图 | 阶段 | Webpack | Vite | |------|--------|------| | 启动时 | ✅ 全量解析 + 打包所有模块 | ❌ 不打包,只注册路由 | | 请求模块 | 已打包好,直接返回 bundle | ✅ 按需编译并返回单个模块 | | 第三方依赖 | 按需解析 | ✅ 使用 esbuild 快速预构建 | | 性能影响 | 项目越大越慢 | 几乎与项目大小无关 | --- ### 四、举个例子说明 假设你有一个包含 500 个组件的 Vue 项目: - **Webpack**:启动时需要分析这 500 个组件之间的依赖关系,生成 AST,应用 loader(如 babel、vue-loader),最后输出 bundle —— 耗时可能超过 30 秒。 - **Vite**:启动时只两件事: 1. 用 esbuild 快速预构建 `node_modules`(几秒内完成)。 2. 启动服务器,等待浏览器请求。 当你打开页面时,只加载用到的几个组件(比如 3 个),Vite 就只编译这 3 个。其余 497 个根本不会参与任何处理。 --- ### 结论 > **Vite 启动快的本质是“懒”——不到最后一刻,绝不编译。** 它充分利用了现代浏览器的 ESM 支持和高性能的编译工具(esbuild),实现了近乎瞬时的开发服务器启动体验。 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值