Nginx sub_filter 的“幽灵陷阱”:为什么页面能打开,懒加载的 JS 却全是 404?

适用对象:使用 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 触发条件(三要素缺一不可)

  1. Nginx 反向代理:前端不是直连,而是经由 Nginx 转发;
  2. Nginx 使用 sub_filter(或同类 HTML 改写)为静态资源 URL 补充子路径前缀
  3. 前端使用了"懒加载"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/

它的三个固有边界:

  1. 只改响应内容,不改浏览器发出的请求 。浏览器请求 http://host/app-prefix/assets/x.js 时,Nginx 收到的是一个已经拼好的 URL,它只能决定"转发还是 404",没有机会给请求补前缀
  2. 默认只处理 text/html 。JS / CSS 文件(text/javascripttext/css)默认不替换;
  3. 依赖 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"。

  1. 前端构建时指定子路径 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 都带前缀,不再需要任何改写。

  1. 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. 诊断清单(遇到类似问题先这样判断)

  1. 看 Network 里失败请求的路径:是否与页面 URL 相比"少了前缀"?是 → 进入下一步;
  2. 看失败的资源属于哪个阶段 :若失败文件(如 index-*.jscolumns-*.js、页面专属 chunk)不在页面 HTML 的静态 <script>/<link> 里,而是在 JS 执行后才出现 → 基本可断定是懒加载运行时请求;
  3. 看 HTML 里是否存在两种 modulepreload
    • as、带 crossorigin → Vite 构建期静态写入(会正常);
    • as="script"、无 crossorigin → 运行时由 JS 动态插入(可能是 404 来源);
  4. 验证 root cause:临时把懒加载改成静态导入并重新构建部署,若 404 消失 → 确认是"懒加载运行时 URL 未带前缀";
  5. 根治:按方案二统一 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。

相关推荐
PBitW1 小时前
为什么vite中TS报错,可以继续运行?Webpack不行?
前端·webpack·typescript·vite
光影少年1 小时前
react navite手写 FlatList 优化配置
前端·react native·react.js
曹牧1 小时前
C#:文本文件读取
服务器·前端·c#
柚yuzumi1 小时前
前端优化,从少触发一次开始:防抖与节流
前端·javascript
默_笙2 小时前
🚤 CSS 布局的"圈地运动":BFC 就是浏览器的独立领地
前端·javascript
我爱写代码i2 小时前
AI对话绘画数字人源码 - uniapp前端
前端·人工智能·uni-app
OpenTiny社区2 小时前
太酷了!装上OpenTiny dsh‑genui这个插件,你的DeepSeek Harness点击就能干活了!
前端·ai编程
ModyQyW2 小时前
vite-plugin-uni-pages 更新了什么
前端·uni-app
前端大卫3 小时前
H5 渲染 PDF 并添加高亮的两种方案【附源码】
前端