工程实践 · ENGINEERING

Vite dev 启动 9 秒:从代码排查到磁盘介质的完整定位

· 王亚博 · 含 41× 冷热对照实测,复现脚本已开源
npm run dev 启动 10 秒以上、浏览器里上百个请求 pending——根因不在代码,而在项目所在的存储介质:外接 USB 机械硬盘。同样的输入、同样的缓存,Vite createServer 冷文件缓存 9430 ms vs 热缓存 228 ms(41×),CPU Profile 里全是文件系统调用。生产环境不受影响(静态产物打进镜像,容器内 nginx 提供)。本文全部测量脚本已开源:opc-dev-io-bench,可在任何项目上复现同样的定位流程。
封面:冷文件缓存 9430ms 对比热缓存 228ms,冷热耗时差 41 倍
同项目、同缓存,仅文件缓存状态不同:createServer 冷热差 41×——瓶颈是 I/O 等待,不是计算

一、结论速览

环节是否受影响实测 / 依据
npm run dev 服务端就绪严重冷文件缓存 9430 ms vs 热缓存 228 ms(41×)
dev 浏览器首屏加载严重首屏 143 个请求;GET / 单个请求 4009 ms;请求瀑布
本地 npm run build:prod轻微15.4 s / 21.8 s,一次性成本,无超时风险
Docker 镜像构建基本不受影响.dockerignore 已排除 node_modules,上下文约 29 MB;构建在容器内执行
生产运行(nginx 容器)不受影响静态产物打进镜像,由容器内 nginx 提供,不读该盘
最终用户体验不受影响只与产物体积、部署机网络有关,与开发机磁盘无关

二、现象与根因

现象:

根因:项目目录位于一块外接 USB 机械硬盘上。diskutil 确认介质类型(设备名已脱敏):

$ diskutil info <外接盘>
   Protocol:                  USB
   Device Location:           External
   Solid State:               No          ← 机械硬盘(旋转盘片)

Vite 开发服务器在启动与提供模块时,需要读取成千上万个小文件(Vite 自身、插件、node_modules 里的包元数据与源码;本项目 node_modules 中 <200 KB 的小文件共 24,524 个)。这类随机小文件读在机械盘上的单次延迟比 SSD 高两个数量级以上,于是 I/O 等待直接变成了用户看到的「启动慢 / 请求 pending」。

三、定位过程

排查遵循「先排除代码 → 分段计时 → 采样剖析 → 冷热对照 → 硬件确认」的顺序。这套流程可复用于任何「前端变慢」的问题。

3.1 先排除代码侧与显性配置

检查项结果结论
依赖预构建缓存是否反复重建新建缓存的 browserHash 与三周前完全一致排除「缓存反复失效」
多实例是否争抢缓存4 个 dev server 并存时,新实例仍 255 ms 就绪排除「实例争抢」
插件是否拖慢启动逐个启用插件测量 createServer,热缓存下均 < 400 ms排除「社区插件问题」
包体是否造成服务端计算瓶颈热缓存下整个 createServer 仅 228 ms排除「代码侧计算瓶颈」
注:早期测得 ready in 曾达 21 s / 12 s,是同时运行生产构建时读到的,属于被污染的读数,已用干净对照推翻。本文只采用干净测量的数据。

3.2 分段计时:耗时全部集中在 createServer

用 Vite 的 JS API 把启动拆成四段(脚本见仓库 vite_startup_timing.mjs):

