
适用对象:使用 Nginx 反向代理,通过子路径(如
https://host/app-prefix/)发布前端应用,并配合sub_filter改写 HTML 中静态资源前缀的开发/运维人员。 本文档不假设读者经历过此问题,从原理讲起,最后给出可落地的解决方案。
1. 问题场景
1.1 现象描述
前端页面能打开,但静态资源部分加载成功、部分加载失败(HTTP 404):
- 一部分 JS / CSS 请求路径带应用前缀,返回 200;
- 另一部分 JS / CSS 请求路径不带前缀,请求落在 Nginx 根路径上,返回 404;
- 失败的资源往往集中在"进入某个页面后才开始加载"的那一批;
- 页面表现为白屏、局部不可用,或错误提示"页面出现异常 / chunk load failed"。
1.2 典型拓扑
bash
用户浏览器
│ 访问 http://host:port/app-prefix/embed/some-page
▼
Nginx(监听 80/端口)
│ ① 返回 HTML(经 sub_filter 改写,资源 URL 被拼上前缀 /app-prefix)
│ ② 转发 /app-prefix/api/* → 后端
▼
后端服务(如 FastAPI),静态目录挂在根路径 /
访问的页面 URL 带子路径前缀:
arduino
http://127.0.0.1:1022/app-prefix/embed/cron-jobs ← 页面本身可打开
但 DevTools / Network 里能看到:
bash
/app-prefix/assets/index-xxxx.js 200 ← 成功(带前缀)
/assets/xxx-yyyy.js 404 ← 失败(不带前缀!)
1.3 触发条件(三要素缺一不可)
- Nginx 反向代理:前端不是直连,而是经由 Nginx 转发;
- Nginx 使用
sub_filter(或同类 HTML 改写)为静态资源 URL 补充子路径前缀; - 前端使用了"懒加载" (
React.lazy/ 动态import()/ Vue 路由懒加载等),页面在运行时才会请求第二批资源。
若去掉任意一条(不反代 / 不 sub_filter / 不懒加载),通常都不会出现"部分成功部分失败"的诡异现象------这正是排查时最迷惑的地方。
2. 前置知识:三个必须理解的原理
2.1 前端应用的"两次加载"
现代前端(Vite 构建)应用的加载天然分两个阶段:
| 阶段 | 谁发起 | 典型资源 |
|---|---|---|
| 第一次加载 | HTML 解析器,读 <script> / <link> 标签 |
主入口 index-*.js、vendor 库(react、antd)、全局 CSS |
| 第二次加载 | 浏览器执行 JS 后,由 JS 代码动态发起 | 懒加载页面 chunk、该页面专属的组件/样式 |
关键在于:两个阶段的资源 URL"写在什么地方"完全不同。
2.2 资源 URL 的两个"出生地"
- 写在 HTML 文本里的 URL (
<script src="/assets/a.js">):它们是服务器返回的 HTML 源码的一部分,Nginx 能看到并改写。 - 写死在 JS 文件里的 URL (JS 内部
import("/assets/b.js")或运行__vitePreload时拼出的字符串):它们藏在 JS 内容里,Nginx 默认只改写 HTML 响应,不碰 JS 文件内容,因此无法被改写。
一句话:浏览器"请求哪个 URL"由 URL 的声明者决定。声明在 HTML 里的 URL 会被 Nginx 改写,声明在 JS 里的不会。
2.3 sub_filter 的工作原理与边界
sub_filter 是 Nginx 的一个"响应体文本替换"指令:
nginx
sub_filter '/assets/' '/app-prefix/assets/'; # 把响应体中的文本替换掉
sub_filter_once off;
它做的事情是:当 Nginx 转发后端响应给浏览器时 ,如果响应类型是 text/html(可配置),就把响应体里的字符串 /assets/ 替换成 /app-prefix/assets/。
它的三个固有边界:
- 只改响应内容,不改浏览器发出的请求 。浏览器请求
http://host/app-prefix/assets/x.js时,Nginx 收到的是一个已经拼好的 URL,它只能决定"转发还是 404",没有机会给请求补前缀; - 默认只处理
text/html。JS / CSS 文件(text/javascript、text/css)默认不替换; - 依赖 HTML 里真的写有那个字符串。如果资源 URL 不出现在 HTML 文本里(而是由 JS 运行时生成),sub_filter 就"无从下手"。
因此
sub_filter本质上是一个"给 HTML 文本里的 URL 加前缀"的工具,作用范围被严格限定在 HTML 这一层。
2.4 前端懒加载(动态 import)的特性
懒加载(React.lazy(() => import("./Page")))目的是"用的时候才下载该页面代码"。但副作用是:
- 该页面 chunk 不会出现在 index.html 里(否则就不是懒加载了);
- Vite 构建时把「懒 chunk → 它的依赖 chunk」的清单以相对/绝对路径字符串 写进主入口 JS(
__vite__mapDeps); - 用户真正进入该路由时,主入口 JS 调用 Vite 的运行时预加载函数
__vitePreload,用这些编译期写死的 URL 字符串 ,document.createElement("link")动态插入一批<link rel="modulepreload" as="script">到<head>,浏览器随即发起请求。
因为 URL 字符串在构建期 就按 base 配置被固化了,所以:
- 构建时
base: "/"→ JS 里写死的 URL 是/assets/xxx.js(无前缀); - 构建时
base: "/app-prefix/"→ JS 里写死的是/app-prefix/assets/xxx.js(带前缀)。
懒加载资源的 URL 前缀,只能在"构建时"决定,不能在运行时被 Nginx 补救。
3. 根因分析
把上面三个原理组合起来,问题全貌如下:
3.1 为什么"第一次加载"成功
index.html 里静态写着的 <script src>、<link rel="modulepreload"> 是 HTML 文本 。Nginx sub_filter 在转发 HTML 时把它们改写成带前缀的 URL:
html
<!-- 浏览器实际收到的(已被 sub_filter 改写) -->
<script src="/app-prefix/assets/index-abc.js"></script>
<link rel="modulepreload" href="/app-prefix/assets/react-vendor-xxx.js">
浏览器照单请求 → 命中 Nginx 带前缀的转发规则 → 200。主入口与首屏资源因此一切正常。
3.2 为什么"第二次加载"(懒加载)失败
进入懒加载路由后,主入口 JS 执行 __vitePreload,动态插入 到 <head> 的标签形如:
html
<!-- 运行时由 JS 生成,URL 是构建期写死的,sub_filter 从未有机会改写它 -->
<link rel="modulepreload" as="script" href="/assets/page-abc.js">
<link rel="modulepreload" as="script" href="/assets/columns-xxx.js">
这些标签不在 HTML 文本里 (是 JS 塞进去的),URL 又是构建期按 base:"/" 固化的绝对根路径。浏览器随即请求 http://host/assets/page-abc.js ------ 不带前缀 → Nginx 上没有对应规则 → 404。
页面 HTML 里"有 emotion 样式 / 但夹着一批
/assets/...的 modulepreload 标签"、且这批标签带as="script"(Vite 运行时写法)而无crossorigin(Vite 静态 HTML 写法),就是"懒加载运行时预加载"发生过的典型指纹。
3.3 为什么"部分页面正常"(如 chat 页面)
如果某页面是静态导入 (import Page from "./Page",而非 lazy),它的代码会直接打进主入口 bundle,不存在第二批运行时请求------所有依赖在第一次加载就齐了,自然永远正常。
因此,在同一个应用里常见的现象是:
- 静态导入的页面:永远正常(它是"第一次加载"覆盖的对象);
- 懒加载的页面:在子路径 + sub_filter 组合下几乎全部 404(它们依赖"第二次加载")。
唯一正常的页面往往不是因为它特殊,而是因为它恰好不需要懒加载。
3.4 为什么"加了新页面后才突然变坏"
懒加载页面增多会改变构建产物分块(chunk 图),可能让更多共享依赖从"首屏静态可达"变成"运行时才拉取",从而让受影响页面数量变多、问题集中爆发。但根因始终是上面 3.2 的机制,与具体加了哪个页面无关。
4. 解决方案
两类解决思路,分别对应去掉触发条件中的一环:不再懒加载 ,或 不再依赖 sub_filter。
方案一:不使用懒加载(快速规避)
把目标页面的动态导入改为静态导入,让它的代码与依赖全部并入主入口,不再产生运行时第二批请求。
tsx
// 改前(懒加载)
const CronJobsPage = lazy(() => import("./pages/CronJobs"));
// 改后(静态导入)
import CronJobsPage from "./pages/CronJobs";
- 优点:改动最小、效果立竿见影;页面从此与 chat 一样"一次加载到位",不再依赖任何运行时 URL 解析;
- 缺点:主入口体积增大(被并入的页面即使不常访问也会随首屏下载);如果应用其余部分仍有懒加载路由,它们仍会踩坑(需要逐一处理或改用方案二);
- 适用场景:懒加载页面数量少、且主包体积不敏感。
方案二:不使用 sub_filter(根治)
正确做法是:让前端构建产物本身就知道自己的部署子路径,从源头让所有 URL(HTML 里的、JS 运行时的)都带上前缀;Nginx 负责"剥掉前缀再转发",而不是"事后改写 HTML"。
- 前端构建时指定子路径 base:
bash
# Vite 项目(Linux/macOS)
VITE_BASE_URL=/app-prefix/ npm run build
# Windows(PowerShell)
$env:VITE_BASE_URL="/app-prefix/"; npm run build
构建后:
- index.html 里:
<script src="/app-prefix/assets/index-xxx.js">; - JS 运行时懒加载 URL:
/app-prefix/assets/xxx.js(因为编译期 base 已带前缀)。
HTML 与 JS 两处 URL 都带前缀,不再需要任何改写。
- Nginx 去掉
sub_filter,改为"剥前缀 + 反代":
nginx
server {
listen 80;
# 带前缀的静态资源/页面 → 剥掉前缀转发给后端(后端静态目录仍挂在 / 根)
location /app-prefix/ {
proxy_pass http://127.0.0.1:8080/; # 注意末尾的 / 表示剥离 /app-prefix/
proxy_set_header Host $host;
}
# API 同样剥前缀转发(若后端 API 以 /api 开头)
location /app-prefix/api/ {
proxy_pass http://127.0.0.1:8080/api/;
}
}
关键点:
proxy_pass的 URI 部分决定了"是否剥前缀"------写了/或具体路径就剥离,不写就原样透传。请按后端实际路由规划好。
- 优点 :一次性根治;HTML 与运行时懒加载 URL 统一带前缀;不再受 sub_filter 的 buffer 大小、Content-Type 等限制;应用内所有懒加载路由都安全;
- 缺点:需要重新构建前端 + 调整 Nginx,改动面稍大;若前端应用本身在代码里硬编码了不带前缀的跳转/请求,需要一并核对;
- 适用场景:长期维护、有多套环境、懒加载页面多、希望一劳永逸。
4.3 方案对比
| 维度 | 方案一:不用懒加载 | 方案二:不用 sub_filter |
|---|---|---|
| 改动位置 | 前端源码(导入方式) | 前端构建参数 + Nginx 配置 |
| 是否需重新构建 | 是 | 是 |
| 是否需改 Nginx | 否 | 是 |
| 主包体积 | 变大 | 不变 |
| 能否覆盖全站所有懒加载 | 否(需逐个处理) | 是(一次根治) |
| 推荐 | 应急/快速验证 | 长期方案 |
5. 诊断清单(遇到类似问题先这样判断)
- 看 Network 里失败请求的路径:是否与页面 URL 相比"少了前缀"?是 → 进入下一步;
- 看失败的资源属于哪个阶段 :若失败文件(如
index-*.js、columns-*.js、页面专属 chunk)不在页面 HTML 的静态<script>/<link>里,而是在 JS 执行后才出现 → 基本可断定是懒加载运行时请求; - 看 HTML 里是否存在两种 modulepreload :
- 无
as、带crossorigin→ Vite 构建期静态写入(会正常); - 带
as="script"、无crossorigin→ 运行时由 JS 动态插入(可能是 404 来源);
- 无
- 验证 root cause:临时把懒加载改成静态导入并重新构建部署,若 404 消失 → 确认是"懒加载运行时 URL 未带前缀";
- 根治:按方案二统一 base 与 Nginx。
6. 常见问题
Q1:为什么不能通过给 Nginx 加 location /assets/ 转发来解决? 可以临时缓解(让无前缀请求也能命中),但治标不治本:无前缀请求还可能是 API、页面路由、字体等其它资源;且它会掩盖真正的配置缺陷。长期应以方案二为准。
Q2:sub_filter 明明写了,为什么 JS 里懒加载的 URL 没被替换? sub_filter 默认只处理 text/html 响应;且即使对 JS 开启改写,也无法可靠匹配经过构建压缩、字符串拼接后的 URL。懒加载 URL 的正确修法是在构建期配置 base,而不是事后改文本。
Q3:为什么直连(不走 Nginx)时不报错? 直连时资源 URL 是否带前缀无所谓------只要能访问到文件就成功。只有在"反代 + 子路径 + 前缀只存在于 HTML 层"时,不带前缀的运行时请求才会 404。
文档基于真实案例整理:应用部署于 http://host:port/app-prefix/,经 Nginx sub_filter 改写 HTML 后,静态导入页面(chat)正常,懒加载页面(cron-jobs / inbox 等)资源 404。