从零开始手写 mini-vite
写在前面 :Vite 凭借其极速的冷启动和热更新,正在成为现代前端构建工具的新宠。本文将从零手写一个 mini-vite,旨在理解 Vite 的核心原理:利用浏览器原生 ES 模块(ESM)实现按需编译,通过 WebSocket 实现热更新。代码已上传至 GitHub,欢迎查阅交流:my-mini-vite。
一、Vite 核心流程
-
读取命令(npm run dev / build)
-
加载配置文件(vite.config.js)
-
合并默认配置
-
启动 dev:
- 创建缓存目录
- 依赖预构建(esbuild)
- 启动 HTTP 服务器
- 启动 HMR 热更新
-
处理请求:
- 返回 HTML
- 返回静态资源
- 返回预构建依赖
- 重写 import 路径
-
启动 build:
- Rollup 打包
- 输出 dist
二、手写mini-vite
1. 准备工作
bash
# 项目结构
mini-vite/
├── node_modules/
├── src/
│ ├── a.js
│ ├── b.js
│ └── main.js
├── hmr-client.js # 浏览器端 HMR 客户端
├── index.html
├── mini-vite.js
├── package-lock.json
├── package.json
└── vite.config.js # 配置文件(可选)
npm init -y
npm install esbuild chokidar ws rollup @rollup/plugin-node-resolve @rollup/plugin-terser
- vite.config.js
js
// 项目的配置文件
export const userConfig = {
port: 5017,
root: './src',
plugins: [],
build: { outDir: './dist', sourcemap: true },
}
- index.html
注意:这里的 index.html 是初始版本,后续 HMR 章节中会添加 hmr-client.js 的引入,并将路径改为
/main.js(由 dev server 解析到 src 目录)
html
<!DOCTYPE html>
<script type="module" src="/main.js"></script>
- a.js
js
import { b } from './b.js'
export const a = '我是业务a模块 → ' + b
- b.js
js
export const b = '我是业务b模块'
- main.js
js
import { a } from './a.js'
console.log(a)
2. 加载并合并配置
js
import path from 'path'
import { userConfig } from './vite.config.js'
// 【1】:加载并合并配置文件
function loadConfig() {
// 提供默认配置
const defaultConfig = {
port: 3000,
root: process.cwd(),
cacheDir: 'node_modules/.mini-vite' // 依赖缓存目录
}
const config = { ...defaultConfig, ...userConfig } // 合并配置
// 相对路径转绝对路径
config.root = path.resolve(process.cwd(), config.root)
config.build.outDir = path.resolve(process.cwd(), config.build.outDir)
config.cacheDir = path.resolve(process.cwd(), config.cacheDir)
return config
}
// 测试一下
console.log(loadConfig())
运行 命令:node .\mini-vite.js
bash
# 控制台输出
{
port: 5017,
root: 'D:\\Code\\AllCode\\手写mini-vite\\mini-vite\\src',
cacheDir: 'D:\\Code\\AllCode\\手写mini-vite\\mini-vite\\node_modules\\.mini-vite',
plugins: [],
build: {
outDir: 'D:\\Code\\AllCode\\手写mini-vite\\mini-vite\\dist',
sourcemap: true
}
}
3. 创建缓存目录
js
import fs from 'fs'
function prepareDir(config) {
fs.mkdirSync(config.cacheDir, { recursive: true })
console.log('依赖缓存目录:',config.cacheDir);
}
// 测试一下:
const config = loadConfig()
prepareDir(config)
此时node_modules文件夹下将会多出一个文件
.mini-vite,用于缓存依赖包
4. 依赖预构建
为什么需要依赖预构建?
js
// 假设你在代码中写了:
import { debounce } from 'lodash-es';
// 浏览器需要向vite server请求lodash这个模块
- 请求太多 :
lodash-es有 300 多个小文件,浏览器要发 300 个请求,卡死。 - 格式不兼容 :很多包(如 React)是 CommonJS 格式(
require),浏览器不认识。
预构建把 300 个文件合并成 1 个,把 CommonJS 转成 ESM,浏览器一次请求就搞定。预构建用到了esbuild ,它是一个极快的打包器 (用 Go 写的,比 JS 打包器快 10-100 倍),它把300 个文件合并成 1 个,并放到依赖缓存目录 node_modules/.vite/deps/中,当浏览器请求的时候,直接发一个文件就行
js
import esbuild from 'esbuild'
// 【3】:依赖预构建
async function preBundle(config) {
const pkgPath = path.join(config.root, '../package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
const deps = Object.keys(pkg.dependencies || {})
for (const dep of deps) {
await esbuild.build({
entryPoints: [dep],
bundle: true,
format: 'esm',
platform: 'node',
outfile: path.join(config.cacheDir, dep + '.js')
})
}
console.log(`\x1b[36;1m${deps.length}/${deps.length} 个依赖预构建完成\x1b[0m`);
}
// 测试一下
const config = loadConfig()
prepareDir(config)
preBundle(config)
可以看到,在node_modules文件夹下多出来一个.mini-vite,里面就是打包并处理成ES6的第三方依赖包

5. import 路径重写
为啥需要import路径重写?
在上一步中,我们完成了依赖预构建,将第三方模块打包到了缓存目录。但浏览器在解析 import 语句时,只能识别相对路径、绝对路径或 URL,而无法直接处理裸模块 ------比如 import { debounce } from 'lodash-es' 中的 'lodash-es'。如果没有重写,浏览器会向vite服务器请求 http://localhost:5173/lodash-es,这个路径显然不存在,导致 404 错误。
Vite 的做法是:拦截所有裸模块导入,将其重写为以 /@modules/ 开头的特殊路径 ,然后在服务器端将 /@modules/xxx 映射到预构建好的依赖文件上。
js
// 【4】:import 路径重写
function rewriteImports(code) {
// 匹配 import导入
const importRegex = /(?:import|export)\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"]([^'"]+)['"]/g;
return code.replace(importRegex, (match, importPath) => {
// 判断是否是裸模块:不以 . 或 / 或 http 开头
const isBareModule = !importPath.startsWith('.') &&
!importPath.startsWith('/') &&
!importPath.startsWith('http');
if (isBareModule) {
// 将 lodash-es 替换成 /@modules/lodash-es.js
const newPath = `/@modules/${importPath}.js`;
return match.replace(`'${importPath}'`, `'${newPath}'`).replace(`"${importPath}"`, `"${newPath}"`);
}
return match;
});
}
// 测试一下
const code = `
import lodash from 'lodash';
import { createApp } from 'vue'
`;
console.log(rewriteImports(code));
# 控制台输出:
import lodash from '/@modules/lodash.js';
import { createApp } from '/@modules/vue.js'
注意:这个简化版正则无法处理动态导入 import() 和复杂的重导出,但足以说明核心原理。完整的 Vite 会使用更严谨的 AST 解析(如 es-module-lexer)。
6. 请求处理
handleRequest的作用根据 URL 返回不同内容:HTML、HMR 客户端、预构建依赖(
/@modules/)、业务 JS(并重写 import)。Vite 源码真实处理
不是用一个函数硬编码,而是用 中间件链 (基于
connect框架),每个中间件负责一件事(如 CORS、静态文件、源码转换),插件可插入自定义中间件。
js
function handleRequest(req, res, config) {
const url = req.url.split('?')[0] // 去掉查询参数(HMR 会加 ?t=xxx 绕过缓存)
// 1. HTML:直接读取 index.html文件内容,然后相应给服务器
if (url === '/') {
/**
* HMR热更新原理
* 【注意】:在启动服务器的时候(写在后续),vite会在本地启动一个8080服务器,
* 浏览器访问 http://localhost:5017/ 的时候,直接请求 index.html
* 在这个index.html中,使用script将main.js和hmr-client.js 以 ES 模块引入
* 在hmr-client.js内部,将会开启一个websocket并连接到本地vite服务器
* 后续,本地代码有更新的时候,vite将通过chokidar监听到变化,
* 然后vite服务器将变更清单通过socket推送给浏览器,浏览器重新请求变更模块的代码
*/
const html = fs.readFileSync(path.join(process.cwd(), 'index.html'), 'utf-8')
res.writeHead(200, { 'Content-Type': 'text/html' })
return res.end(html)
}
// 2. HMR 客户端
if (url === '/hmr-client.js') {
const js = fs.readFileSync(path.join(config.root, '../hmr-client.js'), 'utf8')
res.writeHead(200, { 'Content-Type': 'application/javascript' })
return res.end(js)
}
// 3. 预构建依赖
if (url.startsWith('/@modules/')) {
const name = url.replace('/@modules/', '')
const code = fs.readFileSync(path.join(config.cacheDir, name), 'utf8')
res.writeHead(200, { 'Content-Type': 'application/javascript' })
return res.end(code)
}
// 4. 业务 JS
if (url.endsWith('.js')) {
const code = fs.readFileSync(path.join(config.root, url.slice(1)), 'utf8')
const transformed = rewriteImports(code)
res.writeHead(200, { 'Content-Type': 'application/javascript' })
return res.end(transformed)
}
res.writeHead(404)
res.end('Not found')
}
// 测试一下
const config = loadConfig() // 加载配置
prepareDir(config) // 准备缓存文件
await preBundle(config) // 预构建模块
// 模拟请求函数
function mockReq(url) {
const req = { url }, res = {}
res.writeHead = (c, h) => (res.status = c, res.type = h?.['Content-Type'])
res.end = d => (res.body = d)
handleRequest(req, res, config)
return res
}
// 模拟浏览器向vite服务器动态请求资源
console.log('(1)请求html:',mockReq('/').body,'\n\n')
console.log('(2)请求预构建模块:\n',mockReq('/@modules/chokidar.js').body.slice(0, 100) + '...查看更多') // 这个包太多了,只打印前面一点点
# 控制台输出:
6/6 个依赖预构建完成
(1)请求html:
<!DOCTYPE html>
<script type="module" src="/main.js"></script>
<script type="module" src="/hmr-client.js"></script>
(2)请求预构建模块:
// node_modules/chokidar/index.js
import { EventEmitter } from "node:events";
import { stat as statc...查看更多
7. HMR热更新
HMR 本质:基于浏览器原生 ESM + 模块依赖图谱,实现单文件级精准热替换,全程不打包、不刷新、最小范围更新。
两端角色
- 服务端(Vite Dev Server):文件监听 → 维护模块依赖图 → 计算更新边界 → 推送更新指令
- 客户端(HMR Runtime):接收消息 → 拉取最新模块 → 执行模块替换 → 处理 accept / 降级刷新
- vite服务端:
js
function startHMR(config) {
const wss = new WebSocketServer({ port: 8080 })
console.log('\x1b[36;1m[HMR] WebSocket 服务已启动,端口: 8080\x1b[0m')
const watcher = chokidar.watch(config.root, { persistent: true })
console.log(`\x1b[36;1m[HMR] 正在监听文件变化: ${config.root}\x1b[0m`)
watcher.on('change', (file) => {
const rel = '/' + path.relative(config.root, file).replace(/\\/g, '/')
console.log(`\x1b[33m[HMR] 检测到文件变化: ${rel}\x1b[0m`)
const msg = JSON.stringify({ type: 'update', path: rel })
console.log(`\x1b[33m[HMR] 推送消息: ${msg}\x1b[0m`)
wss.clients.forEach(ws => ws.send(msg))
})
}
// // 测试一下
const config = loadConfig()
startHMR(config)
运行 node .\mini-vite.js 后,websocket 和 chokider启动,监听src文件,当我们更改了src下的文件,此时chokider将会监听到文件变化,并通过websocket通知所有客户端拉取变更模块
bash
[HMR] WebSocket 服务已启动,端口: 8080
[HMR] 正在监听文件变化: D:\Code\AllCode\手写mini-vite\mini-vite\src
[HMR] 检测到文件变化: /main.js
[HMR] 推送消息: {"type":"update","path":"/main.js"}
[HMR] 检测到文件变化: /a.js
[HMR] 推送消息: {"type":"update","path":"/a.js"}
- 客户端(浏览器):
js
const ws = new WebSocket('ws://localhost:8080')
ws.onmessage = async (e) => {
const data = JSON.parse(e.data)
if (data.type === 'update') {
console.log('[HMR] ', data.path,'模块变化,即将重新拉取新代码')
// 动态 import 只重新请求变更的模块,?t= 绕过浏览器缓存
await import(data.path + '?t=' + Date.now())
console.log('[HMR] 拉取成功!')
}
}
console.log('HMR 客户端已连接')
vite 热更新机制的核心步骤
-
文件变化监听(chokidar):文件变动 → chokidar 捕获事件 → 进入 handleHMRUpdate 处理。仅监听源码目录,跳过 node_modules/dist 等无关路径。
-
定位模块并标记脏模块 :根据文件路径从
ModuleGraph找到对应ModuleNode,标记该模块,并清空该模块的缓存编译,此时并不立即编译,等待浏览器请求时再按需编译。 -
计算热更新边界 :从变更模块向上遍历
importers依赖链,遇到accept()后停止,否则继续遍历,遇到decline()→ 标记需整页刷新,遍历至入口仍无 accept → 标记需整页刷新。最终输出:更新模块列表、可接受边界、是否刷新。 -
生成轻量更新清单(不推送代码):服务端构造更新消息,仅含路径、类型、时间戳,无冗余代码:
json
{
"type": "update",
"updates": [
{
"type": "js-update",
"path": "/a.js",
"acceptedPath": "/a.js",
"timestamp": 1712345678901
}
]
}
-
WebSocket 推送更新指令 :复用 Dev Server 连接,通过 WebSocket 把更新清单广播给浏览器。区别于webpack的热更新,vite只传指令,不传代码本体,通信极轻量。
-
客户端接收并拉取新模块 :浏览器 HMR Runtime 收到消息后,首先解析更新路径,接着带时间戳参数发起 HTTP 请求,强制获取最新模块:
http
GET /a.js?t=1712345678901
-
模块热替换 :执行旧模块的dispose清除副作用 ,接着用新模块替换旧模块缓存 ,执行 accept 回调,更新客户端模块关系,同步依赖图
-
降级策略:遇到配置文件变更、异常、HTML 等非 JS/CSS 文件修改等清空,vite无法热更新此时直接整页刷新
8. 启动开发服务
js
import http from 'http'
async function startDev() {
const config = loadConfig()
prepareDir(config)
await preBundle(config)
const server = http.createServer((req, res) => {
handleRequest(req, res, config)
})
startHMR(config)
server.listen(config.port, () => {
console.log(`✅ mini-vite 已启动: http://localhost:${config.port}`)
})
}
启动 node .\mini-vite.js,并在浏览器中打开http://localhost:5017,此时的请求路径为 ' / ',于是将index.html的内容响应给浏览器。
下面将一步一步的展示整个过程:
-
启动服务器:

-
浏览器请求 ' / ' ,服务器响应给浏览器index.html

-
遇到script标签并且type="module"于是请求 main.js

这里有一个细节点:我们发现,在请求完main.js 后,浏览器并没有继续请求a.js ,而是直接请求hmr-client.js,这是怎么回事呢?
回顾 浏览器渲染原理-CSDN博客 的知识:浏览器在解析HTML的时候,为了提高效率,开始解析前,会启动一个 预解析的线程 ,率先下载 HTML中的外部的JS文件 。而
type="module"的脚本默认是 defer 行为,于是预解析线程就会并行请求main.js和hmr-client.js并按顺序返回,当渲染主线程 ,解析到main.js后,发现它 import 了a.js,于是也去请求a.js,a.js又 import 了b.js...所以你看到的网络面板顺序是:
bashindex.html ← # 先拿到 HTML main.js ← # 和 hmr-client.js 并行下载 hmr-client.js ← # 和 main.js 并行下载 a.js ← # 解析 main.js 发现的依赖,并行下载 b.js ← # 解析 a.js 发现的依赖,并行下载 -
请求 hmr-client热更新客户端脚本

浏览器执行整个脚本后,将会开启websocket与vite服务器建立连接,这样子,当后续文件有更新的时候,服务器通过websocket给浏览器发送更新清单的时候,浏览器就能够监听到这个清单,然后用 import动态请求清单上的文件,这就是vite的HMR热更新机制的核心原理
-
浏览器渲染主线程执行main.js,并按照依赖依次请求 a.js → b.js




-
请求完后,依次执行main.js和hmr-client.js:

-
当本地某个文件改动

-
浏览器的hmr-client脚本收到清单

-
浏览器请求清单中的变更模块地址

9. 配置到npm 命令
我们在开发模式下启动vite服务器,运行的是 npm run dev,实际上就是执行的 node .\mini-vite.js,对其官方,我们将node .\mini-vite.js配置到package.json的script中,使得mini-vite也可以使用 npm run dev启动
json
// package.json
{
"name": "mini-vite",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "mini-vite.js",
"scripts": {
"dev": "node mini-vite.js", // 增加这一行命令
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^1.0.0",
"chokidar": "^5.0.0",
"esbuild": "^0.28.0",
"rollup": "^4.60.1",
"ws": "^8.20.0"
}
}
在控制台,使用 npm run dev:

三、详解Vite整个启动和热更新过程:
输入 npm run dev 按下回车后,npm 找到 package.json 中的 scripts 字段,执行对应的 dev 指令 ------ 也就是 node mini-vite.js。此时,startDev 函数被调用,Vite正式启动
首先,调用loadConfig 函数,此时Vite 会读取用户项目根目录下的 vite.config.js,合并默认配置。这个过程中会加载并执行配置文件 (可能包含插件、alias、proxy 等自定义配置)。加载完配置后,计算用户源码目录的绝对路径,并同步创建依赖缓存目录,缓存目录将用于存放预构建的依赖 和转换后的模块元数据。
然后,启动prepareDir 函数从项目入口文件开始,对实际使用到的第三方依赖进行预构建 处理,依赖预构建的好处在于可以将loadash这样包含了数百个独立文件的第三方JS库 打包到一个JS文件中以减少浏览器并发请求数量,在打包的过程中还会将其中的CommonJS转成浏览器能识别的ESModule。依赖预构建是基于esbuild 进行的打包的,它是一个go语言开发的高性能打包器,打包完毕之后生成依赖映射表 ,记录每个依赖的哈希值和导出成员信息。当后续 package-lock.json 或依赖版本发生变化时,Vite 会通过比较哈希值来决定是否需要重新预构建,从而避免重复工作。
完成预构建后,Vite 开始创建开发服务器,基于 connect 框架构建中间件链。用于处理 CORS、静态文件、代理、源码转换、HMR WebSocket 等。与此同时,Vite 会在内存中初始化一个依赖图图谱 ,图中每个节点对应一个模块,记录了该模块的相关信息。当浏览器首次请求某个模块时,Vite 会解析该模块的内容,提取所有 import 语句,动态的更新这张依赖图。
服务器启动完成后,开始监听配置文件中指定的端口(例如 5017),此时用户在浏览器中输入 http://localhost:5017,此时浏览器向服务器发起请求。请求路径为 /,服务器收到请求,读取项目根目录下的 index.html 文件,并在其中注入 HMR 客户端脚本 (⭐这个很重要,等会在HMR阶段将会具体讲解)以及其他必要的运行时代码,然后将这份 HTML 返回给浏览器。
浏览器解析 HTML 后,发现 script 标签引用了 ./src/main.js,于是立即向 Vite 服务器发起一个新的请求:/src/main.js。这个请求会被 vite服务器捕获。此时vite将使用 es-module-lexer对main.js进行精确的 AST 解析,并对其依赖进行路径重写 ,将所有裸模块 的路径改写为 /@modules/xxx。后续当浏览器请求 /@modules/xxx 时,Vite 会直接从预构建的缓存目录 中读取对应的文件并返回,无需再次转换。在返回转换后的 main.js 给浏览器之前,Vite 还会更新依赖图谱。
浏览器收到 main.js 后,解析出其中的 import 语句,立即发起对 ./a.js 和 /@modules/lodash-es.js 的请求。对于lodash.js,vite将直接返回缓存目录中的文件,速度极快。对于 ./a.js 的请求流程与 main.js 完全相同。这个过程会递归进行,直到所有依赖模块都加载完毕。最终浏览器执行入口代码,页面完成首次渲染。
其次是HMR阶段
Vite会基于chokidar对项目源码目录Src进行监听。当src/a.js变更后,Vite拿到变更文件的路径,在依赖图谱将该节点标记为"脏模块",并清空其编译缓存,注意,这里并不会立即重新编译,而是等到浏览器真正请求该模块时再按需编译,这样可以避免不必要的计算。
接下来,Vite 从变更模块出发,沿着 importers 链向上遍历 以确定更新边界,遍历过程中,遇到accept后立刻更新当前模块并停止遍历 ,遇到decline立刻强制刷新 ,如果遍历结束仍然没有accept,则说明没有模块愿意接受这次更新,Vite 会降级为整页刷新
确定了边界后,Vite 会构造一个轻量级的更新消息 ,消息中只包含需要更新的模块路径、边界模块路径和时间戳,并不包含任何代码本体。然后通过 WebSocket 将这个 JSON 消息广播 给所有已连接的浏览器客户端
⭐浏览器在最开始请求的index.html中包含了HMR 客户端脚本,浏览器的HMR脚本内部开启了一个websocket并连接到了Vite服务器,当HMR客户端脚本收到Vite服务器发送的轻量级的更新消息 后,从中解析出需要更新的模块路径 ,对每个路径发起一个动态 import() 请求 ,并在 URL 后附加一个 ?t=时间戳 参数,强制绕过浏览器缓存。Vite服务器在收到这个带时间戳的请求后,重新读取并编译该模块(此时才会编译!)然后返回给浏览器
浏览器拿到新的模块代码后,先执行旧模块的dispose方法清理副作用 ,然后HMR客户端脚本会用新模块替换掉缓存中的旧模块 ,最后递归地调用边界模块以及下游依赖的 accept 回调完成模块替换
值得一提的是:如果更新涉及 CSS 文件,Vite 会将 CSS 更新标记为 css-update,浏览器端收到后直接找到对应的 <link> 或 <style> 标签,替换其内容,而不需要执行任何 JavaScript 逻辑,因此 CSS 热更新通常更加简单可靠。
当遇到无法热更新的情况(如 HTML 文件本身变化、vite.config.js 变化、WebSocket 断开、某个模块调用了 decline() 等),Vite 会回退到整页刷新。这种降级策略保证了即使在复杂场景下,开发者的修改也能最终呈现出来。
四、采用rollup打包
js
import * as rollup from 'rollup'
// 使用rollup进行打包
async function startBuild() {
const startTime = Date.now()
console.log('\x1b[36;1m[build] 正在构建...\x1b[0m')
const config = await loadConfig()
const { nodeResolve } = await import('@rollup/plugin-node-resolve')
const { default: terser } = await import('@rollup/plugin-terser')
// 读取配置
const input = path.join(config.root, 'main.js')
const outDir = path.resolve(config.build.outDir)
const sourcemap = config.build.sourcemap
// 打包
const bundle = await rollup.rollup({
input,
plugins: [nodeResolve(), terser()]
})
await bundle.write({ format: 'esm', file: path.join(outDir, 'index.js'),sourcemap })
// 输出 HTML
const html = `
<!DOCTYPE html>
<script type="module" src="./index.js"></script>
`
fs.writeFileSync(path.join(outDir, 'index.html'), html)
const cost = Date.now() - startTime
console.log(`\x1b[32;1m[build] 构建完成\x1b[0m → \x1b[36m${outDir}\x1b[0m (\x1b[33m${cost}ms\x1b[0m)`)
}
顺带的将npm 命令配置了:
json
// package.json
{
"name": "mini-vite",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "mini-vite.js",
"scripts": {
"build": "node mini-vite.js build", // 添加这一行
"dev": "node mini-vite.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^1.0.0",
"chokidar": "^5.0.0",
"esbuild": "^0.28.0",
"rollup": "^4.60.1",
"ws": "^8.20.0"
}
}
封装一个入口函数
js
function run() {
const command = process.argv[2] || 'dev'
if (command === 'build') {
startBuild()
} else {
startDev()
}
}
run()
测试一下:

五、结尾
核心链路:读取用户配置并合并默认配置 → 创建缓存目录 → 依赖预构建(esbuild)→ 启动 HTTP 服务器与 WebSocket 服务 → 处理请求(返回 HTML、重写 import 路径、返回预构建依赖)→ 文件变动触发热更新(chokidar 监听 → 计算更新边界 → WebSocket 推送指令 → 浏览器动态拉取新模块)→ 生产构建(Rollup 打包输出 dist)。
本文实现的只是一个 mini 版 Vite,但基本复刻了 Vite 的核心流程与设计思想。真实的 Vite 在路径解析、插件机制、模块图管理、错误处理等方面要精妙和严谨得多。
Vite 的可贵之处,不在于它比 Webpack 快了多少,而在于它敢于质疑"开发即打包"这条走了十年的老路,当别人还在打包巨无霸bundle的时候,它转身拥抱了 ES Module,用浏览器的能力做浏览器的事,把复杂留给自己,把简单还给开发者。