import { createServer } from 'vite'
const t = () => performance.now()
const a = t(); const server = await createServer({ root: process.cwd() })
const b = t(); await server.listen()
const c = t(); await (await fetch('http://<dev-host>:51999/')).text()
const d = t(); await server.transformRequest('/src/main.js')
const e = t()
console.log({ createServer: b-a, listen: c-b, firstRequest: d-c, transform: e-d })
阶段冷文件缓存热文件缓存
createServer(读配置 + 插件初始化)9430 ms228 ms
listen()120 ms16 ms
首次 GET /*87 ms39 ms
transformRequest('/src/main.js')271 ms54 ms

* 此行是 JS API、服务已就绪条件下的读数;§3.5 浏览器实测的 GET / 4009 ms 是冷盘 + 首次整页加载场景,两者条件不同,并不矛盾。

结论:9 秒多的耗时几乎全部在 createServer 阶段,且同样的输入、同样的缓存,冷热相差 41 倍——说明它不是在「算」,而是在「等 I/O」。

Vite dev 启动分段计时:冷/热文件缓存对照柱状图
冷/热文件缓存对照:createServer 9430ms → 228ms,41× 差距说明服务端在等 I/O 而非计算

3.3 CPU Profile:耗时函数全是同步文件 I/O

对慢启动采样 .cpuprofile(Node --cpu-prof,采样间隔 500 µs),按命中次数排序:

readFileUtf8      (native)                        ← 最大头
(idle)            (native)
readPackageJSON   (native)                        ← Node 的 ESM/CJS 解析器逐个读 package.json
read              (native)
wrapSafe          node:internal/modules/cjs/loader
dlopen            (native)                        ← 加载原生模块
statSync          node:fs
realpathSync      node:fs
getPackageScopeConfig (native)

没有任何计算密集的函数——全部是 read / stat / realpath / dlopen 这类纯文件系统调用。与 §3.2 的判断互相印证。

「buildStart、config 和 configResolved 钩子会在开发服务器启动期间被 await,从而推迟你在浏览器里访问站点的时间。」—— Vite 官方 Performance 指南(vite.dev/guide/performance)

3.4 小文件随机读基准

对 node_modules 中 <200 KB 的小文件(共 24,524 个)均匀抽样 2000 个逐一完整读取(脚本见仓库 bench_small_files.mjs),两次采样:

第 1 次:读取 2000 个文件(共 6.4 MB,失败 0)  总耗时 4.04s  平均 2.02 ms/文件
第 2 次:读取 2000 个文件(共 6.4 MB,失败 0)  总耗时 5.05s  平均 2.52 ms/文件

即在部分页缓存(排查过程中已多次读过 node_modules)的乐观条件下,也有 2.0 – 2.5 ms/文件,折合有效随机读仅约 400–500 文件/秒。随后在更冷的状态下复测,得到 6.17 ms/文件——真实冷盘只会更差。真正稳健的证据仍是 §3.2 的 41× 冷热比。

机械盘的单次 I/O 延迟可以用教科书参数直接算出来,不需要引用任何评测:7200 RPM 盘片旋转一周 = 60 s ÷ 7200 ≈ 8.33 ms,平均等待半圈 ≈ 4.17 ms;磁头平均寻道 ≈ 9 ms;合计单次随机 I/O ≈ 13 ms 量级,即上限约 70–150 IOPS。而 SSD 无机械动作,读延迟 < 0.1 ms,SATA SSD 4K 随机 IOPS 通常在数万级,NVMe 更高——两个数量级的差距。

介质单文件随机读延迟(典型值)
机械硬盘8 – 13 ms(寻道 + 旋转)
SATA SSD≈ 0.1 ms
NVMe SSD0.01 – 0.05 ms
单个小文件随机读延迟对比(对数轴):机械盘与 SSD 相差两个数量级
一个小文件的随机读代价:机械盘与 SSD 相差两个数量级(旋转延迟可自证:60s ÷ 7200rpm ÷ 2 ≈ 4.17ms)

3.5 浏览器侧请求瀑布

用 Playwright 真实浏览器加载 dev 页面,记录全部请求(脚本见仓库 waterfall.mjs):

=== 结果 ===
  页面加载时间(含额外等待): 19.3s
  请求总数: 143   已完成: 143   仍 pending: 0

=== 最慢的请求 ===
    4009ms  200  /
     137ms  200  /@vite/client
     132ms  200  /src/main.js
      61ms  200  /node_modules/.vite/deps/chunk-*.js
      55ms  200  /node_modules/.vite/deps/element-plus.js
      52ms  200  /src/utils/theme.js

关键观察:单个模块请求都在 50–140 ms 量级(SSD 上本该是 1–5 ms),而第一个 GET / 就花了 4009 ms——服务端要先从冷盘把整条模块链路读起来。每个请求都在等磁盘,整体表现就是「一堆请求卡在 pending」。

「文件转换耗时越长,在浏览器中加载站点时的请求瀑布(request waterfall)就越显著。」—— Vite 官方 Performance 指南
dev 首屏请求瀑布统计:143 个请求的构成与最慢请求
143 个请求里 112 个是 src 源码模块:每个请求都在等磁盘,首请求独占 4 秒

3.6 Spotlight 索引叠加

$ mdutil -s <外接卷>
Indexing enabled.

该卷的 Spotlight 索引处于开启状态。mds / mdworker 会在后台持续扫描文件系统元数据,在机械盘上与开发工具链争抢磁头——这是叠加在根因之上的放大器。

四、机理:为什么机械盘会放大 Vite 的耗时

4.1 Vite dev 的性能模型本身就是 I/O 密集的

Vite 开发模式刻意不做打包:依赖浏览器原生 ESM,按需转换、按需返回每个模块。这是它启动快的原因,但也意味着页面加载路径上每一个模块都要经过一次服务端文件读取 + 转换 + 一次 HTTP 往返。

「Vite 开发服务器只按浏览器请求来转换文件,这使它能够快速启动,并且只对用到的文件应用转换。」—— Vite 官方 Performance 指南

本次实测:dev 首屏 143 个请求,其中 112 个是 src 源码模块、22 个是预打包依赖。在 SSD 上这是毫秒级的开销,在机械盘上每个请求要等几毫秒到十几毫秒的寻道,143 个请求串起来就是十几秒。

4.2 模块解析的隐藏文件系统开销

官方给过一个具体例子——解析隐式扩展名的 import,最坏情况要逐次探测:

「当你用 import './Component' 去导入 ./Component.jsx 时,Vite 会依次检查 ./Component、.mjs、.js、.mts、.ts、.jsx……解析一个导入路径总共需要 6 次文件系统检查。隐式导入越多,累积的解析时间就越多。」—— Vite 官方 Performance 指南

一个常见误区要澄清:本项目配置的 extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'] 看起来是「加长」,实际上 Vite 默认值本来就是 7 项(含 .mts),项目只是把 .mts 换成了 .vue,数量与默认相同,最坏探测次数也相同。真正的要点是:隐式扩展名导入天然带多次 stat 探测(完全未命中时,含无扩展名探测在内最多 8 次),在 SSD 上每次探测不足 0.1 ms 可以忽略,在机械盘上每次都要付一次寻道代价。显式写全扩展名、收敛用不到的项,收益随盘速放大。

4.3 Barrel file(桶文件)放大效应

官方专门有一节讲这个问题:

「桶文件是在同一目录下重新导出其他文件 API 的文件……当你只想导入其中一个 API 时,桶文件中的所有文件都需要被获取和转换……这意味着你在首屏加载了比实际需要更多的文件,导致页面变慢。」—— Vite 官方 Performance 指南 · vitejs/vite#8237

本项目有两处典型的桶文件式全量引入:

位置代码后果
src/main.jsimport ElementPlus from 'element-plus' + 全量主题 CSS拉入整库(dev 下单文件 2.1 MB)
全局图标注册文件import * as components from '@element-plus/icons-vue' 后 for...in 全量注册注册全部约 300 个图标
src/main.js全局注册富文本编辑器组件重量级依赖进入首屏

实测实际用量:模板中只用到 55 个 element-plus 组件、29 个图标。

重要澄清:这些包体问题不是本次 10 秒 pending 的根因——热缓存下整个 createServer 只要 228 ms,证明服务端没有计算瓶颈。它们是放大器:让首屏要读的文件数和字节数变多,把机械盘的代价等比放大。

4.4 机械盘与 SSD 的数量级差

如 §3.4 所算:机械盘读一个文件要付「寻道 ≈9 ms + 等半圈 ≈4.17 ms」,SSD 无机械动作、延迟 < 0.1 ms,且支持深度并发队列。这意味着:在 SSD 上「并行读 1000 个小文件」是并行的;在机械盘上本质是串行的——磁头同一时刻只能在一个位置。开发工具链(node_modules 解析、模块图构建、IDE 索引、git status)恰恰是「海量小文件随机读」的典型负载,因此受机械盘惩罚最重。

4.5 预打包依赖同样要读盘

Vite 的依赖预构建缓存位于 node_modules/.vite/deps,本项目为 25 MB / 60 个文件:

2528.1 KB  echarts.js
2063.1 KB  element-plus.js
 850.5 KB  xlsx.js
 414.7 KB  chunk-*.js
 410.7 KB  vue-quill
 397.9 KB  vuedraggable
 289.0 KB  @vueuse/core
 184.1 KB  js-beautify

这些文件在 dev 下由浏览器直接请求(官方说明:预打包依赖使用 max-age=31536000, immutable 强缓存,命中后不再回源)。但首次加载仍必须从盘上读出这 25 MB。官方文档同时说明:依赖预构建只在开发模式应用,生产构建走的是另一套(Rollup)流程——这正是「dev 慢、生产不慢」的机制来源之一。

五、影响范围:开发严重,生产不受影响

5.1 开发环境——严重影响

Vite dev server 的全部转换都在项目目录上进行,每一次页面加载、每一次 HMR 都要读这块盘。受影响的日常操作:npm run dev 启动、页面刷新、HMR 响应、首次进入某个路由(懒加载 chunk 要现读现转)。

5.2 本地构建——轻微

$ npm run build:prod
build #1: real 21.79
build #2: real 15.37

15–22 秒,是一次性成本(每次发版一次),远低于任何 CI 超时阈值。SSD 上预计可降到 8–12 秒,但收益有限,不构成问题。

5.3 Docker 镜像构建——基本不受影响

项目的构建链是 Docker 化的,这一点很关键:

# 多阶段构建(示意)
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build:prod && chmod -R a+r /app/dist

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 8080

三个原因使它与这块机械盘基本解耦:

  1. .dockerignore 已正确排除大目录(node_modules / dist / .git),构建上下文因此只有约 29 MB(项目 349 MB − node_modules 302 MB − .git 18 MB)。反过来,如果 .dockerignore 缺失或写错,Docker 会把 302 MB / 24,524 个文件送进 daemon,那才会真正拖垮构建;
  2. npm ci 与 npm run build:prod 在容器内执行,读写的是 Docker Desktop 虚拟机的磁盘镜像(位于内置 SSD),不是外接卷;
  3. 只有 COPY . . 这一步读本机源码(约 29 MB),可忽略。

5.4 生产运行——不受影响

docker run -d --name my-web -p 8080:8080 \
  -v ${LOG_DIR}:/var/log/nginx \
  ${IMAGE}:latest

运行时唯一挂载的是宿主机日志目录;前端静态文件是 COPY --from=builder /app/dist 打进镜像的,由容器内 nginx 提供。整个服务链路不读外接卷。因此对最终用户而言:加载速度只取决于构建产物体积与部署机到用户的网络,与开发机磁盘没有任何关系——本次问题不会表现为线上变慢,不需要为此回滚或加急修复。

唯一会把问题带进生产的三种做法(本项目目前都没有做,仅作风险提示):① 在服务器上用 vite preview 或 npm run dev 当生产服务;② 把 dist 目录直接从这块机械盘挂载给 nginx;③ CI Runner 的工作目录挂在这块盘上导致构建超时(当前本地实测 22 s,风险很低)。

5.5 其他受影响的日常操作

pnpm 用户在机械盘上遇到 install 长期卡在 reused/added 阶段的同类案例,可参考掘金《当 pnpm 遇上机械硬盘》——把 store 迁到 SSD 后恢复流畅,与本文结论互为印证。

六、修复方案与取舍

方案 A:把项目移到内置 SSD——唯一根治

依据:§3.2 的 41× 冷热差与 §3.4 的介质对比都指向存储介质本身,任何代码层优化都只能缓解、不能根治。内置 NVMe SSD 容量 228 GB、已用 91%、可用约 16 GB;项目总占用 349 MB,放得下,但需评估余量。做法:整仓库迁移(rsync 或重新 git clone 后 npm ci),迁移后重跑仓库里的 T1 / T3 验证。

不推荐「只把 node_modules 软链到内置盘」:包管理器的写时重命名、工具链的 realpath 解析、Docker/IDE 的路径假设都容易在软链上出问题,收益不如整体迁移,维护成本反而更高。

方案 B:别让 Spotlight 扫这块盘

# 1) 仅关闭该卷索引(推荐,可逆)
sudo mdutil -i off <外接卷>

# 2) 图形化等价做法:系统设置 → Siri 与聚焦 → 聚焦 → 隐私,把该卷加入排除列表

# 3) 卷根目录放 .metadata_never_index(设备级、无需 sudo)
#    ⚠️ 未经本文实测;社区有较新 macOS 上失效的反馈。
#    验证方法:放置后执行 `mdutil -s <卷>`,若仍显示 Indexing enabled 即无效。

代价:该盘上的文件将无法被 Spotlight 搜索到。若盘上还存有需要检索的资料,先确认再执行。

方案 C:让 Vite 少读文件(不迁移也能改善)

C1. 预热高频文件——官方机制 server.warmup:

server: {
  warmup: {
    clientFiles: ['./src/main.js', './src/layout/**/*.vue', './src/components/Editor/index.vue'],
  },
}

