Skip to content

揭秘 Next.js 应用在 Node.js v22 下的神秘 400 错误:一场跨版本的全局对象争夺战【前篇】

前言

在复杂的前端开发生态中,有时候最令人头疼的问题不是明显的错误,而是那些只在特定环境下出现的诡异行为。本文记录了我们如何一步步排查 Next.js 应用在 Node.js v22 环境下的神秘 400 错误,从最初的困惑到最终找到罪魁祸首的全过程。这是一个关于全局对象污染、Node.js 版本差异和服务端渲染的技术侦探故事。

问题现象

最初发现的现象非常奇怪:

  • 在开发环境next dev下,应用运行正常

  • 在生产环境next buildnext start下,访问根路径/就返回 400 错误

  • 这个问题只在 Node.js v22 环境下出现,在 v18 环境中一切正常

排查过程

第一天:现象分析与初步排查

1. 环境变量排查

我们的调查从最基础的部分开始 - 环境变量。Dify 项目使用了多层环境配置:

bash

# 检查 Dify 项目的环境变量
cat /Users/xxxx/Documents/my/codes/xxx/web/.env

我们尝试修改 API 前缀:



# .env 文件
- NEXT_PUBLIC_API_PREFIX=http://localhost:5001/api
+ NEXT_PUBLIC_API_PREFIX=http://xx.xx.xx.xx:5001/api

但这并没有解决问题,环境变量并非罪魁祸首。

2. 排除容器环境影响

为确保不是 Docker 容器导致的问题,我们尝试在裸机上运行:

bash
# 解除 Docker 容器依赖,直接在主机上运行
next build
next start

结果问题依然存在,这条路也走不通。

3. 版本差异排查

接下来,我们着眼于 Next.js 和 Node.js 版本差异:

bash
# 检查当前 Node.js 版本
node -v  # v22.15.1

# 切换到 Node.js v18 测试
nvm use 18  # Now using node v18.20.8
next build
next start  # 成功运行!

这是一个关键发现 - 在 Node.js v18 环境下,完全相同的代码可以正常运行!我们还在 ARM 架构机器上验证,确认在低版本 Node.js 下应用正常运行。

这让我们确定了问题与 Node.js 版本有关,但具体原因仍不明确。

第二天:深入调查与根因分析

4. Node.js URL 行为分析

我们查阅了 Node.js v18 到 v22 之间的 URL 相关变更记录,确实有一些变动,但这些记录并不能直接解释我们遇到的问题。

我们尝试在 Node.js 环境中测试 URL 行为:

js
// 在 Node.js 环境中测试
new URL('//')  // 在任何版本中都会报错

这表明 URL 解析问题并不是 Node.js 版本差异直接导致的。

5. Next.js 水合过程分析

我们开始怀疑问题出在 Next.js 的服务端渲染和客户端水合过程中。我们检查了 Next.js 源码中的关键部分:

ts
// 在 Next.js 源码中的 getLocationOrigin 函数
export function getLocationOrigin() {
  const { protocol, hostname, port } = window.location
  return `${protocol}//${hostname}${port ? ':' + port : ''}`
}

这个函数在服务端渲染时会尝试访问 window.location,但在 Node.js 环境中 window对象并不存在。我们猜测可能有某个库在服务端模拟了 window对象,但行为在不同 Node.js 版本下不一致。

6. 编译后文件全文搜索

面对一筹莫展的局面,我们决定在编译后的文件中全文搜索 window.locationglobal.location以及globalThis.location希望找到可能的线索:

这一步让我们找到了问题的根源 - wangEditor库的 node-polyfill:

ts
/**
 * @description node polyfill
 * @author wangfupeng
 */

// 必须是 node 环境
if (typeof global === 'object') {
  // 用于 nodejs ,避免报错
  const globalProperty = Object.getOwnPropertyDescriptor(global, 'window')

  // global.window 为空则直接写入
  // 部分框架下已经定义了global.window且是不可写属性
  if (!global.window || globalProperty.set) {
    global.window = global
    global.requestAnimationFrame = () => {}
    global.navigator = {
      userAgent: '',
    }
    global.location = {
      hostname: '0.0.0.0',
      port: 0,
      protocol: 'http:',
    }
    global.btoa = () => {}
    global.crypto = {
      getRandomValues: function (buffer: any) {
        return nodeCrypto.randomFillSync(buffer)
      },
    }
  }

  if (global.document != null) {
    // SSR 环境下可能会报错 (issue 4409)
    if (global.document.getElementsByTagName == null) {
      global.document.getElementsByTagName = () => []
    }
  }
}

