WebStorm Windows 版 React / Next.js 实战替换指南



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 启动脚本
  1. 打开 Run → Edit Configurations...。
  2. 点击 + ,选择 npm。
  3. 配置如下:
字段 值
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
  1. 保持 Vite 开发服务器运行。
  2. 打开 Run → Edit Configurations...。
  3. 点击 + ,选择 JavaScript Debug。
  4. 配置如下:
字段 值
Name Debug React @5173
URL http://localhost:5173/
Browser Chrome
  1. 在 src/components/TodoInput.tsx 的 handleSubmit 中设置断点。
  2. 点击 Debug 启动调试。
  3. 刷新浏览器,输入待办内容并提交,断点即可命中。

如果断点不命中,需要检查:

  • 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 中打开项目:

  1. 右键项目根目录 → Add Framework Support... → 勾选 Next.js。
  2. 确认 Settings → Plugins 中 Next.js Support 插件已启用。
  3. 等待 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 项目

  1. 打开项目根目录。
  2. 右键根目录 → Add Framework Support... → 勾选 Next.js。
  3. 确认 Settings → Plugins 中 Next.js Support 已启用。
  4. 打开右上角 Run/Debug Configurations。
  5. 添加 NPM 配置:
    • Command:run
    • Scripts:dev
    • Node interpreter:选择本地 Node.js
    • Package manager:若使用 pnpm,需显式设置为 pnpm
  6. 点击 Debug 启动开发服务器。
  7. 浏览器访问 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 会导致服务端和客户端状态不一致。因此需要:

  1. 把 store 放在 'use client' 文件中;
  2. 使用自定义 hook 延迟读取;
  3. 或者只在客户端组件中使用 Zustand;
  4. 服务端数据优先通过 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 版本不兼容;
  • 端口被占用。

解决方法:

  1. 检查 package.json:
json 复制代码
{
  "scripts": {
    "dev": "next dev"
  }
}
  1. 删除 --turbopack,先使用 Webpack 模式。
  2. 在 WebStorm 中使用 NPM 类型配置,而不是直接执行 shell 命令。
  3. 确认 Node.js 版本满足 Next.js 要求。
  4. 如果端口冲突,可修改启动端口:
json 复制代码
{
  "scripts": {
    "dev": "next dev -p 3001"
  }
}

5.2 WebStorm 不识别 Next.js 项目

表现:

  • app/ 目录没有特殊标识;
  • next/link、next/image 没有自动补全;
  • 路由跳转无法识别。

解决方法:

  1. 确认项目已安装 next。
  2. 右键项目根目录 → Add Framework Support... → 勾选 Next.js。
  3. 检查 Settings → Plugins 中 Next.js Support 是否启用。
  4. 删除 .idea 缓存后重启 WebStorm。
  5. 若使用 pnpm,需在 Settings → Node.js and NPM 中将 Package manager 设置为 pnpm。

5.3 TypeScript 类型不生效

表现:

  • metadata 报错;
  • page.tsx props 推导异常;
  • next/navigation 没有提示。

解决方法:

  1. 确认 tsconfig.json 包含 Next.js 插件:
json 复制代码
{
  "compilerOptions": {
    "plugins": [
      {
        "name": "next"
      }
    ]
  }
}
  1. 确认 include 覆盖 **/*.ts 和 **/*.tsx。
  2. 运行 pnpm install 确保依赖完整。
  3. 在 WebStorm 中执行 File → Invalidate Caches → Invalidate and Restart。

5.4 客户端组件断点不命中

表现:

  • 在 TodoItem.tsx 中打断点,但调试器不停止;
  • Source Map 无法映射。

解决方法:

  1. 确认文件顶部有 'use client'。
  2. 使用 JavaScript Debug 配置,而不是只启动 npm 脚本。
  3. URL 必须与实际访问地址一致,例如 http://localhost:3000。
  4. 安装 Chrome 的 JetBrains IDE Support 插件。
  5. 若使用生产构建调试,需开启 productionBrowserSourceMaps。

5.5 服务端断点不命中

表现:

  • 在 actions.ts 或 route.ts 中打断点无效;
  • 调试器只停在浏览器端代码。

解决方法:

  1. 服务端代码需要使用 Node.js 调试器。
  2. 添加 Node.js 启动配置,入口指向 node_modules/.bin/next。
  3. 或使用 NODE_OPTIONS=--inspect 启动后,通过 Attach to Node.js/Chrome 连接。
  4. 确认断点不在纯客户端组件中。
  5. 避免同时启动多个调试器导致端口冲突。

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

解决方法:

  1. Windows 终端执行:
bash 复制代码
netstat -ano | findstr :3000
taskkill /F /PID <PID>
  1. 或者修改 Next.js 端口:
json 复制代码
{
  "scripts": {
    "dev": "next dev -p 3001"
  }
}
  1. 同时更新 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 初始值与服务端数据不一致。

