部署后静态资源 404:5 个原因 + 修复路径(Astro/Vite)

图片、CSS、字体在生产环境 404,本地却正常。5 个已核实的原因和修复:没进 dist、base 路径、大小写、content hash、CDN 缓存。

部署后页面打开正常,但图片是裂图、CSS 没加载、字体掉光——浏览器 Network 面板里一片 404。本地 npm run dev 一切正常,让人怀疑是平台问题,但九成情况问题在你这边:文件根本没被 build 复制到 dist/、你写的硬编码路径忽略了 base 设置、文件名在你的 Mac 和 Linux 服务器上大小写不一致、或者 Vite/Astro 给文件名加了 content hash 而你引用的还是原名。

最快的排查(覆盖约 80% 的情况):打开 DevTools → Network,点那个红色的 404 请求,复制它的完整路径(比如 images/foo.png)。本地做一次干净 build,然后搜这个路径:

rm -rf dist/
npm run build
find dist -path "*images/foo*"
  • 找不到 → 文件没进 build 产物。把它移到 public/,或者改用 import 引用(原因 1)。
  • 找到了,但文件名带 hash,比如 foo.a3f7b9.png → 你硬编码了不带 hash 的原名。改用框架的资源引用方式(原因 4)。
  • 找到了,名字也一字不差 → 文件 build 没问题,那问题出在请求路径上:base 前缀(原因 2)、大小写不一致(原因 3),或者 CDN 边缘节点的旧缓存(原因 5)。

下面把这 5 类逐一拆开,每类都给一条能在 dist/ 或 DevTools 里直接验证的判断方式。

我属于哪一类?

现象最可能的原因跳到
文件在 dist/ 里完全不存在放错目录 / 没 import原因 1
站点部署在 /blog/ 这类子路径下base 路径偏移原因 2
Mac/Windows 上正常,只有线上 Linux 才 404文件名大小写不一致原因 3
请求的名字不带 hash,dist/_astro/ 里的带content hash 对不上原因 4
文件在 dist/ 里有,加 cache buster 是 200、不加是 404CDN 边缘节点旧缓存原因 5

常见原因

按命中率从高到低。

1. 资源放错位置,没进 build 产物

只有放在 public/ 下的文件会被原样复制到 dist/(路径不变)。放在 src/assets/ 的资源必须通过 import 引用,让 Vite 处理;直接写 /src/assets/foo.png 在浏览器里 404。

写法结果
文件在 public/images/foo.png,HTML 写 /images/foo.pngOK
文件在 src/assets/foo.png,HTML 写 /src/assets/foo.png404
文件在 src/assets/foo.png,组件里 import foo from './foo.png'OK,Vite 会输出到 dist/_astro/foo.<hash>.png

如何判断

npm run build
find dist -name "foo.png" -o -name "foo.*.png"

找不到就是没进产物。

2. base 路径偏移

astro.config.mjs 里设了 base: '/blog',所有资源实际部署到 /blog/... 下。如果你模板里硬编码 /images/foo.png,浏览器请求的就是 https://yourdomain.com/images/foo.png(404)而不是 https://yourdomain.com/blog/images/foo.png。这是 GitHub Pages 项目站点最经典的坑:在 npm run dev 里好用,是因为 dev server 两个路径都照单全收;一旦部署到子路径下就崩。

Astro 不会替你改写裸写的 <img src="/...">public/ 的 URL——按 Astro 官方文档,设了 base 之后,所有静态资源 URL 都得由你自己加上 base 前缀。连 public/favicon.svg 都得按 /blog/favicon.svg 来请求。

如何判断

grep -n "base:" astro.config.mjs

如果设了 base,所有路径都用 import.meta.env.BASE_URL 拼前缀(见下面的修复),或者用 Astro 的 <Image> 组件,别硬编码带前导斜杠的路径。

3. 大小写不一致

/Images/Foo.PNG/images/foo.png 在 macOS 和 Windows 本地(默认大小写不敏感)是同一个文件,在 Linux 部署服务器(大小写敏感)是两个文件。本地永远好,生产永远 404。Netlify 和 Vercel 的 build/serve 环境都是 Linux,所以这是它们支持论坛里最常见的「本地能 build、生产 404」报告之一。