这段代码在 Node.js 环境中注入了模拟的 global.location对象!

7. 验证解决方案

我们通过从项目中移除 wangEditor 依赖来验证我们的猜测:

json
// package.json
   "@tanstack/react-query": "^5.60.5",
   "@tanstack/react-query-devtools": "^5.60.5",
   "@ant-design/pro-components": "^2.7.15",
-  "@wangeditor/editor": "^5.1.23",
-  "@wangeditor/editor-for-react": "^1.0.6",
   "ahooks": "^3.8.4",

移除依赖后,应用在 Node.js v22 环境下成功运行!

根因分析

现在我们可以解释为什么相同的代码在不同 Node.js 版本下有不同的行为:

  1. wangEditor在 Node.js 环境中注入了global.location对象,包含默认值

    json
    {hostname: '0.0.0.0', port: 0, protocol: 'http:'}
  2. 在 Node.js v18 中,这些值被正确地使用,使得 getLocationOrigin() 返回http://0.0.0.0

  3. 但在 Node.js v22 中,注入的这些值变成了空字符串,导致 getLocationOrigin()返回//

  4. Next.js 随后尝试用

    js
    new URL('//')

    解析这个无效 URL,导致报错

  5. 在开发环境中,可能由于不同的代码路径或热重载机制,避免了这个问题在 parseRelativeUrl方法中,由于 window.location的值不同,生成的 URL 也就不同。在 Node.js v18 中能够正常执行,而在 v22 中则抛出错误。

调试 Next.js 应用的实用技巧

通过这次排查经历,我们总结了一些调试 Next.js 应用的实用技巧:

1. 启用 Node.js 调试模式

修改 package.json 中的脚本,添加 --inspect

选项:

json
// package.json
"scripts": {
  "dev": "pnpm run run:dev",
- "run:dev": "cross-env NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT dotenv -e .env.local next dev --turbopack",
+ "run:dev": "cross-env NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT NODE_OPTIONS='--inspect' dotenv -e .env.local next dev --turbopack",
  "build": "next build",
  "start": "cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && cross-env PORT=$npm_config_port HOSTNAME=$npm_config_host node .next/standalone/server.js",
+ "start:inspect": "cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && cross-env PORT=$npm_config_port HOSTNAME=$npm_config_host NODE_OPTIONS='--inspect' node .next/standalone/server.js",
}

2. 使用 Chrome DevTools 调试 Node.js 代码

  1. 启动应用:

    bash
    pnpm start:inspect
  2. 打开 Chrome,访问chrome://inspect

  3. 在 "Remote Target" 部分找到你的 Node.js 应用

  4. 点击 "inspect" 打开 DevTools

  5. 在 "Sources" 标签中找到源文件并设置断点

3. 区分服务端和客户端代码

在 Next.js 应用中,代码可能在服务端和客户端都会执行,因此必须谨慎处理浏览器特有的 API:

ts


// 错误方式 - 服务端渲染时会报错
const url = window.location.href;

// 正确方式 - 安全检查
const url = typeof window !== 'undefined' ? window.location.href : '';

4. 使用版本对比定位问题

当怀疑是版本问题时,系统地测试不同版本:

bash


# 测试不同 Node.js 版本
nvm use 18 && next build && next start
nvm use 20 && next build && next start
nvm use 22 && next build && next start

去除wangedit

node-v18

5. 全文搜索定位问题

有时候问题出在你意想不到的地方,全文搜索是最后的救命稻草:

bash

# 搜索编译后的代码
grep -r "关键词" .next/

结论与后续

这个案例展示了现代 Web 开发中的复杂性,特别是在服务端渲染和同构应用领域。通过系统的调试方法和对 Next.js 内部工作原理的深入理解,我们不仅解决了问题,还积累了宝贵的经验。

还有一个待解决的问题:为什么降级 Node.js 版本后,注入的 location对象值会有差异?这个问题需要更深入地研究 Node.js v18 到 v22 之间的实现变化,可能与 Node.js 对全局对象的处理方式有关。因为这步排查的过程预计会更消耗时间,所以我们放在下一篇开聊

最重要的教训是:在 Next.js 应用中,我们必须时刻意识到代码可能在服务端和客户端两种环境中执行,并且第三方库可能会以意想不到的方式影响应用行为,特别是在不同 Node.js 版本之间。

通过这次经历,我们再次认识到:技术调试不仅是一种技能,更是一种艺术,需要耐心、系统性思考和持续探索的精神。