正文

Nuxt 4 不是一次“推倒重来”的升级,而是一次更偏向开发体验、项目组织、数据获取一致性和类型安全的稳定型大版本演进。官方在 Nuxt 4.0 发布说明中提到,它重点改进了新的 app/ 目录结构、更聪明的数据获取、更好的 TypeScript 支持,以及更快的 CLI 和开发体验。

不过,越是“看起来平滑”的升级,越容易让团队低估细节风险。真正开始升级前,建议先查四件事:目录结构有没有受影响、useFetch 行为有没有改变、TypeScript 是否会暴露新错误、模块和本地扩展是否兼容 Nuxt 4

为什么 Nuxt 4 升级前不能直接改版本号?

很多项目从 Nuxt 3 升到 Nuxt 4,第一反应是执行:

或者:

这确实是官方升级指南给出的第一步,但它不应该是你的第一项工作。官方指南也明确列出了 Nuxt 4 的关键迁移点,包括新目录结构、数据获取层、TypeScript 配置变化、模板编译变化、useFetch 默认值变化等。

更安全的顺序是:

换句话说,Nuxt 4 升级前不是先“改”,而是先“查”。

  • 先在独立分支升级。
  • 跑完整测试、类型检查和构建。
  • 检查目录别名、服务端 API、模块、插件。
  • 再处理业务代码中的行为差异。
Bash
pnpm add nuxt@^4.0.0
Bash
npm install nuxt@^4.0.0

先查目录结构:app/ 不是小改名,而是上下文变化

Nuxt 4 最明显的变化是新的目录组织方式。应用层代码默认放到 app/ 目录中,例如:

官方目录结构文档说明,app/ 是 Nuxt 应用的主目录,包含组件、页面、布局、插件、组合式函数、工具函数、app.vueerror.vue 等应用层文件;server/ 用于服务端代码;shared/ 用于同时被 Vue 应用和 Nitro 服务端使用的共享代码。

升级前你要重点查这些位置:

Nuxt 4 的升级指南提到,新默认 srcDirapp/serverDir 默认指向 <rootDir>/server,而 layers/modules/public/ 默认相对 <rootDir> 解析;同时新增 shared/,并支持 shared/utils/shared/types/ 自动导入。

这意味着,目录迁移不是简单把文件拖进 app/。你还要检查所有路径引用。例如旧项目中常见写法:

迁移后,如果 utils/date.ts 被放进 app/utils/date.ts,问题不大;但如果它是前后端都要用的工具函数,就更适合放到:

这样可以避免服务端和客户端边界混乱。

  • pages/components/layouts/ 是否还在根目录。
  • 代码里是否大量使用 ~/@/ 路径别名。
  • server/ 是否被错误放进了旧的 srcDir
  • public/modules/layers/ 是否依赖旧路径。
  • 共享类型和工具函数是否应该移到 shared/
Text
my-nuxt-app/
├─ app/
│  ├─ assets/
│  ├─ components/
│  ├─ composables/
│  ├─ layouts/
│  ├─ middleware/
│  ├─ pages/
│  ├─ plugins/
│  ├─ utils/
│  ├─ app.vue
│  ├─ app.config.ts
│  └─ error.vue
├─ server/
├─ shared/
├─ public/
├─ content/
└─ nuxt.config.ts
TypeScript
import { formatDate } from '~/utils/date'
Text
shared/utils/date.ts

旧结构还能不能继续用?可以,但要知道代价

Nuxt 4 不是强制所有项目立刻迁移到 app/ 目录。官方说明,Nuxt 会检测旧结构并保持向后兼容;如果你暂时不想迁移,也可以通过配置把 srcDir 改回根目录。

示例:

这对大型项目很有用,尤其是你有复杂 CI、多人协作、模块封装或大量路径别名时。

