Next.js 静态站点部署后用户看到满屏代码?一文彻底解决

适用场景:Next.js output: "export" 静态导出 + Cloudflare Pages(或任何静态托管平台)


一、问题现象

部署新版本后,部分用户在页面上点击导航时,整个页面突然变成了一堆看不懂的代码文本——既不是报错页面,也不是白屏,而是原始的 HTML/JS 源码直接渲染在屏幕上。

image-xZFT.png

典型触发路径:

  1. 用户打开页面正常使用
  2. 页面在浏览器标签页中静置一段时间(用户去做其他事)
  3. 期间开发者部署了新版本
  4. 用户回来继续点击操作
  5. 页面显示乱码般的代码文本

二、根本原因

2.1 Next.js 静态导出的文件结构

Next.js 在构建时会生成带有 内容哈希 的 JS chunk 文件:

out/
├── index.html
├── _next/
│   └── static/
│       ├── chunks/
│       │   ├── pages/dashboard-a1b2c3.js    ← 哈希值 a1b2c3
│       │   └── pages/settings-d4e5f6.js
│       └── abc123/
│           └── _buildManifest.js             ← 构建标识 abc123

HTML 文件中硬编码了这些带哈希的文件路径:

<script src="/_next/static/chunks/pages/dashboard-a1b2c3.js"></script>

2.2 版本不匹配的灾难链

┌──────────────┐     部署新版本     ┌──────────────┐
│  旧版 HTML    │ ──────────────→  │  新版文件系统   │
│  引用 a1b2c3  │                  │  只有 x7y8z9   │
└──────┬───────┘                  └──────┬───────┘
       │                                 │
       │  用户点击导航                      │
       │  请求 dashboard-a1b2c3.js        │
       │                                 │
       ▼                                 ▼
   ┌────────────────────────────────────────┐
   │   文件不存在 → 返回 404 HTML 页面        │
   │   浏览器把 HTML 当 JS 解析 → 解析失败    │
   │   页面直接渲染出原始代码文本 💥           │
   └────────────────────────────────────────┘

一句话总结:旧 HTML 中的 JS 引用指向了已被新版本替换掉的文件,请求 404 后平台返回的错误页被浏览器当作代码渲染。


三、解决方案

我们采用 两层防护 策略,确保用户在任何情况下都不会看到乱码。

3.1 第一层:资源加载失败自动刷新

layout.tsx<head> 中注入一段内联脚本,在页面最早期就开始监听资源加载错误:

// src/app/layout.tsx
<head>
  <script
    dangerouslySetInnerHTML={{
      __html: `
(function(){
  var reloaded = sessionStorage.getItem('__chunk_reload');
  if (reloaded === location.href) {
    sessionStorage.removeItem('__chunk_reload');
    return;
  }
  window.addEventListener('error', function(e) {
    var t = e.target;
    if (t && (t.tagName === 'SCRIPT' || t.tagName === 'LINK')) {
      var src = t.src || t.href || '';
      if (src.indexOf('/_next/') !== -1) {
        sessionStorage.setItem('__chunk_reload', location.href);
        location.reload();
      }
    }
  }, true);
})();
`,
    }}
  />
</head>

工作原理:

要点说明
捕获模式监听使用 addEventListener('error', fn, true) 捕获阶段,在错误冒泡前就能拦截
精准匹配只处理 <script><link> 标签,且 URL 包含 /_next/ 的资源
防死循环通过 sessionStorage 标记当前 URL,刷新一次后如果仍然失败则不再刷新
用户无感对用户而言只是页面闪了一下就恢复正常

3.2 第二层:Error Boundary 兜底页面

创建 src/app/error.tsx,当 Next.js 运行时捕获到 chunk 加载异常时展示友好提示:

// src/app/error.tsx
"use client";