如何判断

ls public/images/ | grep -i foo

把磁盘上的实际大小写和 DevTools 里请求的 URL 逐字对一遍。HTML 里的引用必须和磁盘文件名一字不差,大小写和扩展名都算(.JPG 不等于 .jpg)。一个可靠的抓法:git config core.ignorecase false,然后在大小写敏感的文件系统上重新 clone 一份——如果文件「消失」了,说明你的引用大小写写错了。

4. Vite/Astro 加了 content hash,你引用的还是原名

Vite 默认把 src/ 里 import 的资源加哈希,输出成 foo.a3f7b9.png。如果你在 .mdx / .astro 里硬编码 /src/assets/foo.png/foo.png,build 后那个路径根本没有文件。

如何判断:DevTools → Network → 看 404 那个请求的 URL,再 ls dist/_astro/ 看实际文件名带没带哈希。

5. CDN 还没同步 / 旧版本缓存

刚部署完,CDN 边缘节点还在拉新文件。或者上一次部署的 HTML 缓存了,引用了旧的 hash 文件名,但 dist/ 里那个 hash 文件已经被新 build 覆盖——一个回访用户停在旧 HTML 页面上,请求 app.OLD.js,这文件已经不存在了,于是 404。(Vercel 的 Skew Protection,截至 2026 年 6 月在 Pro 和 Enterprise 套餐上提供,就是专门把客户端钉在它当初加载的那次部署的资源上,时长可配置;没有它的话,硬刷新只救了这一个访客,救不了缓存。)

如何判断

curl -I "https://yourdomain.com/images/foo.png"
curl -I "https://yourdomain.com/images/foo.png?cb=$(date +%s)"

带 buster 是 200、不带是 404 → CDN 缓存;两个都 404 → 文件确实没传上去。

最短修复路径

按收益从高到低,前 3 步通常能解决 80% 的问题。

Step 1:本地 build 后确认文件在 dist 里

rm -rf dist/
npm run build
find dist -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.svg" -o -name "*.css" -o -name "*.woff*" \) | head -20

把 DevTools 里 404 那个 URL 的路径片段(如 images/foo.png)拿来:

find dist -path "*images/foo*"
  • 找不到 → 文件没进产物,检查 public/ 位置或换成 import
  • 找得到但名字带 hash → 你引用的是原名,需要让框架处理引用

Step 2:用框架推荐的资源引用方式

Astro 推荐:

---
import { Image } from 'astro:assets';
import foo from '../assets/foo.png';
---
<Image src={foo} alt="foo" />

这样 Vite 会保证 build 后引用和文件名一致(带 hash 也对得上)。

如果资源不能用 import(比如 MDX 里的图),放进 public/

public/images/foo.png   →  HTML 里写 /images/foo.png

注意 public/ 里的文件不会被处理——不压缩、不加 hash、不优化。换文件名时浏览器可能缓存旧版本,需要手动加 cache buster。

Step 3:处理 base 路径

如果有 base: '/blog',所有硬编码路径都要前缀。Astro 推荐:

<img src={`${import.meta.env.BASE_URL}images/foo.png`} alt="" />

或者:

<img src={new URL('images/foo.png', Astro.url).pathname} alt="" />

不要写 src="/images/foo.png"——只在 base: '/' 时正确。

Step 4:清 CDN,按文件清

# 验证源站
curl -I "https://yourdomain.com/images/foo.png?cb=$(date +%s)"
# 看 CDN 状态
curl -I "https://yourdomain.com/images/foo.png"

如果带 buster 200、不带 404,清这个 URL 的 CDN(菜单路径截至 2026 年 6 月):

  • Cloudflare:控制台 → 你的域名 → Caching → Configuration → Purge Cache → Custom Purge,选 URL,粘贴完整 URL。现在所有套餐都支持单文件(即时)清缓存。注意路径是大小写敏感的,要按你实际提供的大小写来清。
  • Vercel:命令行跑 vercel --prod --force,或在控制台打开那次部署的 ⋯ 菜单 → Redeploy,取消勾选 Use existing Build Cache
  • NetlifyDeploys → Trigger deploy → Clear cache and deploy site

