改完代码页面不动:一次 Next.js 16 热更新排查实录

改样式、改文案,保存后页面一点反应都没有,刷新还是旧内容。最后定位到两个独立问题:Next 16.2 的服务端热更新回归,以及 dev server 对 HMR WebSocket 的同源校验。

  • #Next.js
  • #热更新
  • #踩坑
本页目录

改样式、改文案,保存,页面没有任何变化。手动刷新,还是旧内容。只有重启 pnpm dev,改动才出现。

这件事发生在我把网站升到 Next.js 16.2.1 之后。一开始以为是 Tailwind 没有扫描到新类名,后来以为是浏览器缓存,都不是。

这篇记录完整的排查过程:怎么把问题从「页面不动」缩到两个具体原因,以及最后怎么修的。

先别清缓存:把三层分开

热更新出问题,通常卡在三层里的一层:

  1. 浏览器缓存。
  2. 文件监听和编译。
  3. 服务端渲染时用的模块。

大部分教程会让你清缓存、删 .next、重启。删缓存有用,但它是碰运气,不告诉你问题在哪一层。

我的顺序是先用最笨的办法取证。

第一步,直接看服务端返回的 HTML:

curl -s http://localhost:3000/ | grep "扫一扫关注"

改过的新文案不在返回里,说明不是浏览器缓存。服务器自己就在吐旧内容。

第二步,看 HMR 通道有没有消息。用一个 Node 脚本连上 dev server 的 WebSocket:

const ws = new WebSocket("ws://localhost:3000/_next/webpack-hmr");
ws.addEventListener("message", (e) => console.log(e.data));

改文件后能看到 building、built,说明文件监听和编译都在跑。

第三步,去编译产物里搜新文案:

grep -rl "新文案" .next/dev/server/chunks

它出现了。Turbopack 读到了新文件,也编译了。

三层查完,结论很反直觉:编译是好的,问题在渲染时用的还是旧模块。

问题一:16.2 的服务端 Fast Refresh 回归

浏览器一旦连上 HMR,服务端渲染就固定在第一次编译出来的模块上,关掉标签页也不会恢复,只有重启 dev server 才行。

我去翻了 Next.js 的 issue,#96705 的描述几乎一样:连接浏览器之前,改服务端组件,curl 拿到的是新内容;连上浏览器之后,所有后续修改都不再应用。修复 PR #92273 进了 16.2.3,提交者在 16.3.0 上复测通过。

我装的是 16.2.1,正好在修复之前。

临时办法是关掉服务端 Fast Refresh:

next dev --no-server-fast-refresh

关掉之后,改服务端组件会走整页重载,能用了。代价是每次改动都丢客户端状态,而且它在 Node 24 上还有一个内存问题(issue #100367:每次重载的旧模块会留在内存里)。所以它只是拐杖,不是解法。

问题二:127.0.0.1 连不上 HMR

排查途中还发现一个独立的坑:用 http://127.0.0.1:3000 打开时,HMR 根本连不上。

Next 16 的 dev server 会对 HMR WebSocket 做同源校验,默认只放行 localhost。源码在 node_modules/next/dist/server/lib/router-utils/block-cross-site-dev.js,放行列表是 ["*.localhost", "localhost"] 再加上配置里的 allowedDevOrigins。

我直接用 WebSocket 握手验证了一下:Origin: http://localhost:3000 返回 101,Origin: http://127.0.0.1:3000 返回 Unauthorized。局域网 IP 也一样。

修法是在 next.config.mjs 里补一行:

allowedDevOrigins: ["127.0.0.1"],

手机真机调试的话,把手机访问用的局域网 IP 也加进去。改配置不会热更新,必须重启 dev server。

升到 16.4.3

两个问题里,第一个是框架的回归,最干净的办法是升级。

我选了 16.4.3:它是 16.4 的 LTS 回补版,只回补修复。16.5 还是 canary,不碰。React 没动,还是 19.2.4。

步骤:

# 改 package.json:next / eslint-config-next / @next/eslint-plugin-next 对齐到 16.4.3
CI=true pnpm install --no-frozen-lockfile
rm -rf .next
BASE_URL=https://jiepp.com pnpm build

加 CI=true 是因为本机 pnpm 在没有 TTY 时拒绝删除 node_modules。代价是它会在项目里生成一个本地 .pnpm-store,几百兆,记得删掉并写进 .gitignore。

升级后 pnpm lint 通过,生产构建通过,next-sitemap 正常生成。

验证热更新没有靠肉眼刷新,还是用无头浏览器:

  1. 打开 localhost:3000。
  2. 改页脚文案,不刷新,等 6 到 8 秒,检查 DOM 里有没有新文案。
  3. 改 globals.css 加一条自定义属性,检查样式表里有没有这条规则。
  4. 控制台没有报错。

都通过了。dev 脚本也从 next dev --no-server-fast-refresh 改回了 next dev。

这次的结论

  • 热更新不动,先分清浏览器缓存、编译、服务端渲染三层,别一上来删缓存。
  • 编译产物里有新代码,不等于页面会用新代码。
  • 127.0.0.1 和 localhost 在 HMR 眼里是两个来源。
  • 改 next.config.mjs 之后必须重启。
  • 为了复现同时开了三个 dev server 和几份项目副本,机器直接重启了一次。对照实验有用,但别在 16G 内存的机器上这么干。

本文同步发布于公众号「傲慢鱼偏见」,欢迎关注。