官方提醒只应预热频繁使用的文件,以免给启动增加负担;使用 --open / server.open 也会自动预热应用入口。C2. 显式写导入扩展名 / 收敛 resolve.extensions——注意默认值本就是 7 项,收益来自减少隐式导入的 stat 探测次数,且随盘速放大(SSD 上收益很小)。C3. 消除桶文件全量引入——见方案 D。

方案 D:包体治理(减少首屏字节数与文件数)

这解决的是另一个独立问题(首屏体积),但在机械盘上它同时是放大器(§4.3)。三件事,按性价比排序:

#改动收益风险
D1element-plus 按需引入(unplugin-vue-components + ElementPlusResolver),去掉全量 app.use(ElementPlus) 与全量 CSS预计入口从 561 KB gzip 降到 150–200 KB;455 KB 全量 CSS 大幅缩减中:app.use(ElementPlus) 当前还提供 $message 等全局属性(项目内多数已显式 import { ElMessage },影响面可控),需回归验证
D2图标按需(模板只用到 29 个,当前全量注册约 300 个)dev 首屏 −64 KB,入口包相应缩小低
D3富文本编辑器(vue-quill,dev 单文件 420 KB)改为异步/局部组件首屏少一个重量级依赖低

另有一项独立于本次问题的观察:echarts 在 9 个文件中被 import * as echarts 全量引入,生成了一个 1012 KB(gzip 335 KB)的 chunk,可通过 echarts/core 按需引入缩减。现状基线(供改造后对照):dist 入口 JS 1722.5 KB / gzip 561 KB,同目录 CSS 455.3 KB / gzip 64.9 KB,echarts chunk 1012.0 KB / gzip 334.7 KB。

