npm run dev 启动 10 秒以上、浏览器里上百个请求 pending——根因不在代码,而在项目所在的存储介质:外接 USB 机械硬盘。同样的输入、同样的缓存,Vite createServer 冷文件缓存 9430 ms vs 热缓存 228 ms(41×),CPU Profile 里全是文件系统调用。生产环境不受影响(静态产物打进镜像,容器内 nginx 提供)。本文全部测量脚本已开源:opc-dev-io-bench,可在任何项目上复现同样的定位流程。
| 环节 | 是否受影响 | 实测 / 依据 |
|---|---|---|
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 提供,不读该盘 |
| 最终用户体验 | 不受影响 | 只与产物体积、部署机网络有关,与开发机磁盘无关 |
现象:
npm run dev 后,浏览器打开页面 loading 时间 10 秒以上;根因:项目目录位于一块外接 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」。
排查遵循「先排除代码 → 分段计时 → 采样剖析 → 冷热对照 → 硬件确认」的顺序。这套流程可复用于任何「前端变慢」的问题。
| 检查项 | 结果 | 结论 |
|---|---|---|
| 依赖预构建缓存是否反复重建 | 新建缓存的 browserHash 与三周前完全一致 | 排除「缓存反复失效」 |
| 多实例是否争抢缓存 | 4 个 dev server 并存时,新实例仍 255 ms 就绪 | 排除「实例争抢」 |
| 插件是否拖慢启动 | 逐个启用插件测量 createServer,热缓存下均 < 400 ms | 排除「社区插件问题」 |
| 包体是否造成服务端计算瓶颈 | 热缓存下整个 createServer 仅 228 ms | 排除「代码侧计算瓶颈」 |
ready in 曾达 21 s / 12 s,是同时运行生产构建时读到的,属于被污染的读数,已用干净对照推翻。本文只采用干净测量的数据。用 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 ms | 228 ms |
| listen() | 120 ms | 16 ms |
| 首次 GET /* | 87 ms | 39 ms |
| transformRequest('/src/main.js') | 271 ms | 54 ms |
* 此行是 JS API、服务已就绪条件下的读数;§3.5 浏览器实测的 GET / 4009 ms 是冷盘 + 首次整页加载场景,两者条件不同,并不矛盾。
结论:9 秒多的耗时几乎全部在 createServer 阶段,且同样的输入、同样的缓存,冷热相差 41 倍——说明它不是在「算」,而是在「等 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 的判断互相印证。
对 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 SSD | 0.01 – 0.05 ms |

用 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」。

$ mdutil -s <外接卷>
Indexing enabled.
该卷的 Spotlight 索引处于开启状态。mds / mdworker 会在后台持续扫描文件系统元数据,在机械盘上与开发工具链争抢磁头——这是叠加在根因之上的放大器。
Vite 开发模式刻意不做打包:依赖浏览器原生 ESM,按需转换、按需返回每个模块。这是它启动快的原因,但也意味着页面加载路径上每一个模块都要经过一次服务端文件读取 + 转换 + 一次 HTTP 往返。
本次实测:dev 首屏 143 个请求,其中 112 个是 src 源码模块、22 个是预打包依赖。在 SSD 上这是毫秒级的开销,在机械盘上每个请求要等几毫秒到十几毫秒的寻道,143 个请求串起来就是十几秒。
官方给过一个具体例子——解析隐式扩展名的 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 可以忽略,在机械盘上每次都要付一次寻道代价。显式写全扩展名、收敛用不到的项,收益随盘速放大。
官方专门有一节讲这个问题:
本项目有两处典型的桶文件式全量引入:
| 位置 | 代码 | 后果 |
|---|---|---|
src/main.js | import 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 个图标。
createServer 只要 228 ms,证明服务端没有计算瓶颈。它们是放大器:让首屏要读的文件数和字节数变多,把机械盘的代价等比放大。如 §3.4 所算:机械盘读一个文件要付「寻道 ≈9 ms + 等半圈 ≈4.17 ms」,SSD 无机械动作、延迟 < 0.1 ms,且支持深度并发队列。这意味着:在 SSD 上「并行读 1000 个小文件」是并行的;在机械盘上本质是串行的——磁头同一时刻只能在一个位置。开发工具链(node_modules 解析、模块图构建、IDE 索引、git status)恰恰是「海量小文件随机读」的典型负载,因此受机械盘惩罚最重。
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 慢、生产不慢」的机制来源之一。
Vite dev server 的全部转换都在项目目录上进行,每一次页面加载、每一次 HMR 都要读这块盘。受影响的日常操作:npm run dev 启动、页面刷新、HMR 响应、首次进入某个路由(懒加载 chunk 要现读现转)。
$ npm run build:prod
build #1: real 21.79
build #2: real 15.37
15–22 秒,是一次性成本(每次发版一次),远低于任何 CI 超时阈值。SSD 上预计可降到 8–12 秒,但收益有限,不构成问题。
项目的构建链是 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
三个原因使它与这块机械盘基本解耦:
.dockerignore 已正确排除大目录(node_modules / dist / .git),构建上下文因此只有约 29 MB(项目 349 MB − node_modules 302 MB − .git 18 MB)。反过来,如果 .dockerignore 缺失或写错,Docker 会把 302 MB / 24,524 个文件送进 daemon,那才会真正拖垮构建;npm ci 与 npm run build:prod 在容器内执行,读写的是 Docker Desktop 虚拟机的磁盘镜像(位于内置 SSD),不是外接卷;COPY . . 这一步读本机源码(约 29 MB),可忽略。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,风险很低)。npm install / npm ci:解压上万个包文件,机械盘上耗时成倍增加;git status / git diff:.git 也在该盘上;pnpm 用户在机械盘上遇到 install 长期卡在 reused/added 阶段的同类案例,可参考掘金《当 pnpm 遇上机械硬盘》——把 store 迁到 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 的路径假设都容易在软链上出问题,收益不如整体迁移,维护成本反而更高。# 1) 仅关闭该卷索引(推荐,可逆)
sudo mdutil -i off <外接卷>
# 2) 图形化等价做法:系统设置 → Siri 与聚焦 → 聚焦 → 隐私,把该卷加入排除列表
# 3) 卷根目录放 .metadata_never_index(设备级、无需 sudo)
# ⚠️ 未经本文实测;社区有较新 macOS 上失效的反馈。
# 验证方法:放置后执行 `mdutil -s <卷>`,若仍显示 Indexing enabled 即无效。
代价:该盘上的文件将无法被 Spotlight 搜索到。若盘上还存有需要检索的资料,先确认再执行。
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。
这解决的是另一个独立问题(首屏体积),但在机械盘上它同时是放大器(§4.3)。三件事,按性价比排序:
| # | 改动 | 收益 | 风险 |
|---|---|---|---|
| D1 | element-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 依赖、最后才下介质结论。每一步都有可复现的测量,反直觉的结论才立得住。遇到「变慢」,先用测量逼近根因,再动手优化——顺序反过来,大概率是在给错误的瓶颈写优化代码。