但长期来看,建议逐步迁移到新结构。原因很简单:Nuxt 4 的 app/server/shared/ 分层更清楚,IDE 更容易理解当前代码运行在哪个上下文,团队成员也更容易判断“这段代码能不能访问浏览器 API”或“这段代码能不能跑在服务端”。

TypeScript
export default defineNuxtConfig({
  srcDir: '.',
  dir: {
    app: 'app'
  }
})

再查 useFetch:同一个 key 会共享数据引用

Nuxt 4 的数据获取层变化很关键,尤其是 useFetchuseAsyncData。官方说明,Nuxt 4 重组了数据获取系统:相同 key 的 useFetchuseAsyncData 会共享同一组 dataerrorstatus refs。

这听起来是优化,但也可能暴露问题。

例如你在两个组件里这样写:

另一个地方:

如果 key 相同,但 picktransformdeepdefault 等选项不同,就可能出现数据形态不一致的问题。升级前最好全局搜索:

然后检查显式 key 是否被重复使用。我的建议是:同一个 key 只表达同一种数据形态。如果返回结构不同,就换 key。

TypeScript
const { data } = await useFetch('/api/user', {
  key: 'user',
  pick: ['name']
})
TypeScript
const { data } = await useFetch('/api/user', {
  key: 'user',
  transform: user => ({
    label: user.name
  })
})
Text
useFetch(
useAsyncData(
key:

查 data 和 error:不要再只判断 null

Nuxt 4 中,useAsyncDatauseFetch 返回的 dataerror 默认值现在会是 undefined,而不是旧行为里更常见的 null。官方建议,如果你以前检查 data.value === nullerror.value === null,应改为检查 undefined

旧写法:

建议改成:

模板里也一样:

这类问题不一定会让构建失败,却会导致空状态、错误状态、加载状态显示异常。所以升级前要查所有 null 判断,尤其是列表页、详情页、搜索页和后台管理页面。

TypeScript
if (data.value === null) {
  // no data
}
TypeScript
if (data.value === undefined) {
  // no data
}
Vue
<template>
  <div v-if="data === undefined">
    暂无数据
  </div>
  <div v-else>
    {{ data }}
  </div>
</template>

查 pending:它不再等于“还没请求过”

Nuxt 4 中,pending 的含义更严格。官方说明,pending 现在是计算属性,只有当 status 也是 pending 时才为 true;当你设置 immediate: false 时,在第一次请求发出前,pending 会是 false

这会影响很多手动触发请求的场景:

如果旧代码用:

升级后可能逻辑不准。更稳的写法是根据 status 判断:

升级前请重点检查所有 pendingstatusimmediate: falseexecute()refresh() 的组合。

TypeScript
const { data, pending, execute, status } = await useFetch('/api/report', {
  immediate: false
})
Vue
<div v-if="!pending">
  {{ data }}
</div>
Vue
<template>
  <button @click="execute">加载报表</button>

  <p v-if="status === 'idle'">请点击加载</p>
  <p v-else-if="status === 'pending'">加载中...</p>
  <pre v-else-if="status === 'success'">{{ data }}</pre>
  <p v-else>加载失败</p>
</template>

查深层响应式:data 现在默认是 shallowRef

Nuxt 4 中,useFetchuseAsyncDatauseLazyFetchuseLazyAsyncData 返回的 data 默认变为 shallowRef。官方解释是,当新数据整体替换时仍然响应式,但如果你直接修改数据对象内部属性,不会触发深层响应式更新;这样做能改善深层对象和数组的性能。

这类旧代码要特别小心:

更好的做法是整体替换:

如果你确实依赖深层响应式,可以单次开启:

但别为了省事全局开启。Nuxt 4 默认浅响应式是为了性能,尤其对大型列表、嵌套 JSON、CMS 内容和后台表格更友好。

TypeScript
const { data: user } = await useFetch('/api/user')

user.value.profile.name = 'New Name'
TypeScript
user.value = {
  ...user.value,
  profile: {
    ...user.value.profile,
    name: 'New Name'
  }
}
TypeScript
const { data } = await useFetch('/api/user', {
  deep: true
})

查 refresh({ dedupe }):布尔值该改成字符串

Nuxt 4 移除了 refreshdedupe: boolean 的旧写法。官方迁移示例显示,应把 true 改为 'cancel',把 false 改为 'defer'

旧写法:

新写法:

这类问题 TypeScript 通常能帮你抓出来,但前提是你真的跑了类型检查。

TypeScript
await refresh({ dedupe: true })
await refresh({ dedupe: false })
TypeScript
await refresh({ dedupe: 'cancel' })
await refresh({ dedupe: 'defer' })

查 TypeScript:Nuxt 4 会让隐藏问题浮出水面

Nuxt 4 的 TypeScript 体验更强,但它也会让旧代码中“之前没报错”的问题变得明显。

官方升级指南提到,compilerOptions.noUncheckedIndexedAccess 现在默认是 true,这意味着通过索引访问数组或对象时,TypeScript 会更谨慎地认为结果可能是 undefined

例如:

升级后可能需要改成:

或者:

这不是 Nuxt 在“找麻烦”,而是在帮你提前发现潜在运行时错误。尤其是接口数据、CMS 数据、搜索结果、分页列表,都很容易出现数组为空的问题。

TypeScript
const first = users[0]
console.log(first.name)
TypeScript
const first = users[0]

if (first) {
  console.log(first.name)
}
TypeScript
const firstName = users[0]?.name

查 TypeScript 配置拆分:客户端、服务端、共享代码要分清

Nuxt 4 会生成更细的 TypeScript 配置,包括 app、server、node、shared 等不同上下文的配置文件。官方说明,这样能让应用代码、服务端代码、构建时代码和共享代码获得更准确的类型检查、自动补全和错误提示。

你要重点检查这几类问题:

shared/ 的核心价值是“前后端都能安全使用”。所以这里最好只放:

如果某个函数需要 window,放 app/utils。如果某个函数需要 fs、数据库、私钥或服务端环境变量,放 server/utils

  • 类型定义。
  • 纯函数。
  • 常量。
  • 不依赖浏览器或 Node 专属 API 的工具函数。
TypeScript
// server/ 中误用 window
window.localStorage.getItem('token')
TypeScript
// app/ 中误用仅服务端可用的逻辑
import { readFileSync } from 'node:fs'
TypeScript
// shared/ 中混入浏览器或 Node 专属 API
export const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches

查模块兼容性:不要只看项目能启动

模块兼容性是 Nuxt 4 升级前最容易被低估的一项。官方 Nuxt 4 发布说明提到,少数模块可能需要进一步更新才能完整兼容 Nuxt 4,部分旧工具和废弃特性也已被清理。

升级前请列出项目中的模块:

然后逐个检查:

官方 Nuxt Modules 页面可以作为检查入口,用来确认模块生态状态和维护情况。

本地模块也别忘了查。很多团队会在 modules/ 里写自己的 Nuxt 模块,用来注册组件、插件、API、运行时配置或自动导入。Nuxt 模块会在开发启动或生产构建时运行,适合封装可复用能力,但升级后也要确认 hooks、路径、类型声明和模板生成是否还正常。

  • 是否有支持 Nuxt 4 的版本。
  • 是否有迁移指南。
  • 是否依赖旧目录结构。
  • 是否修改了 Nitro、Vite、路由、自动导入或 TypeScript 配置。
  • 是否在构建时和运行时表现一致。
TypeScript
export default defineNuxtConfig({
  modules: [
    '@nuxtjs/i18n',
    '@nuxt/image',
    '@pinia/nuxt',
    '@nuxt/content',
    // ...
  ]
})

建议的 Nuxt 4 升级前检查清单

目录结构`app/`、`server/`、`shared/`、`public/` 是否放对
`useFetch` key相同 key 是否用了不同 `transform`、`pick`、`default`
空值判断是否还在判断 `null`
`pending`是否依赖旧的 pending 行为
深层响应式是否直接修改 `data.value.xxx`
TypeScript是否新增索引访问错误
`shared/`是否混入浏览器或 Node 专属 API
模块第三方模块和本地模块是否兼容

一个更稳的升级流程

推荐按下面节奏做:

如果你想先自动处理一部分迁移,Nuxt 官方升级指南也提供了 codemod 迁移方式,可以运行 Nuxt 4 migration recipe 来批量执行多个迁移步骤。

但要记住:codemod 只能处理可机械替换的部分,不能替你判断业务语义。像 useFetch key 是否冲突、共享函数应该放哪里、模块是否真的兼容生产构建,这些仍然需要人工 review。

Bash
git checkout -b upgrade/nuxt-4
pnpm add nuxt@^4.0.0
pnpm install
pnpm nuxi prepare
pnpm typecheck
pnpm build
pnpm test

常见问题 FAQ

1. Nuxt 4 一定要使用 app/ 目录吗?

不一定。Nuxt 4 对旧结构有向后兼容能力,也可以通过配置继续使用旧结构。但新项目或中长期维护项目更建议迁移到 app/,因为它能让应用、服务端和共享代码边界更清晰。

2. useFetch 升级后最容易出什么问题?

最常见的是相同 key 复用导致数据形态冲突,其次是 dataerror 默认值从旧习惯中的 null 判断变成更适合判断 undefined,以及 pending、浅响应式行为变化。

3. 为什么我的数据改了,但页面没更新?

因为 Nuxt 4 中 useFetch 返回的 data 默认是 shallowRef。如果你直接修改深层属性,可能不会触发更新。建议整体替换对象,或者在确实需要时设置 { deep: true }

4. TypeScript 报更多错是坏事吗?

不一定。Nuxt 4 的 TypeScript 配置更严格、更分上下文,很多新增错误其实是在提醒你处理潜在的 undefined、客户端服务端 API 混用、共享代码边界不清等问题。

5. 第三方模块怎么确认是否支持 Nuxt 4?

先查模块官方文档、npm 版本、GitHub issue/release,再看 Nuxt Modules 官方目录。升级时最好逐个启用模块并测试,而不是一次性全部打开后再排查。

6. 小项目可以直接升级 Nuxt 4 吗?

可以,但仍建议至少跑 typecheckbuild 和主要页面回归测试。小项目风险低,不代表没有 useFetch 状态判断、路径别名或模块兼容问题。

结论:Nuxt 4 升级前,先查边界,再改代码

Nuxt 4 升级前先查什么:目录结构、useFetch、TypeScript 和模块兼容性?答案很明确:先查代码运行边界,再查数据行为,再查类型系统,最后查生态模块。

app/ 目录让项目更清楚,shared/ 让前后端共享更规范,useFetch 的新行为让数据层更一致,TypeScript 拆分让错误更早暴露。只要你在升级前把这些点查清楚,Nuxt 4 迁移通常不会是灾难,反而会是一次清理技术债、提升项目可维护性的好机会。

参考来源

Announcing Nuxt 4.0Nuxt BlogUpgrade GuideNuxt DocsNuxt Directory StructureNuxt DocsNuxt ModulesNuxtModule Author GuideNuxt Docs

相关文章

Vite 7 升级卡住:为什么要求 Node 20.19+ / 22.12+工程实践 / 约 15 分钟7 个关键洞察:AI Coding 工具真正改变的不是写代码,而是验证代码智能编程 / 约 18 分钟Claude Code 项目上下文怎么检查开发环境 / 约 11 分钟为什么 AI Agent 写代码必须有 AGENTS.md / CLAUDE.md:提高质量的 9 个关键理由智能编程 / 约 16 分钟