方案取舍建议

你的目标建议
只想让开发不难受,且生产不受影响方案 A(迁移)。一次迁移,永久解决
不想动环境方案 B(Spotlight)+ C1(warmup)。零代码风险
顺便优化线上首屏方案 D。D1 有回归面,建议单独排期、单独验证
只关心生产无需任何改动(见 §5.4)

七、在你的项目上复现

以下用例脚本均已在 opc-dev-io-bench 开源,可在任何项目上复现同样的定位流程:

用例脚本 / 操作回答的问题
T1 冷/热缓存对照vite_startup_timing.mjs(连续跑两次取第二次)耗时在哪个阶段?冷热差多少倍?
T2 小文件随机读基准bench_small_files.mjs(冷盘测法:先 sudo purge)介质是 SSD 还是机械盘?
T3 请求瀑布waterfall.mjs(Playwright,可选)浏览器侧慢在哪?
T4 CPU 剖析profile_startup.sh + speedscope耗时函数是计算还是文件 I/O?
T5 迁移验收迁移后重复 T1 / T3方案 A 是否生效?
T6 生产解耦验证构建上下文 / 镜像内容 / 挂载检查生产链路是否与该盘无关?

T2 判定标准:< 0.2 ms/文件为 SSD 正常;0.5 – 2 ms 为慢介质或部分缓存;> 2 ms/文件即为机械盘,必然拖慢前端工具链。T1 判读:热缓存 createServer 通常 0.2–3 s;冷缓存达 8–20 s 且冷热差超 10×,即复现本问题。