解决方法:

  1. 访问浏览器 API 的代码必须放在 'use client' 组件中。
  2. 使用 useEffect 延迟访问客户端状态。
  3. Zustand store 配合自定义 hook 等待水合完成。
  4. 避免在 Server Component 中直接调用 useStore。

5.9 Server Action 提交后页面不刷新

表现:

  • 点击添加按钮后,数据已经写入数据库(刷新页面可见新数据);
  • 但当前页面 UI 没有立即变化,必须手动刷新才能看到结果。

原因分析:

Server Action 执行完成后,Next.js 不会自动让所有依赖该数据的页面重新渲染。它只在两种情况下刷新 UI:

  1. 通过 <form action={...}> 提交的表单,会触发所属路由的重新渲染;
  2. 调用了 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>
  )
}

排查顺序:

  1. 确认 Server Action 文件顶部有 'use server';
  2. 确认 Action 返回前调用了 revalidatePath;
  3. 确认被刷新页面的 getTodos 没有使用 cache: 'force-cache' 等强制缓存策略;
  4. 若使用了 unstable_cache 或静态生成,需配合 tag 失效。

5.10 静态导出部署后路由 404

表现:

  • 使用 next build && next export 或 output: 'export' 导出静态文件;
  • 部署后访问 /about 返回 404。

原因:静态导出不支持动态路由和需要运行时的功能(Server Actions、Route Handlers 部分能力),且子路由需要正确的路径回退配置。

解决方法:

  1. 在 next.config.ts 中设置基础路径:
ts 复制代码
const nextConfig: NextConfig = {
  output: 'export',
  // 若部署在子目录,需要指定 basePath
  // basePath: '/app',
  // 静态导出通常需要关闭图片优化,或自行处理
  images: {
    unoptimized: true
  }
}
  1. 关闭静态导出不支持的功能(如 Server Actions、headers() 的运行时依赖);
  2. 部署到静态服务器时配置回退到 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 本文核心回顾

本替换指南完成了三件事:

  1. React + Vite 完整替换:把原 Vue 实战的入口、路由(React Router)、状态管理(Zustand)、请求封装、组件与 WebStorm 调试配置逐一对应迁移。
  2. Next.js App Router 完整替换:演示了服务端组件、客户端组件边界、Server Actions、Route Handlers、水合处理,以及服务端 + 客户端双端调试。
  3. 差异与陷阱梳理:给出何时选 SPA、何时选 SSR 的判断依据,以及 Turbopack 卡死、断点不命中、Hydration Error、路由 404 等高频问题的解法。

7.2 技术选型建议

  • 内部后台、无 SEO 需求、团队熟悉 SPA → React + Vite。
  • 公开站点、内容平台、需要 SEO 与首屏性能 → Next.js App Router。
  • 已有成熟后端 API,前端只做展示与交互 → React + Vite 更轻。
  • 希望前后端同仓、快速接入数据库与鉴权 → Next.js。

7.3 后续可扩展方向

  1. 接入真实数据库 :把 lib/db.ts 替换为 Prisma + PostgreSQL / MySQL,Server Actions 内部直接调用 ORM。
  2. 加入鉴权:Next.js 可接入 NextAuth,React 版本可接入 OIDC / 自建 token 方案。
  3. 表单校验统一:React 与 Next.js 都可接入 Zod,做到客户端与服务端共用同一套 schema。
  4. 数据缓存层:React 版本可引入 TanStack Query 管理服务端状态;Next.js 版本可利用 Next 缓存与 tag 失效。
  5. 测试:Vitest + React Testing Library(React)或 Playwright(端到端),并在 WebStorm 中配置测试运行器一键运行。
  6. 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 逐步扩展。



相关推荐
apollo_qwe38 分钟前
接口超时了,前端为什么不能直接重试?
javascript·http
陈工大模型41 分钟前
2026年11月最新免费解压软件横评:7-Zip 、 Win解压缩名列前茅
windows·软件工程·7-zip·压缩软件
toooooop85 小时前
CRMEB 故障排查笔记:安全网关只放行 GET/POST 怎么办
前端·javascript·vue.js·thinkphp6·crmeb
软件技术NINI6 小时前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
vipxieliang6 小时前
JavaScript 模板字符串与标签模板:不只是拼接
前端·javascript
Dream-Y.ocean16 小时前
鸿蒙PC平台宅匠 3D 室内设计适配实战:基于 Electron 壳方案的 Three.js 双渲染引擎与参数化户型编辑器开发
javascript·3d·harmonyos
Highcharts.js17 小时前
Highcharts Gantt使用符号的自定义数据标签开发甘特图示例
javascript·甘特图·highcharts·图表开发·实战代码·响应式图表
kyriewen20 小时前
Skills 榜前 10 我全装了:只有 3 个能直接用
前端·javascript·程序员
工控丫丫21 小时前
帆软看板中经常使用的公式函数及经常搭的组合
前端·javascript·数据库