
WebStorm Windows 版 React / Next.js 实战替换指南
摘要
本指南是《WebStorm Windows 版安装配置与实战效果全景指南》第 14 章的 React / Next.js 替换版本 。目标是在不破坏原有 Vue 3 实战结构的前提下,把第 14 章从 14.7.2 src/stores/todoStore.ts 之后,完整替换为 React 生态与 Next.js App Router 生态两套可直接落地的实战代码。
全文按以下结构展开:
- 一、替换总览:说明哪些章节保留、哪些章节替换、哪些章节新增。
- 二、React + Vite + TypeScript 替换方案:从项目结构、路由、状态管理、API 封装到 WebStorm 调试配置。
- 三、Next.js 15 App Router 替换方案:从目录规范、服务端组件、客户端组件、API Route、数据获取到 WebStorm 调试配置。
- 四、React 与 Next.js 版本差异对比:帮助判断何时用 SPA,何时用 SSR。
- 五、WebStorm 常见陷阱与问题排查:重点覆盖 Vite、Next.js、Turbopack、Source Map、Attach 调试、端口冲突等问题。
- 六、完整替换代码清单:提供可直接复制到项目中的代码。
- 七、总结与后续扩展建议。
本指南默认你已经按照前文完成了 WebStorm、Node.js、pnpm、Git、TypeScript、浏览器调试环境等基础配置。
一、替换总览
1.1 保留章节
以下章节不需要替换,可直接沿用原 Vue 3 实战版本:
| 章节 | 说明 |
|---|---|
| 14.1 示例项目结构 | 保留结构说明,但需替换为 React / Next.js 目录 |
| 14.2 package.json 配置 | 保留思路,依赖与脚本需替换 |
| 14.3 Vite 配置文件 | React 版本保留,Next.js 版本改为 next.config.ts |
| 14.4 TypeScript 配置 | 保留思路,但需适配 React / Next.js 类型 |
| 14.9 调试运行配置 | 保留思路,但需分别替换为 React / Next.js 调试方式 |
| 14.10 构建与部署预览 | 保留思路,但构建产物和部署方式不同 |
| 十五至十八章 | 总结、资源、快捷键、排查表可保留,但需补充 React / Next.js 专用内容 |
1.2 需要替换的章节
| 原章节 | 替换内容 |
|---|---|
| 14.5 入口文件与路由配置 | 替换为 React Router 或 Next.js App Router |
| 14.6 组件示例 | 替换为 React 函数组件 / Next.js Server/Client 组件 |
| 14.7 状态管理示例 | 替换为 Zustand |
| 14.8 API 请求封装 | 保留 axios 思路,但改为 React 项目使用方式 |
| 14.9 调试运行配置 | React 使用 Vite + JavaScript Debug;Next.js 使用 npm + Node Attach |
| 14.10 构建与部署预览 | React 构建为静态 SPA;Next.js 构建为 SSR/SSG 应用 |
1.3 新增章节
为了覆盖 Next.js App Router 的特殊性,新增以下章节:
- 14.11 Next.js App Router 服务端与客户端边界
- 14.12 Next.js 数据获取与 Server Actions 示例
- 14.13 React 与 Next.js 版本差异对比
- 14.14 WebStorm 常见陷阱与问题排查
- 14.15 完整替换代码清单
二、React + Vite + TypeScript 替换方案
2.1 项目初始化
在 WebStorm 中新建 React 项目时,建议直接使用内置 Vite + React + TypeScript 模板。
如果使用命令行,可以执行:
bash
pnpm create vite react-todo-app --template react-ts
cd react-todo-app
pnpm install
安装状态管理、路由和请求库:
bash
pnpm add zustand react-router-dom axios
pnpm add -D @types/node
Zustand 在 React 项目中适合替代 Pinia,它的优势是 API 简单、支持 TypeScript、支持选择性订阅,能有效减少不必要的组件重渲染。
2.2 React 版本项目结构
替换后的 React 项目结构如下:
react-todo-app/
├── public/
├── src/
│ ├── api/
│ │ ├── request.ts
│ │ └── todoApi.ts
│ ├── components/
│ │ ├── TodoInput.tsx
│ │ ├── TodoList.tsx
│ │ └── TodoItem.tsx
│ ├── pages/
│ │ ├── HomePage.tsx
│ │ └── AboutPage.tsx
│ ├── stores/
│ │ └── todoStore.ts
│ ├── types/
│ │ └── todo.ts
│ ├── App.tsx
│ ├── main.tsx
│ └── index.css
├── .env.development
├── index.html
├── package.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
与 Vue 版本相比,主要差异是:
- 入口文件从
main.ts变为main.tsx。 - 路由从 Vue Router 变为 React Router。
- 状态管理从 Pinia 变为 Zustand。
- 组件从
.vue单文件组件变为.tsx组件。
2.3 src/types/todo.ts
ts
/**
* 待办项类型
*/
export interface Todo {
/** 唯一标识 */
id: string
/** 待办内容 */
text: string
/** 是否完成 */
completed: boolean
/** 创建时间戳 */
createdAt: number
}
2.4 src/stores/todoStore.ts
这里使用 Zustand 替代原 Vue 版本的 Pinia store。
ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { Todo } from '../types/todo'
/**
* Zustand Store 类型定义
*
* 建议把 state 和 actions 放在同一个类型中,
* 这样 TypeScript 推导更完整,组件中使用时也有完整提示。
*/
interface TodoStore {
/** 待办列表 */
todos: Todo[]
/** 新增待办 */
addTodo: (text: string) => void
/** 切换完成状态 */
toggleTodo: (id: string) => void
/** 删除待办 */
removeTodo: (id: string) => void
/** 清空已完成待办 */
clearCompleted: () => void
}
/**
* localStorage 存储键名
*/
const STORAGE_KEY = 'react-todo-store'
/**
* 创建待办 Store
*
* persist 中间件用于把 todos 持久化到 localStorage,
* 刷新页面后数据不会丢失。
*/
export const useTodoStore = create<TodoStore>()(
persist(
(set) => ({
todos: [],
addTodo: (text: string) => {
const trimmed = text.trim()
if (!trimmed) return
set((state) => ({
todos: [
...state.todos,
{
id: crypto.randomUUID(),
text: trimmed,
completed: false,
createdAt: Date.now()
}
]
}))
},
toggleTodo: (id: string) => {
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
}))
},
removeTodo: (id: string) => {
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id)
}))
},
clearCompleted: () => {
set((state) => ({
todos: state.todos.filter((todo) => !todo.completed)
}))
}
}),
{
name: STORAGE_KEY,
/**
* 只持久化 todos,不持久化 actions。
*
* actions 是函数,无法被 JSON.stringify 序列化。
* 如果不配置 partialize,刷新后可能出现状态恢复异常。
*/
partialize: (state) => ({
todos: state.todos
})
}
)
)
/**
* 自定义 Hook:获取待办列表
*
* 使用 selector 精确订阅,避免整个 store 变化时组件重复渲染。
*/
export function useTodos() {
return useTodoStore((state) => state.todos)
}
/**
* 自定义 Hook:获取未完成待办数量
*/
export function useActiveCount() {
return useTodoStore((state) =>
state.todos.filter((todo) => !todo.completed).length
)
}
/**
* 自定义 Hook:获取待办 actions
*
* 将 actions 单独暴露出来,组件中不需要订阅 todos 数据,
* 只订阅 actions 时,todos 变化不会触发该组件重渲染。
*/
export function useTodoActions() {
return useTodoStore((state) => ({
addTodo: state.addTodo,
toggleTodo: state.toggleTodo,
removeTodo: state.removeTodo,
clearCompleted: state.clearCompleted
}))
}
2.5 src/api/request.ts
ts
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig
} from 'axios'
/**
* 创建 axios 实例
*/
const request: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
/**
* 请求拦截器
*/
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
/**
* 响应拦截器
*/
request.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
if (res.code === 200) {
return res.data
}
console.error('接口返回错误:', res.message)
return Promise.reject(new Error(res.message || '请求失败'))
},
(error) => {
if (error.response) {
const status = error.response.status
if (status === 401) {
console.warn('未授权,请重新登录')
} else if (status === 403) {
console.warn('没有权限访问')
} else if (status === 404) {
console.warn('请求资源不存在')
} else if (status >= 500) {
console.error('服务器错误')
}
} else {
console.error('网络异常,请检查网络连接')
}
return Promise.reject(error)
}
)
export function get<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<T> {
return request.get(url, config)
}
export function post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
return request.post(url, data, config)
}
export function put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
return request.put(url, data, config)
}
export function del<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<T> {
return request.delete(url, config)
}
export default request
2.6 src/api/todoApi.ts
ts
import { get, post, put, del } from './request'
import type { Todo } from '../types/todo'
/**
* 获取待办列表
*/
export function fetchTodoList() {
return get<Todo[]>('/todos')
}
/**
* 新增待办
*/
export function addTodoApi(text: string) {
return post<Todo>('/todos', { text })
}
/**
* 更新待办完成状态
*/
export function updateTodoStatus(id: string, completed: boolean) {
return put(`/todos/${id}`, { completed })
}
/**
* 删除待办
*/
export function deleteTodoApi(id: string) {
return del(`/todos/${id}`)
}
2.7 src/main.tsx
tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
/**
* React 18 使用 createRoot 挂载应用。
*
* BrowserRouter 必须包裹在应用最外层,
* 这样所有子组件才能使用 useNavigate、useLocation 等路由能力。
*/
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)
2.8 src/App.tsx
tsx
import { Routes, Route, Link } from 'react-router-dom'
import HomePage from './pages/HomePage'
import AboutPage from './pages/AboutPage'
/**
* 应用根组件
*
* Routes 负责匹配路由,
* Route 负责声明路径与组件对应关系。
*/
export default function App() {
return (
<div className="app">
<header className="app-header">
<h1>React Todo App</h1>
<nav>
<Link to="/">首页</Link>
<Link to="/about">关于</Link>
</nav>
</header>
<main className="app-main">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
</main>
</div>
)
}
2.9 src/pages/HomePage.tsx
tsx
import TodoInput from '../components/TodoInput'
import TodoList from '../components/TodoList'
/**
* 首页
*
* 这里只负责组合待办输入框和待办列表,
* 不直接操作 Zustand store,保持页面组件尽量"瘦"。
*/
export default function HomePage() {
return (
<section>
<h2>待办事项</h2>
<TodoInput />
<TodoList />
</section>
)
}
2.10 src/pages/AboutPage.tsx
tsx
/**
* 关于页面
*
* 用于演示 React Router 多页面切换。
*/
export default function AboutPage() {
return (
<section>
<h2>关于本项目</h2>
<p>这是一个基于 React + Vite + TypeScript + Zustand 的待办示例。</p>
</section>
)
}
2.11 src/components/TodoInput.tsx
tsx
import { useState } from 'react'
import { useTodoActions } from '../stores/todoStore'
/**
* 待办输入框组件
*
* 使用本地 state 管理输入内容,
* 提交时调用 Zustand action 新增待办。
*/
export default function TodoInput() {
const [text, setText] = useState('')
const { addTodo } = useTodoActions()
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!text.trim()) return
addTodo(text)
setText('')
}
return (
<form onSubmit={handleSubmit} className="todo-input">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="输入待办内容"
/>
<button type="submit">添加</button>
</form>
)
}
2.12 src/components/TodoList.tsx
tsx
import { useTodos, useActiveCount, useTodoActions } from '../stores/todoStore'
import TodoItem from './TodoItem'
/**
* 待办列表组件
*
* useTodos 只订阅 todos 数据,
* useActiveCount 只订阅未完成数量,
* useTodoActions 只订阅 actions。
*
* 这样每个 selector 变化时,只会触发对应组件更新。
*/
export default function TodoList() {
const todos = useTodos()
const activeCount = useActiveCount()
const { clearCompleted } = useTodoActions()
if (todos.length === 0) {
return <p className="empty-tip">暂无待办</p>
}
return (
<div className="todo-list">
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
<div className="todo-footer">
<span>未完成:{activeCount}</span>
<button onClick={clearCompleted}>清空已完成</button>
</div>
</div>
)
}
2.13 src/components/TodoItem.tsx
tsx
import type { Todo } from '../types/todo'
import { useTodoActions } from '../stores/todoStore'
interface TodoItemProps {
todo: Todo
}
/**
* 单个待办项组件
*
* 只接收 todo 作为 props,
* actions 从 store 中获取,避免层层传递回调。
*/
export default function TodoItem({ todo }: TodoItemProps) {
const { toggleTodo, removeTodo } = useTodoActions()
return (
<li className="todo-item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span className={todo.completed ? 'completed' : ''}>{todo.text}</span>
<button onClick={() => removeTodo(todo.id)}>删除</button>
</li>
)
}
2.14 .env.development
env
# 开发环境 API 基础地址
VITE_API_BASE_URL=http://localhost:3000/api
注意:Vite 项目中只有以 VITE_ 开头的环境变量才会暴露到客户端代码中。
2.15 WebStorm 调试配置:React + Vite
React + Vite 项目的调试方式与原 Vue 3 版本基本一致。
2.15.1 配置 npm 启动脚本
- 打开 Run → Edit Configurations...。
- 点击 + ,选择 npm。
- 配置如下:
| 字段 | 值 |
|---|---|
| Name | dev: vite react |
| package.json | 项目根目录 package.json |
| Command | run |
| Scripts | dev |
| Run npm script in new terminal | 勾选 |
启动后访问地址通常为:
http://localhost:5173/
不要访问 WebStorm 内置的 63342 端口。
2.15.2 配置 JavaScript Debug
- 保持 Vite 开发服务器运行。
- 打开 Run → Edit Configurations...。
- 点击 + ,选择 JavaScript Debug。
- 配置如下:
| 字段 | 值 |
|---|---|
| Name | Debug React @5173 |
| URL | http://localhost:5173/ |
| Browser | Chrome |
- 在
src/components/TodoInput.tsx的handleSubmit中设置断点。 - 点击 Debug 启动调试。
- 刷新浏览器,输入待办内容并提交,断点即可命中。
如果断点不命中,需要检查:
- URL 是否与 Vite 实际地址一致。
Settings → Languages & Frameworks → JavaScript → Debugging是否启用了 Source Map。- Chrome 是否安装了 JetBrains IDE Support 插件。
- 是否误用了 WebStorm 内置静态服务器。
三、Next.js 15 App Router 替换方案
3.1 项目初始化
Next.js 项目建议使用官方脚手架创建,WebStorm 也内置了 Next.js 项目模板。
bash
pnpm create next-app next-todo-app --typescript --app --tailwind --eslint
cd next-todo-app
pnpm add zustand
创建时选择:
- TypeScript:Yes
- App Router:Yes
- Tailwind CSS:Yes(可选,本示例使用原生 CSS 降低依赖)
- ESLint:Yes
- Turbopack :根据 WebStorm 版本决定。若 WebStorm 版本较旧或调试时卡在 "Starting the development server...",可先不使用
--turbopack,改用传统 Webpack 模式。
创建完成后,在 WebStorm 中打开项目:
- 右键项目根目录 → Add Framework Support... → 勾选 Next.js。
- 确认
Settings → Plugins中 Next.js Support 插件已启用。 - 等待 WebStorm 索引完成,
app/目录会被识别为路由根目录。
3.2 Next.js 版本项目结构
next-todo-app/
├── app/
│ ├── about/
│ │ └── page.tsx
│ ├── api/
│ │ └── todos/
│ │ └── route.ts
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── TodoInput.tsx
│ ├── TodoList.tsx
│ └── TodoItem.tsx
├── lib/
│ ├── store.ts
│ └── db.ts
├── actions.ts
├── types.ts
├── next.config.ts
├── package.json
├── tsconfig.json
└── README.md
与 React + Vite 版本的关键差异:
| 对比项 | React + Vite | Next.js App Router |
|---|---|---|
| 路由 | React Router | 文件系统路由(app/ 目录) |
| 入口 | src/main.tsx |
app/layout.tsx + app/page.tsx |
| 组件 | 全部客户端组件 | 默认服务端组件,需 "use client" 才可使用 Hooks |
| API | 独立后端或 Mock | Route Handlers(route.ts)或 Server Actions |
| 状态管理 | Zustand 直接使用 | 需处理 SSR 水合问题 |
3.3 types.ts
ts
/**
* 待办项类型定义
*
* 该文件放在项目根目录,供服务端和客户端代码共同引用。
* Next.js App Router 中,Server Component 和 Client Component
* 都可以 import 纯类型文件,不会造成客户端打包体积增加。
*/
export interface Todo {
/** 唯一标识 */
id: string
/** 待办内容 */
text: string
/** 是否完成 */
completed: boolean
/** 创建时间戳 */
createdAt: number
}
3.4 lib/db.ts
ts
import type { Todo } from '../types'
/**
* 内存数据库模拟
*
* 注意:在生产环境中,这里应替换为真实数据库(如 Prisma + PostgreSQL)。
* 使用内存数组仅用于演示 Server Actions 和 Route Handlers 的工作方式。
*
* 由于 Next.js 开发服务器可能存在模块热重载,
* 实际开发中每次重启服务器数据会重置。
*/
let todos: Todo[] = [
{
id: '1',
text: '学习 Next.js App Router',
completed: false,
createdAt: Date.now()
},
{
id: '2',
text: '理解 Server Components',
completed: true,
createdAt: Date.now() - 100000
}
]
/**
* 获取所有待办
*/
export async function getTodos(): Promise<Todo[]> {
// 模拟异步数据库查询
await new Promise((resolve) => setTimeout(resolve, 100))
return [...todos]
}
/**
* 新增待办
*/
export async function addTodo(text: string): Promise<Todo> {
await new Promise((resolve) => setTimeout(resolve, 100))
const newTodo: Todo = {
id: crypto.randomUUID(),
text: text.trim(),
completed: false,
createdAt: Date.now()
}
todos.push(newTodo)
return { ...newTodo }
}
/**
* 切换待办完成状态
*/
export async function toggleTodo(id: string): Promise<Todo | null> {
await new Promise((resolve) => setTimeout(resolve, 100))
const index = todos.findIndex((t) => t.id === id)
if (index === -1) return null
todos[index] = { ...todos[index], completed: !todos[index].completed }
return { ...todos[index] }
}
/**
* 删除待办
*/
export async function deleteTodo(id: string): Promise<boolean> {
await new Promise((resolve) => setTimeout(resolve, 100))
const index = todos.findIndex((t) => t.id === id)
if (index === -1) return false
todos.splice(index, 1)
return true
}
/**
* 清空已完成待办
*/
export async function clearCompletedTodos(): Promise<number> {
await new Promise((resolve) => setTimeout(resolve, 100))
const beforeCount = todos.length
todos = todos.filter((t) => !t.completed)
return beforeCount - todos.length
}
3.5 lib/store.ts(Zustand,带 SSR 水合处理)
ts
'use client'
/**
* Zustand Store for Next.js App Router
*
* 关键问题:Next.js App Router 默认使用 Server Components,
* 而 Zustand 是纯客户端状态管理库。
*
* 如果在 Server Component 中直接调用 useStore(),
* 会导致服务端和客户端状态不一致,产生 Hydration Error。
*
* 解决方案:
* 1. 将 store 定义在 'use client' 文件中
* 2. 使用自定义 hook 延迟读取 store 值,等待客户端水合完成
* 3. 配合 persist 中间件时,确保只在客户端访问 localStorage
*/
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { useState, useEffect } from 'react'
import type { Todo } from '../types'
/**
* Store 类型定义
*/
interface TodoStore {
/** 待办列表 */
todos: Todo[]
/** 新增待办 */
addTodo: (text: string) => void
/** 切换完成状态 */
toggleTodo: (id: string) => void
/** 删除待办 */
removeTodo: (id: string) => void
/** 清空已完成 */
clearCompleted: () => void
}
/**
* 创建 Zustand Store
*
* persist 中间件将数据保存到 localStorage。
* 在 Next.js 中,localStorage 仅存在于浏览器环境,
* 因此该 store 只能在 Client Component 中使用。
*/
export const useTodoStore = create<TodoStore>()(
persist(
(set) => ({
todos: [],
addTodo: (text: string) => {
const trimmed = text.trim()
if (!trimmed) return
set((state) => ({
todos: [
...state.todos,
{
id: crypto.randomUUID(),
text: trimmed,
completed: false,
createdAt: Date.now()
}
]
}))
},
toggleTodo: (id: string) => {
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
}))
},
removeTodo: (id: string) => {
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id)
}))
},
clearCompleted: () => {
set((state) => ({
todos: state.todos.filter((todo) => !todo.completed)
}))
}
}),
{
name: 'next-todo-store',
partialize: (state) => ({ todos: state.todos })
}
)
)
/**
* 自定义 Hook:安全读取 Zustand Store
*
* 这是解决 Next.js Hydration Error 的关键。
*
* 原理:
* - 服务端渲染时,store 处于初始状态(空数组)
* - 客户端水合完成后,persist 中间件从 localStorage 恢复数据
* - 使用 useEffect 延迟一帧读取,确保拿到的是水合后的数据
*
* 用法:
* const todos = useStore(useTodoStore, (state) => state.todos)
*/
export function useStore<T, F>(
store: (callback: (state: T) => unknown) => unknown,
callback: (state: T) => F
): F | undefined {
const result = store(callback) as F
const [data, setData] = useState<F>()
useEffect(() => {
setData(result)
}, [result])
return data
}
/**
* 便捷 Hook:获取待办列表(已处理水合)
*/
export function useTodos() {
return useStore(useTodoStore, (state) => state.todos)
}
/**
* 便捷 Hook:获取未完成数量(已处理水合)
*/
export function useActiveCount() {
return useStore(useTodoStore, (state) =>
state.todos.filter((todo) => !todo.completed).length
)
}
/**
* 便捷 Hook:获取 actions
*
* actions 是函数引用,不会因数据变化而改变,
* 因此可以直接返回,无需水合处理。
*/
export function useTodoActions() {
return useTodoStore((state) => ({
addTodo: state.addTodo,
toggleTodo: state.toggleTodo,
removeTodo: state.removeTodo,
clearCompleted: state.clearCompleted
}))
}
3.6 actions.ts(Server Actions)
ts
/**
* Server Actions
*
* 'use server' 指令标记该文件中的所有导出函数都在服务端执行。
*
* Server Actions 的优势:
* 1. 无需手写 API Route,直接在组件中调用
* 2. 表单数据自动序列化,无需 JSON.stringify
* 3. 执行后可调用 revalidatePath 刷新缓存,UI 自动更新
* 4. 敏感逻辑(数据库操作、密钥)不会泄露到客户端
*/
'use server'
import { revalidatePath } from 'next/cache'
import { addTodo, toggleTodo, deleteTodo, clearCompletedTodos } from './lib/db'
/**
* 新增待办 Server Action
*
* @param formData - 表单数据,Next.js 自动注入
* @returns 操作结果
*/
export async function createTodo(formData: FormData) {
const text = String(formData.get('text') ?? '').trim()
if (!text) {
return { error: '待办内容不能为空' }
}
try {
await addTodo(text)
// 刷新首页缓存,使新数据立即显示
revalidatePath('/')
return { error: null }
} catch (err) {
console.error('新增待办失败:', err)
return { error: '新增失败,请重试' }
}
}
/**
* 切换待办完成状态 Server Action
*/
export async function flipTodo(formData: FormData) {
const id = String(formData.get('id') ?? '')
if (!id) {
return { error: 'ID 无效' }
}
try {
await toggleTodo(id)
revalidatePath('/')
return { error: null }
} catch (err) {
console.error('切换状态失败:', err)
return { error: '操作失败,请重试' }
}
}
/**
* 删除待办 Server Action
*/
export async function removeTodo(formData: FormData) {
const id = String(formData.get('id') ?? '')
if (!id) {
return { error: 'ID 无效' }
}
try {
await deleteTodo(id)
revalidatePath('/')
return { error: null }
} catch (err) {
console.error('删除失败:', err)
return { error: '删除失败,请重试' }
}
}
/**
* 清空已完成待办 Server Action
*/
export async function clearCompleted() {
try {
await clearCompletedTodos()
revalidatePath('/')
return { error: null }
} catch (err) {
console.error('清空失败:', err)
return { error: '清空失败,请重试' }
}
}
3.7 app/globals.css
css
/* 全局样式 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.app-container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
/* 导航栏 */
.nav-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #e0e0e0;
margin-bottom: 24px;
}
.nav-bar h1 {
font-size: 24px;
color: #1a1a1a;
}
.nav-links {
display: flex;
gap: 16px;
}
.nav-links a {
text-decoration: none;
color: #666;
font-size: 14px;
}
.nav-links a:hover {
color: #0070f3;
}
.nav-links a.active {
color: #0070f3;
font-weight: 600;
}
/* 待办输入框 */
.todo-input {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.todo-input input {
flex: 1;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
}
.todo-input input:focus {
border-color: #0070f3;
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.1);
}
.todo-input button {
padding: 10px 20px;
background-color: #0070f3;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.todo-input button:hover {
background-color: #0051cc;
}
.todo-input button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
/* 待办列表 */
.todo-list {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.todo-list ul {
list-style: none;
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
}
.todo-item span {
flex: 1;
font-size: 14px;
}
.todo-item span.completed {
text-decoration: line-through;
color: #999;
}
.todo-item button {
padding: 4px 10px;
background-color: #ff4d4f;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.todo-item button:hover {
background-color: #d9363e;
}
/* 列表底部 */
.todo-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
font-size: 13px;
color: #666;
}
.todo-footer button {
padding: 4px 12px;
background: none;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
color: #666;
}
.todo-footer button:hover {
border-color: #ff4d4f;
color: #ff4d4f;
}
/* 空状态 */
.empty-tip {
text-align: center;
padding: 40px 16px;
color: #999;
font-size: 14px;
}
/* 关于页面 */
.about-section {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.about-section h2 {
margin-bottom: 12px;
font-size: 20px;
}
.about-section p {
font-size: 14px;
color: #666;
line-height: 1.8;
}
3.8 app/layout.tsx
tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { headers } from 'next/headers'
import './globals.css'
/**
* 根布局组件
*
* layout.tsx 是 App Router 中的特殊文件,
* 它包裹所有子页面,提供共享的 HTML 结构和 UI。
*
* 注意:layout.tsx 默认是 Server Component,
* 不能使用 useState、useEffect 等 Hooks。
* 如果需要交互,应在子组件中使用 "use client"。
*/
export const metadata: Metadata = {
title: 'Next.js Todo App',
description: '基于 Next.js 15 App Router 的待办事项示例'
}
/**
* RootLayout 接收 children 作为 props,
* children 是当前路由匹配的页面组件。
*/
export default async function RootLayout({
children
}: {
children: React.ReactNode
}) {
// Server Component 中可以使用 next/headers API
const headersList = await headers()
const pathname = headersList.get('x-nextjs-pathname') || '/'
return (
<html lang="zh-CN">
<body>
<div className="app-container">
{/* 共享导航栏 */}
<nav className="nav-bar">
<h1>Next.js Todo</h1>
<div className="nav-links">
<Link
href="/"
className={pathname === '/' ? 'active' : ''}
>
首页
</Link>
<Link
href="/about"
className={pathname === '/about' ? 'active' : ''}
>
关于
</Link>
</div>
</nav>
{/* 页面内容 */}
{children}
</div>
</body>
</html>
)
}
3.8 app/page.tsx
tsx
/**
* Next.js 15 App Router 首页
*
* 这里演示两种写法:
* 1. 纯 Server Component 写法:直接在服务端读取数据,配合 Server Actions 提交表单
* 2. 客户端交互增强写法:使用 useOptimistic 做乐观更新
*
* Next.js 15 中,App Router 的页面默认是 Server Component,
* 可以直接 await 异步函数获取数据。
*/
import { getTodos } from './lib/db'
import { createTodo, flipTodo, removeTodo, clearCompleted } from './actions'
import TodoItem from '../components/TodoItem'
export default async function HomePage() {
// Server Component 中可以直接 await 异步数据
const todos = await getTodos()
const activeCount = todos.filter((t) => !t.completed).length
const completedCount = todos.filter((t) => t.completed).length
return (
<main>
<section className="todo-input-section">
{/*
* Server Action 表单
*
* Next.js 会自动将 form action 指向服务端函数,
* 提交时浏览器不会发生传统页面跳转。
*/}
<form action={createTodo} className="todo-input">
<input
type="text"
name="text"
placeholder="输入新的待办事项..."
required
aria-label="待办内容"
/>
<button type="submit">添加</button>
</form>
</section>
<section className="todo-list">
{todos.length === 0 ? (
<p className="empty-tip">暂无待办,添加一条试试吧。</p>
) : (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
)}
<div className="todo-footer">
<span>
共 {todos.length} 项,未完成 {activeCount} 项,已完成 {completedCount} 项
</span>
{completedCount > 0 && (
<form action={clearCompleted}>
<button type="submit">清空已完成</button>
</form>
)}
</div>
</section>
</main>
)
}
3.9 components/TodoItem.tsx
tsx
'use client'
import type { Todo } from '../types'
import { flipTodo, removeTodo } from '../actions'
/**
* 单个待办项组件
*
* 因为需要绑定 form action,并且可能后续加入交互状态,
* 这里标记为客户端组件。
*/
export default function TodoItem({ todo }: { todo: Todo }) {
return (
<li className="todo-item">
{/*
* 切换完成状态
*
* form 的 action 会接收 FormData,
* 这里通过隐藏 input 传递 id。
*/}
<form action={flipTodo} style={{ display: 'contents' }}>
<input type="hidden" name="id" value={todo.id} />
<input
type="checkbox"
defaultChecked={todo.completed}
aria-label={`标记"${todo.text}"为${todo.completed ? '未完成' : '已完成'}`}
/>
</form>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
{/* 删除待办 */}
<form action={removeTodo} style={{ display: 'contents' }}>
<input type="hidden" name="id" value={todo.id} />
<button type="submit" aria-label={`删除"${todo.text}"`}>
删除
</button>
</form>
</li>
)
}
3.10 app/about/page.tsx
tsx
/**
* 关于页面
*
* 这是一个纯静态 Server Component,
* 不需要客户端交互,因此不需要 "use client"。
*/
export default function AboutPage() {
return (
<section className="about-section">
<h2>关于 Next.js Todo</h2>
<p>
本示例基于 Next.js 15 App Router,演示了服务端组件、客户端组件、
Server Actions、文件系统路由和基础样式组织方式。
</p>
<p>
与 React + Vite 版本相比,Next.js 版本默认在服务端渲染页面,
更适合需要 SEO、首屏性能或全栈能力的项目。
</p>
</section>
)
}
3.11 app/api/todos/route.ts
ts
/**
* API Route Handler
*
* Next.js App Router 中,app/api 目录下的 route.ts
* 会暴露 HTTP 接口。
*
* 与 Server Actions 的区别:
* - Server Actions 更适合表单提交、页面内操作
* - Route Handlers 更适合第三方调用、移动端接口、定时任务
*/
import { NextResponse } from 'next/server'
import { getTodos, addTodo, toggleTodo, deleteTodo, clearCompletedTodos } from '../../lib/db'
/**
* GET /api/todos
*/
export async function GET() {
const todos = await getTodos()
return NextResponse.json({ todos })
}
/**
* POST /api/todos
*
* 请求体示例:
* { "text": "学习 Next.js" }
*/
export async function POST(request: Request) {
try {
const body = await request.json()
const text = String(body?.text ?? '').trim()
if (!text) {
return NextResponse.json(
{ error: '待办内容不能为空' },
{ status: 400 }
)
}
const todo = await addTodo(text)
return NextResponse.json({ todo }, { status: 201 })
} catch (err) {
console.error('新增待办失败:', err)
return NextResponse.json(
{ error: '服务器错误' },
{ status: 500 }
)
}
}
/**
* PATCH /api/todos
*
* 请求体示例:
* { "id": "xxx", "completed": true }
*/
export async function PATCH(request: Request) {
try {
const body = await request.json()
const id = String(body?.id ?? '')
const completed = Boolean(body?.completed)
if (!id) {
return NextResponse.json(
{ error: 'id 不能为空' },
{ status: 400 }
)
}
const todo = await toggleTodo(id)
if (!todo) {
return NextResponse.json(
{ error: '待办不存在' },
{ status: 404 }
)
}
return NextResponse.json({ todo })
} catch (err) {
console.error('更新待办失败:', err)
return NextResponse.json(
{ error: '服务器错误' },
{ status: 500 }
)
}
}
/**
* DELETE /api/todos
*
* 请求体示例:
* { "id": "xxx" }
*/
export async function DELETE(request: Request) {
try {
const body = await request.json()
const id = String(body?.id ?? '')
if (!id) {
return NextResponse.json(
{ error: 'id 不能为空' },
{ status: 400 }
)
}
const ok = await deleteTodo(id)
if (!ok) {
return NextResponse.json(
{ error: '待办不存在' },
{ status: 404 }
)
}
return NextResponse.json({ success: true })
} catch (err) {
console.error('删除待办失败:', err)
return NextResponse.json(
{ error: '服务器错误' },
{ status: 500 }
)
}
}
3.12 next.config.ts
ts
import type { NextConfig } from 'next'
/**
* Next.js 配置文件
*
* WebStorm 中建议保持 next.config.ts 与 package.json 中的脚本一致。
* 如果开发服务器启动异常,可优先检查这里是否开启了实验性功能。
*/
const nextConfig: NextConfig = {
reactStrictMode: true,
// 如果 WebStorm 启动 dev 服务器时卡在 "Starting the development server...",
// 可先关闭 Turbopack,改用传统 Webpack 模式。
// turbopack: false,
// 生产环境若需要浏览器 Source Map,可开启:
// productionBrowserSourceMaps: true,
}
export default nextConfig
3.13 tsconfig.json
json
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
3.14 package.json 关键脚本
json
{
"scripts": {
"dev": "next dev",
"dev:turbo": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}
如果 WebStorm 启动
next dev时长时间卡在 "Starting the development server...",可先删除--turbopack,使用传统 Webpack 模式。
3.15 WebStorm 中运行 Next.js 项目
- 打开项目根目录。
- 右键根目录 → Add Framework Support... → 勾选 Next.js。
- 确认
Settings → Plugins中 Next.js Support 已启用。 - 打开右上角 Run/Debug Configurations。
- 添加 NPM 配置:
- Command:
run - Scripts:
dev - Node interpreter:选择本地 Node.js
- Package manager:若使用 pnpm,需显式设置为 pnpm
- Command:
- 点击 Debug 启动开发服务器。
- 浏览器访问
http://localhost:3000。
如果需要同时调试客户端代码,可再添加一个 JavaScript Debug 配置:
- URL:
http://localhost:3000 - Browser:Chrome
- 确保 Chrome 已安装 JetBrains IDE Support 插件
3.16 WebStorm 中调试 Next.js 服务端代码
Next.js 的服务端组件、Server Actions、Route Handlers 都运行在 Node.js 环境中。调试它们有两种常用方式。
方式一:Node.js 启动配置
在 WebStorm 中新建 Node.js 配置:
- JavaScript file:
node_modules/.bin/next - Application parameters:
dev - Working directory:项目根目录
- Node parameters:
--inspect
启动后,WebStorm 会连接到 Node.js 调试端口,你可以在 lib/db.ts、actions.ts、app/api/todos/route.ts 中打断点。
方式二:Attach to Node.js
在 package.json 中增加调试脚本:
json
{
"scripts": {
"dev:debug": "cross-env NODE_OPTIONS=--inspect=localhost:9400 next dev"
}
}
然后添加 Attach to Node.js/Chrome 配置:
- Host:
localhost - Port:
9400
先运行 pnpm dev:debug,再通过 Attach 配置连接。
四、React 与 Next.js 版本差异对比
| 维度 | React + Vite | Next.js App Router |
|---|---|---|
| 渲染方式 | 客户端 SPA | 默认 SSR / 静态预渲染 |
| 路由 | React Router | 文件系统路由 |
| 入口 | src/main.tsx |
app/layout.tsx + app/page.tsx |
| 数据获取 | useEffect / React Query | Server Component 直接 await |
| 表单提交 | fetch API | Server Actions |
| API | 独立后端 / Mock | Route Handlers |
| SEO | 需额外处理 | 默认更好 |
| 部署 | 静态托管或 Node 服务 | Vercel / Node 服务 |
| 状态管理 | Zustand 直接使用 | 需处理 SSR 水合 |
| 调试 | 浏览器调试为主 | 浏览器 + Node.js 调试 |
4.1 何时选择 React + Vite
适合以下场景:
- 后台管理系统
- 内部工具
- 不需要 SEO 的应用
- 团队更熟悉 SPA 架构
- 后端已有独立 API 服务
React + Vite 的优势是结构简单、开发速度快、部署成本低。大多数管理后台、仪表盘、内部运营系统都可以直接使用这一套。
4.2 何时选择 Next.js App Router
适合以下场景:
- 需要 SEO 的官网、内容站
- 希望首屏加载更快
- 希望前端直接具备服务端能力
- 需要服务端渲染、静态生成或增量静态再生
- 项目需要更完整的路由、缓存和数据获取方案
Next.js 的优势在于它把前端、路由、服务端渲染、数据缓存和部署策略整合在一起。对于公开站点、内容平台、电商首页、博客系统,Next.js 通常更合适。
4.3 状态管理差异
React + Vite 中,Zustand 可以直接使用:
ts
const todos = useTodoStore((state) => state.todos)
Next.js 中,如果页面是 Server Component,直接读取客户端 store 会导致服务端和客户端状态不一致。因此需要:
- 把 store 放在
'use client'文件中; - 使用自定义 hook 延迟读取;
- 或者只在客户端组件中使用 Zustand;
- 服务端数据优先通过 Server Component 获取。
4.4 数据获取差异
React + Vite 通常这样获取数据:
tsx
useEffect(() => {
fetch('/api/todos')
.then((res) => res.json())
.then((data) => setTodos(data.todos))
}, [])
Next.js Server Component 中可以直接:
tsx
const todos = await getTodos()
服务端获取数据可以减少客户端请求,也更容易做缓存和预渲染。
五、WebStorm 常见陷阱与问题排查
5.1 Next.js 启动卡在 "Starting the development server..."
常见原因:
package.json中使用了next dev --turbopack;- WebStorm Run Configuration 未正确识别 npm/pnpm;
- Node.js 版本不兼容;
- 端口被占用。
解决方法:
- 检查
package.json:
json
{
"scripts": {
"dev": "next dev"
}
}
- 删除
--turbopack,先使用 Webpack 模式。 - 在 WebStorm 中使用 NPM 类型配置,而不是直接执行 shell 命令。
- 确认 Node.js 版本满足 Next.js 要求。
- 如果端口冲突,可修改启动端口:
json
{
"scripts": {
"dev": "next dev -p 3001"
}
}
5.2 WebStorm 不识别 Next.js 项目
表现:
app/目录没有特殊标识;next/link、next/image没有自动补全;- 路由跳转无法识别。
解决方法:
- 确认项目已安装
next。 - 右键项目根目录 → Add Framework Support... → 勾选 Next.js。
- 检查
Settings → Plugins中 Next.js Support 是否启用。 - 删除
.idea缓存后重启 WebStorm。 - 若使用 pnpm,需在
Settings → Node.js and NPM中将 Package manager 设置为 pnpm。
5.3 TypeScript 类型不生效
表现:
metadata报错;page.tsxprops 推导异常;next/navigation没有提示。
解决方法:
- 确认
tsconfig.json包含 Next.js 插件:
json
{
"compilerOptions": {
"plugins": [
{
"name": "next"
}
]
}
}
- 确认
include覆盖**/*.ts和**/*.tsx。 - 运行
pnpm install确保依赖完整。 - 在 WebStorm 中执行 File → Invalidate Caches → Invalidate and Restart。
5.4 客户端组件断点不命中
表现:
- 在
TodoItem.tsx中打断点,但调试器不停止; - Source Map 无法映射。
解决方法:
- 确认文件顶部有
'use client'。 - 使用 JavaScript Debug 配置,而不是只启动 npm 脚本。
- URL 必须与实际访问地址一致,例如
http://localhost:3000。 - 安装 Chrome 的 JetBrains IDE Support 插件。
- 若使用生产构建调试,需开启
productionBrowserSourceMaps。
5.5 服务端断点不命中
表现:
- 在
actions.ts或route.ts中打断点无效; - 调试器只停在浏览器端代码。
解决方法:
- 服务端代码需要使用 Node.js 调试器。
- 添加 Node.js 启动配置,入口指向
node_modules/.bin/next。 - 或使用
NODE_OPTIONS=--inspect启动后,通过 Attach to Node.js/Chrome 连接。 - 确认断点不在纯客户端组件中。
- 避免同时启动多个调试器导致端口冲突。
5.6 Turbopack 兼容问题
Turbopack 在 Next.js 15 中已经更加成熟,但在部分 WebStorm 版本中仍可能出现调试上下文注入不完整、断点偏移或启动卡顿等问题。
建议:
- 日常开发可继续使用
next dev; - 若需要更快热更新,可单独增加
dev:turbo脚本; - 调试时优先使用 Webpack 模式;
- 生产构建默认仍使用 Webpack,除非显式启用 Turbopack 构建。
5.7 端口冲突
表现:
Error: listen EADDRINUSE: address already in use :::3000
解决方法:
- Windows 终端执行:
bash
netstat -ano | findstr :3000
taskkill /F /PID <PID>
- 或者修改 Next.js 端口:
json
{
"scripts": {
"dev": "next dev -p 3001"
}
}
- 同时更新 WebStorm 中 JavaScript Debug 的 URL。
5.8 Hydration Error
表现:
Hydration failed because the initial UI does not match what was rendered on the server.
常见原因:
- 服务端和客户端渲染内容不一致;
- 直接在 Server Component 中使用
window、localStorage; - Zustand store 初始值与服务端数据不一致。
解决方法:
- 访问浏览器 API 的代码必须放在
'use client'组件中。 - 使用
useEffect延迟访问客户端状态。 - Zustand store 配合自定义 hook 等待水合完成。
- 避免在 Server Component 中直接调用
useStore。
5.9 Server Action 提交后页面不刷新
表现:
- 点击添加按钮后,数据已经写入数据库(刷新页面可见新数据);
- 但当前页面 UI 没有立即变化,必须手动刷新才能看到结果。
原因分析:
Server Action 执行完成后,Next.js 不会自动让所有依赖该数据的页面重新渲染。它只在两种情况下刷新 UI:
- 通过
<form action={...}>提交的表单,会触发所属路由的重新渲染; - 调用了
revalidatePath或revalidateTag使缓存失效。
如果你的 Server Action 是通过 startTransition 手动调用,或者提交发生在非表单上下文中,就需要显式刷新缓存。
解决方法一:确保用表单方式提交
tsx
// 正确:使用 form action,会自动触发当前路由重新渲染
<form action={createTodo}>
<input name="text" />
<button type="submit">添加</button>
</form>
解决方法二:在 Action 内部调用 revalidatePath
ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createTodo(formData: FormData) {
const text = String(formData.get('text') ?? '').trim()
await addTodo(text)
// 让 '/' 路径的缓存失效,触发页面重新渲染
revalidatePath('/')
// 如果多个页面共享同一数据,可以用 tag 精确刷新
// revalidateTag('todos')
}
解决方法三:客户端手动刷新
tsx
'use client'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { createTodo } from '../actions'
export function AddButton() {
const router = useRouter()
const [isPending, startTransition] = useTransition()
function onClick() {
const formData = new FormData()
formData.set('text', '新的待办')
startTransition(async () => {
await createTodo(formData)
// 手动刷新当前路由
router.refresh()
})
}
return (
<button onClick={onClick} disabled={isPending}>
{isPending ? '提交中...' : '添加'}
</button>
)
}
排查顺序:
- 确认 Server Action 文件顶部有
'use server'; - 确认 Action 返回前调用了
revalidatePath; - 确认被刷新页面的
getTodos没有使用cache: 'force-cache'等强制缓存策略; - 若使用了
unstable_cache或静态生成,需配合 tag 失效。
5.10 静态导出部署后路由 404
表现:
- 使用
next build && next export或output: 'export'导出静态文件; - 部署后访问
/about返回 404。
原因:静态导出不支持动态路由和需要运行时的功能(Server Actions、Route Handlers 部分能力),且子路由需要正确的路径回退配置。
解决方法:
- 在
next.config.ts中设置基础路径:
ts
const nextConfig: NextConfig = {
output: 'export',
// 若部署在子目录,需要指定 basePath
// basePath: '/app',
// 静态导出通常需要关闭图片优化,或自行处理
images: {
unoptimized: true
}
}
- 关闭静态导出不支持的功能(如 Server Actions、
headers()的运行时依赖); - 部署到静态服务器时配置回退到
index.html(视托管平台而定)。
对于需要 Server Actions 和 SSR 的项目,不建议使用静态导出,直接部署到支持 Node 运行时的平台(Vercel、自建 Node 服务)更稳妥。
六、完整替换代码清单
本节把 React 版本与 Next.js 版本所有需要落地/替换的文件汇总,方便你直接对照复制。
6.1 React + Vite 版本文件清单
| 文件 | 用途 | 对应章节 |
|---|---|---|
src/types/todo.ts |
待办类型定义 | 2.3 |
src/stores/todoStore.ts |
Zustand store(含 persist) | 2.4 |
src/api/request.ts |
axios 封装 | 2.5 |
src/api/todoApi.ts |
待办接口 | 2.6 |
src/main.tsx |
应用入口 + 路由挂载 | 2.7 |
src/App.tsx |
根组件 + 路由声明 | 2.8 |
src/pages/HomePage.tsx |
首页 | 2.9 |
src/pages/AboutPage.tsx |
关于页 | 2.10 |
src/components/TodoInput.tsx |
输入框组件 | 2.11 |
src/components/TodoList.tsx |
列表组件 | 2.12 |
src/components/TodoItem.tsx |
列表项组件 | 2.13 |
.env.development |
环境变量 | 2.14 |
依赖安装:
bash
pnpm add zustand react-router-dom axios
pnpm add -D @types/node
6.2 Next.js App Router 版本文件清单
| 文件 | 用途 | 对应章节 |
|---|---|---|
types.ts |
待办类型定义 | 3.3 |
lib/db.ts |
内存数据层 | 3.4 |
lib/store.ts |
Zustand store(含水合处理) | 3.5 |
actions.ts |
Server Actions | 3.6 |
app/globals.css |
全局样式 | 3.7 |
app/layout.tsx |
根布局 + 导航 | 3.8(上轮) |
app/page.tsx |
首页(Server Component) | 3.8 |
app/about/page.tsx |
关于页 | 3.10 |
components/TodoItem.tsx |
列表项(Client Component) | 3.9 |
app/api/todos/route.ts |
REST API Route Handler | 3.11 |
next.config.ts |
Next.js 配置 | 3.12 |
tsconfig.json |
TypeScript 配置 | 3.13 |
package.json scripts |
启动/构建脚本 | 3.14 |
依赖安装:
bash
pnpm add zustand
6.3 两套版本对照关系
| 概念 | Vue 3 版本 | React + Vite 版本 | Next.js 版本 |
|---|---|---|---|
| 类型定义 | types/todo.ts |
src/types/todo.ts |
types.ts |
| 状态管理 | Pinia todoStore |
Zustand todoStore |
Zustand store.ts(+水合 hook) |
| 输入组件 | TodoList.vue(合一) |
TodoInput.tsx + TodoList.tsx |
表单 + Server Action |
| 列表项 | TodoItem.vue |
TodoItem.tsx |
TodoItem.tsx('use client') |
| 路由 | Vue Router | React Router | 文件路由 app/ |
| 数据层 | localStorage | localStorage(persist) | lib/db.ts(内存/DB) |
| 请求封装 | axios request.ts |
axios request.ts |
Route Handlers / Server Actions |
七、总结与后续扩展建议
7.1 本文核心回顾
本替换指南完成了三件事:
- React + Vite 完整替换:把原 Vue 实战的入口、路由(React Router)、状态管理(Zustand)、请求封装、组件与 WebStorm 调试配置逐一对应迁移。
- Next.js App Router 完整替换:演示了服务端组件、客户端组件边界、Server Actions、Route Handlers、水合处理,以及服务端 + 客户端双端调试。
- 差异与陷阱梳理:给出何时选 SPA、何时选 SSR 的判断依据,以及 Turbopack 卡死、断点不命中、Hydration Error、路由 404 等高频问题的解法。
7.2 技术选型建议
- 内部后台、无 SEO 需求、团队熟悉 SPA → React + Vite。
- 公开站点、内容平台、需要 SEO 与首屏性能 → Next.js App Router。
- 已有成熟后端 API,前端只做展示与交互 → React + Vite 更轻。
- 希望前后端同仓、快速接入数据库与鉴权 → Next.js。
7.3 后续可扩展方向
- 接入真实数据库 :把
lib/db.ts替换为 Prisma + PostgreSQL / MySQL,Server Actions 内部直接调用 ORM。 - 加入鉴权:Next.js 可接入 NextAuth,React 版本可接入 OIDC / 自建 token 方案。
- 表单校验统一:React 与 Next.js 都可接入 Zod,做到客户端与服务端共用同一套 schema。
- 数据缓存层:React 版本可引入 TanStack Query 管理服务端状态;Next.js 版本可利用 Next 缓存与 tag 失效。
- 测试:Vitest + React Testing Library(React)或 Playwright(端到端),并在 WebStorm 中配置测试运行器一键运行。
- CI/CD:结合前文的 Git 工作流,配置提交前 lint + 类型检查 + 测试,构建产物自动部署。
7.4 WebStorm 提效小结
- React + Vite 调试:npm 配置启动 + JavaScript Debug 连接浏览器。
- Next.js 调试:npm 配置(客户端)+ Node.js 启动/Attach(服务端)。
- 遇到启动卡死优先排查 Turbopack;断点不命中优先排查 Source Map 与调试配置类型。
- 大型项目务必排除
node_modules、.next、dist,并合理调高 JVM 内存。
全文从替换总览、React 方案、Next.js 方案、差异对比、陷阱排查到完整代码清单已闭合。你可以按第 6.1 或 6.2 的文件清单,在 WebStorm 中逐项落地,跑通对应版本后再按第 7.3 逐步扩展。