import { useEffect } from "react";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    const isChunkError =
      error.message.includes("ChunkLoadError") ||
      error.message.includes("Loading chunk") ||
      error.message.includes("Failed to fetch dynamically imported module") ||
      error.message.includes("Importing a module script failed");

    if (isChunkError) {
      const key = "__chunk_reload";
      if (sessionStorage.getItem(key) !== location.href) {
        sessionStorage.setItem(key, location.href);
        location.reload();
        return;
      }
      sessionStorage.removeItem(key);
    }
  }, [error]);

  return (
    <div className="flex min-h-screen items-center justify-center ...">
      <div className="text-center space-y-5">
        <div className="text-6xl animate-bounce">🛸</div>
        <h2>哎呀,页面走丢了~</h2>
        <p>我们刚刚偷偷升级了系统 ✨ 刷新一下就能看到全新的页面啦!</p>
        <button onClick={() => location.reload()}>刷新试试 🔄</button>
        <button onClick={reset}>再来一次</button>
      </div>
    </div>
  );
}

这一层能捕获的异常类型:

  • ChunkLoadError — Webpack chunk 加载失败
  • Loading chunk xxx failed — 动态 import 的 chunk 404
  • Failed to fetch dynamically imported module — ESM 动态导入失败
  • Importing a module script failed — 浏览器原生模块加载失败

四、Next.js 配置优化

next.config.ts 中,以下配置与该问题相关:

// next.config.ts
const nextConfig: NextConfig = {
  output: "export",
  images: { unoptimized: true },

  // ---- 以下配置与部署稳定性相关 ----

  trailingSlash: true,
  skipTrailingSlashRedirect: true,
  onDemandEntries: {
    maxInactiveAge: 60 * 60 * 1000,
  },
  experimental: {
    optimizeCss: false,
    staleTimes: {
      dynamic: 0,
    },
  },
};

各配置项作用

配置项作用
trailingSlashtrue为所有路由生成 /path/index.html 而非 /path.html,确保 Cloudflare Pages 的目录式路由能正确匹配,避免路径解析歧义导致的 404
skipTrailingSlashRedirecttrue跳过 Next.js 默认的尾斜杠重定向逻辑,静态导出模式下无服务端可执行重定向,开启此项避免不必要的 redirect 配置冲突
onDemandEntries.maxInactiveAge3600000(1小时)开发模式下页面保留时间,设置为 1 小时可减少开发时的频繁重新编译,提升开发体验
experimental.optimizeCssfalse关闭实验性的 CSS 优化,避免静态导出时 CSS 内联或压缩引发的兼容性问题
experimental.staleTimes.dynamic0将客户端路由缓存的过期时间设为 0,确保每次导航都获取最新页面数据,不使用过期的缓存版本

其中 staleTimes.dynamic: 0 对解决本文问题尤为关键——它避免了客户端路由器使用过期的缓存 prefetch 数据,减少版本不匹配的窗口期。


五、防护机制流程图

用户点击导航
     │
     ▼
 Next.js 请求 JS chunk
     │
     ├── ✅ 成功 → 正常渲染页面
     │
     └── ❌ 失败(404)
          │
          ▼
     第一层:内联脚本捕获
     <script>/<link> error 事件
          │
          ├── 检测到 /_next/ 资源 404
          │   ├── 首次失败 → sessionStorage 标记 → 自动 reload
          │   └── 已刷新过 → 放行,交给下一层
          │
          ▼
     第二层:Error Boundary
     捕获 ChunkLoadError 等异常
          │
          ├── 首次失败 → 自动 reload
          └── 已刷新过 → 展示友好错误页面
                         「哎呀,页面走丢了~」
                         [刷新试试 🔄] [再来一次]

六、总结

层级文件捕获时机处理方式
第一层layout.tsx 内联 <script>资源标签加载失败(最早)静默自动刷新
第二层error.tsx Error BoundaryReact 运行时异常自动刷新 + 友好提示页
配置层next.config.ts构建阶段减少缓存、规范路由,缩小问题窗口期

三层配合,让用户在版本更新时的体验从「满屏乱码,一脸懵逼」变成「页面闪了一下,继续使用」。