T6 生产解耦验证(三条命令确认生产链路与该盘无关):

# 1) 确认构建上下文不含大目录(应只列出源码,不含 node_modules)
docker build --no-cache --progress=plain -t iotest . 2>&1 | grep -i "transferring context"

# 2) 确认镜像内静态文件来自构建阶段,而非本机挂载
docker run --rm --entrypoint sh iotest -c "ls /usr/share/nginx/html | head"

# 3) 确认部署链路文件(Dockerfile / nginx.conf / 部署脚本)中没有指向外接卷的路径
grep -rn "<外接卷挂载路径>" Dockerfile nginx.conf deploy.sh
# 预期:无输出

迁移验收的预期对照:createServer 冷缓存 9430 ms → < 1500 ms,热缓存 228 ms → 200–400 ms,小文件随机读 → < 0.2 ms/文件,首屏总时长 10 s+ → 1–3 s。

八、写在最后

这次排查最大的收获不是「换个盘」这个结论,而是流程本身:先排除代码侧、再分段计时锁定阶段、用 CPU Profile 区分「算」与「等」、用冷热对照证明 I/O 依赖、最后才下介质结论。每一步都有可复现的测量,反直觉的结论才立得住。遇到「变慢」,先用测量逼近根因,再动手优化——顺序反过来,大概率是在给错误的瓶颈写优化代码。

参考来源

  1. Vite 官方 Performance 指南(请求瀑布、resolve 开销、桶文件、warmup、剖析方法)
  2. Vite 官方依赖预构建文档(缓存策略、仅 dev 生效)
  3. server.warmup 配置项 · resolve.extensions 配置项(默认 7 项)
  4. How we made Vite 4.3 faaaaster(官方对 resolve 热路径的优化记录)
  5. vitejs/vite#8237(桶文件性能问题)
  6. 当 pnpm 遇上机械硬盘(独立同类案例,掘金)
  7. opc-dev-io-bench(本文全部测量脚本)
微信二维码 · 扫码联系作者
扫码加微信,直接聊 👋
我是 王亚博(Abel Wang),医疗行业 · AI 数字效能方向。
对本文有疑问、想交流技术方案或合作,扫码加我微信,备注来意通过更快。