Step 5:CI 加资源 404 冒烟测试

部署后跑:

#!/usr/bin/env bash
set -e
BASE="https://yourdomain.com"
# 关键资源列表
ASSETS=(
  "/favicon.ico"
  "/images/og-default.png"
  "/fonts/inter.woff2"
)
for a in "${ASSETS[@]}"; do
  code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$a")
  [[ "$code" == "200" ]] || { echo "FAIL $a$code"; exit 1; }
done
echo "All key assets OK"

任何缺失资源在部署后 30 秒内就会被发现。

怎么确认修好了

别信浏览器刷新——本地缓存会骗你。从干净状态确认:

# 1. 资源从线上源站返回 200(加 -L 跟随 base 路径的跳转)
curl -sIL "https://yourdomain.com/images/foo.png" | head -1

# 2. 页面上不再有 404(grep 线上 HTML 看这个资源,然后重新看 Network)

然后在 DevTools 里勾上 Disable cache(Network 标签页)刷新页面,或者用无痕窗口,确认每个之前飘红的请求现在都是 200(而不是 200 (from disk cache))。如果你清了某个 CDN URL,把原因 5 的 cache-buster 对比再跑一遍:带 buster 和不带 buster 现在都应该是 200

预防建议

  • 资源全部小写 + kebab-case,杜绝大小写敏感问题
  • 组件内引用资源用 import,让框架管 hash;不要硬编码 /src/... 路径
  • 如果用了 base,所有路径用 import.meta.env.BASE_URL 拼接
  • 部署后 CI 跑关键资源 200 检查,favicon / og 图 / 主字体最少要在内
  • 大文件资源放 CDN 单独 host,避免 build 体积失控

常见问题

为什么 npm run dev 里好好的,生产环境就 404? dev server 直接从磁盘读文件,跑在大小写不敏感的 Mac/Windows 文件系统上,而且无视你的 base 设置,所以写错大小写、漏了 base 前缀,在本地都「能用」。生产环境是在大小写敏感的 Linux 上、按你真实的 base 跑一次真正的 build。本地复现的方法:npm run build && npm run preview——preview 会遵守 base,而且只提供 dist/ 里有的东西。

图片到底放 public/ 还是 src/assets/ 能用 import 就放 src/assets/ 并 import 进来:框架会给文件名加 hash、优化图片、并保证引用对得上。public/ 只留给那些必须保持固定、可预测 URL 的文件(favicon、robots.txt、被绝对 URL 引用的 OG 图、site.webmanifest)——这些是原样复制,不做任何处理。

dist/ 里的 CSS/JS 文件叫 app.a3f7b9.css,但浏览器请求的是 app.css,哪里坏了? 你引用了一个不带 hash 的名字,Vite 在 build 时把它改名了。别硬编码路径——import 这个资源(或通过框架链接样式表),让输出的 hash 始终同步。硬编码一个带 hash 的名字下次 build 也会坏,因为文件内容一变 hash 就跟着变。

我清了 CDN 还是 404,接下来怎么办? 把原因 5 的检查再跑一遍。如果 cache-buster URL(?cb=...)也 404,说明文件确实不在源站上——这是 build/上传问题,不是缓存问题,回到 Step 1 确认它进了 dist/。CDN 清缓存只在 buster URL 返回 200 时才管用。

加了 base 路径,是不是每个图片标签都得改? 凡是你硬编码带前导斜杠的路径,都得改。用 import.meta.env.BASE_URL 加前缀,或者在 <head> 里加一个 <base href={import.meta.env.BASE_URL}>,然后用不带前导斜杠的根相对路径。注意 BASE_URL 末尾带不带斜杠取决于你的 trailingSlash 配置,所以拼路径时靠字符串拼接,别假设斜杠在或不在。

相关阅读

标签: #部署 / 托管 #排查 #排查