🚀 省流助手(速通结论)
- 现象:
pnpm dev启动 Vite 后,浏览器访问http://localhost:5173/返回 404,但访问http://127.0.0.1:5173/一切正常 - 根因:之前删掉的项目目录里有一个 Vite 进程没被 kill,成了孤儿进程,绑在 IPv6
::1:5173上;macOS 的localhost优先解析到 IPv6,请求被这个孤儿进程接管,返回 404 - 解决:
lsof -i :5173找出多余的 node PID,kill <PID>即可
一、现象:奇怪的「半通不通」
某天打开新项目准备开发,习惯性 pnpm dev:
$ pnpm --filter=@your-org/your-app run dev
> @your-org/your-app@0.0.0 dev
> vite --open
VITE v5.4.21 ready in 1045 ms
➜ Local: http://localhost:5173/
➜ Network: http://10.0.0.99:5173/
启动看上去一切正常。但浏览器自动打开 http://localhost:5173/ 之后:
找不到与以下网址对应的网页:http://localhost:5173/
HTTP ERROR 404
奇怪的是——把 URL 换成 Network 那一行的 IP(http://10.0.0.99:5173/),页面完全正常。
第一反应:「应该是浏览器缓存吧?」
开个无痕窗口——还是 404。
「换 Chrome 试试?」——还是 404。
昨天还好好的,今天就这样了。
二、排查路径:层层排除
假设 1:浏览器层问题(缓存 / Service Worker / 扩展)
最常见的可能。但隐身窗口也 404,多个浏览器都 404 ——浏览器层基本可以排除。
假设 2:本地代理软件劫持
有些代理工具(Clash、Surge 之类)会顺手把 localhost 流量也挂到自己的端口上。临时关掉系统代理 + 关掉所有代理客户端,情况依旧——排除。
假设 3:hosts 文件被改
$ cat /etc/hosts | grep localhost
127.0.0.1 localhost
::1 localhost
干净。排除。
到这里如果还停留在猜测,会越走越偏。该让证据说话了。
三、关键证据:终端三连击
第一发:lsof 看谁占着 5173
$ lsof -i :5173
COMMAND PID USER FD TYPE NODE NAME
node 4040 <user> 37u IPv4 TCP *:5173 (LISTEN)
node 20742 <user> 37u IPv6 TCP localhost:5173 (LISTEN)
两个 node 进程?! 一个绑在 IPv4 通配地址,一个绑在 IPv6
localhost。
我刚启动的 Vite 应该只有一个,那 PID 20742 是哪来的?
第二发:curl 分别打三种地址
# IPv4 直连
$ curl -v http://127.0.0.1:5173/
< HTTP/1.1 200 OK
< Content-Type: text/html
(正常返回 vite 的 index.html)
# IPv6 直连
$ curl -v http://[::1]:5173/
< HTTP/1.1 404 Not Found
< Content-Length: 0
# localhost 让系统决定
$ curl -v http://localhost:5173/
* Trying [::1]:5173... ← 注意这里!localhost 解析到了 ::1
< HTTP/1.1 404 Not Found
谜底揭开一半:
- PID 4040(我刚启的 Vite)在 IPv4
127.0.0.1:5173上,正常返回 200 - PID 20742(来路不明)在 IPv6
::1:5173上,返回 404 - macOS 上
localhost优先解析到::1,所以浏览器和curl localhost默认走 IPv6,被另一个进程接管
第三发:PID 20742 到底是谁?
$ ps -p 20742 -o pid,ppid,user,command
PID PPID USER COMMAND
20742 20729 <user> node /Users/<user>/projects/legacy-web/node_modules/...
$ lsof -p 20742 | grep cwd
node 20742 <user> cwd DIR /Users/<user>/.Trash/legacy-web
看到
cwd(current working directory)那行了吗?这个进程的工作目录在
.Trash里。
也就是说,我之前把一个叫 legacy-web 的旧项目目录拖进了废纸篓——但当时它的 pnpm dev 还在跑。进程没被 kill 掉,孤零零地继续监听 5173 端口。
新项目今天要用 5173,Vite 拿不到 IPv6(被孤儿占了),只绑成功了 IPv4。于是浏览器走 localhost 默认 IPv6 时,永远撞上孤儿进程,返回 404。
四、根因
三件事叠加,触发了这个诡异现象:
- Vite dev server 在删除项目时不会自动停止——文件系统的
unlink不会通知到进程,进程的 cwd 指向已删除目录,但内核保留了 inode 引用,进程照常运行 - Vite 默认在 IPv6
::上 listen——Node 的server.listen('localhost')在双栈系统上会同时绑 IPv4 和 IPv6,所以你看到的*:5173和localhost:5173实际上是两个 socket - macOS 的
getaddrinfo默认优先返回 IPv6——浏览器拿localhost去解析时,先拿到::1,于是走 IPv6 路径
三者叠加 → localhost 被孤儿 Vite 截胡。
五、解决方案
临时救火
# 找到孤儿进程
$ lsof -i :5173
# 干掉它
$ kill 20742
立刻恢复正常。
永久方案:Vite 强制只绑 IPv4
如果你想避免类似问题,编辑 vite.config.ts:
export default defineConfig({
server: {
host: '127.0.0.1', // 强制 IPv4
},
});
或者养成习惯,开发时直接用 127.0.0.1 替代 localhost。
六、预防建议
-
删项目目录前先
Ctrl+C停掉 dev server,或关闭对应终端,让进程优雅退出 -
遇到「奇怪的 localhost 问题」,第一反应
lsof -i :端口看占用情况,用证据排除假设比脑补更快 -
调试网络问题时,
curl三连击是必备:curl http://127.0.0.1:port(IPv4)curl http://[::1]:port(IPv6)curl http://localhost:port(让系统决定)
对比三者的差异,能快速定位 IPv4/IPv6 双栈问题。
七、知识点提炼
7.1 localhost 到底解析到哪?
在 macOS / 多数现代 Linux 上,/etc/hosts 同时定义:
127.0.0.1 localhost
::1 localhost
getaddrinfo() 返回时,默认优先返回 IPv6 地址。所以 Node.js、curl、浏览器拿到的第一个地址通常是 ::1。
如果想强制 IPv4:
- Node 启动加
--dns-result-order=ipv4first - 直接用
127.0.0.1替代localhost
7.2 Vite 的 listen 行为
Vite 5.x 默认 server.host 为 localhost。底层 Node 在双栈系统上会同时绑 IPv4 和 IPv6——这就是 lsof 里能看到一个 IPv4 socket 加一个 IPv6 socket 的原因。
如果其中一个端口被占用,Vite 不会自动换端口,会启动失败;但如果 IPv4 没被占、只有 IPv6 被占(像本文的情况),Vite 会只成功绑 IPv4,看起来一切正常,但默认路径上的请求会被孤儿进程截胡。
7.3 为什么孤儿进程不会自动退出?
Unix 进程的工作目录被删除后:
- 内核保留 inode——只要还有进程持有引用,目录在文件系统视图上消失了,但实际内容还在
- 进程不会主动收到信号——除非父进程 SIGTERM 它,或它自己注意到资源消失
- Vite 的 file watcher(fsevents)在目录被删时可能会报错,但不会主动退出 dev server
类似的孤儿进程模式还会在以下场景出现:
- VSCode 关闭但终端中的 dev server 没退出
- macOS 强制注销而某些 GUI 应用未正常关闭
- 容器被
docker stop但容器内进程对 SIGTERM 没响应
养成 lsof -i 和 ps aux | grep node 的肌肉记忆,能省下很多 debug 时间。
写于一次愉快的 debug 之后。如果你也遇到了「localhost 神秘 404」,希望这篇能帮你少走 30 分钟弯路 🚀