Skip to content

Next.js 前端架构深度剖析

深入理解 Dify 前端的 Next.js App Router、组件设计、状态管理和 API 调用

📖 内容概述

本文将深入剖析 Dify 前端的技术架构,包括 Next.js App Router 的使用、组件设计模式、状态管理方案(Zustand + SWR)、API 调用封装等核心内容。

🎯 学习目标

  • 理解 Next.js App Router 架构
  • 掌握 Dify 的组件设计模式
  • 理解状态管理方案(Zustand + SWR + Context)
  • 掌握 API 调用封装和错误处理
  • 学习国际化方案(i18next)
  • 理解 SSR 和 CSR 的使用场景

📂 源码路径

web/
├── app/                      # Next.js App Router 目录 ⭐
│   ├── (commonLayout)/       # 通用布局
│   │   ├── app/              # 应用页面
│   │   ├── datasets/         # 知识库页面
│   │   └── plugins/          # 插件页面
│   ├── (shareLayout)/        # 分享布局
│   ├── components/           # 页面级组件
│   ├── layout.tsx            # 根布局
│   └── page.tsx              # 首页

├── components/               # 通用组件库 ⭐
│   ├── base/                 # 基础组件
│   ├── app/                  # 应用相关组件
│   ├── datasets/             # 知识库组件
│   ├── workflow/             # 工作流组件
│   └── header/               # 头部导航

├── service/                  # API 调用层 ⭐
│   ├── apps.ts               # 应用 API
│   ├── datasets.ts           # 知识库 API
│   ├── use-apps.ts           # React Hooks
│   └── base.ts               # 基础请求

├── context/                  # React Context ⭐
│   ├── app-context.tsx       # 应用上下文
│   ├── provider-context.tsx  # Provider 上下文
│   └── modal-context.tsx     # 弹窗上下文

├── hooks/                    # 自定义 Hooks
├── models/                   # TypeScript 类型
├── i18n/                     # 国际化
├── utils/                    # 工具函数
└── public/                   # 静态资源

一、Next.js App Router 架构

1.1 App Router vs Pages Router

Dify 使用 Next.js 15 的 App Router(不是旧的 Pages Router),这是一个重大的架构升级。

主要区别

特性Pages Router(旧)App Router(新)
路由方式文件系统路由(pages/)文件系统路由(app/)
布局_app.tsx + _document.tsxlayout.tsx
数据获取getServerSidePropsServer Components
API 路由pages/api/app/api/ 或 Route Handlers
客户端组件默认需要 'use client'
服务端组件不支持默认
流式渲染不支持支持 Suspense
性能较慢更快

1.2 目录结构设计

app/
├── layout.tsx                # 根布局(所有页面共享)
├── page.tsx                  # 首页
├── loading.tsx               # 加载状态
├── error.tsx                 # 错误页面

├── (commonLayout)/           # 路由组(共享布局)
│   ├── layout.tsx            # 通用布局
│   ├── app/                  # 应用管理
│   │   ├── page.tsx          # /app - 应用列表
│   │   └── [appId]/          # /app/:appId - 应用详情
│   │       ├── page.tsx
│   │       ├── overview/     # /app/:appId/overview
│   │       ├── configuration/
│   │       └── logs/
│   │
│   └── datasets/             # 知识库管理
│       ├── page.tsx          # /datasets - 知识库列表
│       └── [datasetId]/      # /datasets/:datasetId

├── (shareLayout)/            # 分享布局(不同的导航栏)
│   ├── layout.tsx
│   ├── chat/[token]/         # /chat/:token - 分享聊天
│   └── completion/[token]/   # /completion/:token

├── signin/                   # /signin - 登录页
│   └── page.tsx

└── api/                      # API 路由(可选)
    └── auth/
        └── route.ts

路由组的作用

  • 用括号包裹的目录(如 (commonLayout))不会出现在 URL 中
  • 用于组织共享相同布局的路由
  • 每个路由组可以有自己的 layout.tsx

1.3 布局系统

根布局app/layout.tsx

typescript
// app/layout.tsx
import { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Dify - 开源 LLM 应用开发平台',
  description: '构建和运营生成式 AI 原生应用',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        {/* 提供全局上下文 */}
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  )
}

通用布局app/(commonLayout)/layout.tsx

typescript
// app/(commonLayout)/layout.tsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Header from '@/app/components/header'
import { useAppContext } from '@/context/app-context'

export default function CommonLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const router = useRouter()
  const { isLoggedIn } = useAppContext()
  
  // 未登录重定向到登录页
  useEffect(() => {
    if (!isLoggedIn) {
      router.push('/signin')
    }
  }, [isLoggedIn, router])
  
  return (
    <div className="flex h-screen">
      {/* 侧边栏 */}
      <aside className="w-64 bg-gray-100">
        <Header />
      </aside>
      
      {/* 主内容区 */}
      <main className="flex-1 overflow-auto">
        {children}
      </main>
    </div>
  )
}

1.4 服务端组件 vs 客户端组件

服务端组件(默认):

typescript
// app/apps/page.tsx
// 默认是服务端组件(无需 'use client')
import { getApps } from '@/service/apps'

export default async function AppsPage() {
  // 可以直接在服务端获取数据
  const apps = await getApps()
  
  return (
    <div>
      <h1>应用列表</h1>
      {apps.map(app => (
        <div key={app.id}>{app.name}</div>
      ))}
    </div>
  )
}

客户端组件

typescript
// app/components/app-card.tsx
'use client'  // 必须声明

import { useState } from 'react'
import { useRouter } from 'next/navigation'

export default function AppCard({ app }) {
  const [isHovered, setIsHovered] = useState(false)
  const router = useRouter()
  
  // 可以使用 React Hooks 和浏览器 API
  const handleClick = () => {
    router.push(`/app/${app.id}`)
  }
  
  return (
    <div 
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      onClick={handleClick}
    >
      {app.name}
    </div>
  )
}

何时使用客户端组件

  • ✅ 需要使用 React Hooks(useState, useEffect 等)
  • ✅ 需要事件监听(onClick, onChange 等)
  • ✅ 需要浏览器 API(localStorage, window 等)
  • ✅ 需要使用第三方客户端库

何时使用服务端组件

  • ✅ 纯展示内容
  • ✅ 直接访问后端资源(数据库、文件系统)
  • ✅ 保护敏感信息(API Key 等)
  • ✅ 减少客户端 JavaScript 体积

二、组件设计模式

2.1 组件分层

Dify 的组件分为三层:

components/
├── base/                    # 第一层:基础组件(最底层)
│   ├── button/              # 按钮
│   ├── input/               # 输入框
│   ├── modal/               # 弹窗
│   ├── select/              # 下拉选择
│   └── icons/               # 图标

├── app/                     # 第二层:业务组件(中间层)
│   ├── configuration/       # 应用配置
│   ├── log/                 # 日志查看
│   └── annotation/          # 标注管理

└── header/                  # 第三层:页面组件(最上层)
    ├── account-dropdown/
    ├── app-nav/
    └── workspace-selector/

分层原则

  1. 基础组件

    • 纯 UI 组件,无业务逻辑
    • 高度可复用
    • 接受 props 控制行为
    • 可以独立使用
  2. 业务组件

    • 包含业务逻辑
    • 调用 API
    • 管理状态
    • 组合基础组件
  3. 页面组件

    • 完整的功能模块
    • 路由级别的组件
    • 组合业务组件和基础组件

2.2 基础组件示例

Button 组件

typescript
// components/base/button/index.tsx
'use client'
import { forwardRef } from 'react'
import classNames from 'classnames'

export type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'small' | 'medium' | 'large'
  loading?: boolean
  disabled?: boolean
  children: React.ReactNode
  onClick?: () => void
  className?: string
}

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = 'primary',
      size = 'medium',
      loading = false,
      disabled = false,
      children,
      onClick,
      className,
    },
    ref
  ) => {
    const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors'
    
    const variantClasses = {
      primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300',
      secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
      danger: 'bg-red-600 text-white hover:bg-red-700',
    }
    
    const sizeClasses = {
      small: 'px-3 py-1.5 text-sm',
      medium: 'px-4 py-2 text-base',
      large: 'px-6 py-3 text-lg',
    }
    
    return (
      <button
        ref={ref}
        className={classNames(
          baseClasses,
          variantClasses[variant],
          sizeClasses[size],
          className
        )}
        disabled={disabled || loading}
        onClick={onClick}
      >
        {loading && (
          <svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
            {/* Loading icon */}
          </svg>
        )}
        {children}
      </button>
    )
  }
)

Button.displayName = 'Button'

export default Button

2.3 业务组件示例

AppCard 组件

typescript
// components/app/app-card.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Trash2, MoreVertical } from 'lucide-react'
import Button from '@/components/base/button'
import Modal from '@/components/base/modal'
import { deleteApp } from '@/service/apps'
import { App } from '@/models/app'

type AppCardProps = {
  app: App
  onDelete?: () => void
}

export default function AppCard({ app, onDelete }: AppCardProps) {
  const router = useRouter()
  const [showDeleteModal, setShowDeleteModal] = useState(false)
  const [isDeleting, setIsDeleting] = useState(false)
  
  const handleClick = () => {
    router.push(`/app/${app.id}/overview`)
  }
  
  const handleDelete = async () => {
    setIsDeleting(true)
    try {
      await deleteApp(app.id)
      onDelete?.()
      setShowDeleteModal(false)
    } catch (error) {
      console.error('Failed to delete app:', error)
    } finally {
      setIsDeleting(false)
    }
  }
  
  return (
    <>
      <div 
        className="p-4 border rounded-lg hover:shadow-lg transition-shadow cursor-pointer"
        onClick={handleClick}
      >
        {/* 应用图标 */}
        <div 
          className="w-12 h-12 rounded-lg mb-3"
          style={{ backgroundColor: app.icon_background }}
        >
          <span className="text-2xl">{app.icon}</span>
        </div>
        
        {/* 应用信息 */}
        <h3 className="text-lg font-semibold mb-1">{app.name}</h3>
        <p className="text-sm text-gray-500 mb-3">
          {app.mode === 'completion' ? '文本生成' : '对话助手'}
        </p>
        
        {/* 操作按钮 */}
        <div className="flex items-center justify-between">
          <span className="text-xs text-gray-400">
            {new Date(app.created_at).toLocaleDateString()}
          </span>
          
          <button
            className="p-1 hover:bg-gray-100 rounded"
            onClick={(e) => {
              e.stopPropagation()
              setShowDeleteModal(true)
            }}
          >
            <Trash2 className="w-4 h-4 text-gray-500" />
          </button>
        </div>
      </div>
      
      {/* 删除确认弹窗 */}
      <Modal
        isOpen={showDeleteModal}
        onClose={() => setShowDeleteModal(false)}
        title="删除应用"
      >
        <p className="mb-4">确定要删除应用「{app.name}」吗?此操作无法撤销。</p>
        <div className="flex justify-end gap-2">
          <Button
            variant="secondary"
            onClick={() => setShowDeleteModal(false)}
          >
            取消
          </Button>
          <Button
            variant="danger"
            loading={isDeleting}
            onClick={handleDelete}
          >
            删除
          </Button>
        </div>
      </Modal>
    </>
  )
}

2.4 复合组件模式

typescript
// components/base/tabs/index.tsx
'use client'
import { createContext, useContext, useState } from 'react'

type TabsContextType = {
  activeTab: string
  setActiveTab: (tab: string) => void
}

const TabsContext = createContext<TabsContextType | null>(null)

// 主容器
export function Tabs({ 
  defaultTab, 
  children 
}: { 
  defaultTab: string
  children: React.ReactNode 
}) {
  const [activeTab, setActiveTab] = useState(defaultTab)
  
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  )
}

// Tab 列表
export function TabList({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex border-b">
      {children}
    </div>
  )
}

// 单个 Tab
export function Tab({ 
  value, 
  children 
}: { 
  value: string
  children: React.ReactNode 
}) {
  const context = useContext(TabsContext)
  if (!context) throw new Error('Tab must be used within Tabs')
  
  const { activeTab, setActiveTab } = context
  const isActive = activeTab === value
  
  return (
    <button
      className={`px-4 py-2 ${isActive ? 'border-b-2 border-blue-600' : ''}`}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  )
}

// Tab 面板
export function TabPanel({ 
  value, 
  children 
}: { 
  value: string
  children: React.ReactNode 
}) {
  const context = useContext(TabsContext)
  if (!context) throw new Error('TabPanel must be used within Tabs')
  
  const { activeTab } = context
  
  if (activeTab !== value) return null
  
  return <div className="p-4">{children}</div>
}

// 使用示例
function AppSettings() {
  return (
    <Tabs defaultTab="general">
      <TabList>
        <Tab value="general">通用设置</Tab>
        <Tab value="model">模型配置</Tab>
        <Tab value="prompt">Prompt 设置</Tab>
      </TabList>
      
      <TabPanel value="general">
        通用设置内容
      </TabPanel>
      
      <TabPanel value="model">
        模型配置内容
      </TabPanel>
      
      <TabPanel value="prompt">
        Prompt 设置内容
      </TabPanel>
    </Tabs>
  )
}

三、状态管理

3.1 三种状态管理方案

Dify 使用三种状态管理方案的组合:

方案用途示例
Zustand全局状态(跨页面)用户信息、工作空间
SWR服务端数据(API)应用列表、知识库数据
React Context组件树状态主题、多语言、弹窗

3.2 Zustand 全局状态

创建 Store

typescript
// context/app-context.tsx
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

type User = {
  id: string
  name: string
  email: string
}

type AppStore = {
  // 状态
  user: User | null
  currentWorkspaceId: string | null
  isLoggedIn: boolean
  
  // Actions
  setUser: (user: User) => void
  setCurrentWorkspace: (workspaceId: string) => void
  logout: () => void
}

export const useAppStore = create<AppStore>()(
  persist(
    (set) => ({
      // 初始状态
      user: null,
      currentWorkspaceId: null,
      isLoggedIn: false,
      
      // 设置用户
      setUser: (user) => set({ 
        user, 
        isLoggedIn: true 
      }),
      
      // 设置当前工作空间
      setCurrentWorkspace: (workspaceId) => set({ 
        currentWorkspaceId: workspaceId 
      }),
      
      // 登出
      logout: () => set({ 
        user: null, 
        currentWorkspaceId: null,
        isLoggedIn: false 
      }),
    }),
    {
      name: 'app-storage', // localStorage key
      partialize: (state) => ({ 
        // 只持久化部分状态
        currentWorkspaceId: state.currentWorkspaceId,
      }),
    }
  )
)

// 使用示例
function Header() {
  const { user, logout } = useAppStore()
  
  return (
    <div>
      <span>欢迎,{user?.name}</span>
      <button onClick={logout}>退出</button>
    </div>
  )
}

3.3 SWR 数据获取

基础用法

typescript
// service/use-apps.ts
import useSWR from 'swr'
import { getApps } from './apps'
import type { App } from '@/models/app'

export function useApps() {
  const { data, error, isLoading, mutate } = useSWR<App[]>(
    '/apps',  // key
    getApps,  // fetcher
    {
      revalidateOnFocus: true,      // 窗口聚焦时重新验证
      revalidateOnReconnect: true,  // 重连时重新验证
      dedupingInterval: 5000,       // 5秒内不重复请求
    }
  )
  
  return {
    apps: data,
    isLoading,
    isError: error,
    refresh: mutate,  // 手动刷新
  }
}

// 在组件中使用
function AppList() {
  const { apps, isLoading, isError, refresh } = useApps()
  
  if (isLoading) return <div>加载中...</div>
  if (isError) return <div>加载失败</div>
  
  return (
    <div>
      <button onClick={() => refresh()}>刷新</button>
      {apps?.map(app => (
        <AppCard key={app.id} app={app} />
      ))}
    </div>
  )
}

乐观更新

typescript
// service/use-apps.ts
import useSWR from 'swr'
import { getApps, createApp, deleteApp } from './apps'

export function useApps() {
  const { data, mutate } = useSWR('/apps', getApps)
  
  // 创建应用(乐观更新)
  const create = async (appData: CreateAppData) => {
    // 乐观更新 UI(先更新,后请求)
    const newApp = { id: 'temp-id', ...appData, created_at: new Date() }
    mutate([...(data || []), newApp], false)  // false = 不重新验证
    
    try {
      // 实际请求
      const createdApp = await createApp(appData)
      
      // 更新为真实数据
      mutate([...(data || []), createdApp])
      
      return createdApp
    } catch (error) {
      // 失败时回滚
      mutate(data)
      throw error
    }
  }
  
  // 删除应用
  const remove = async (appId: string) => {
    // 乐观删除
    mutate(data?.filter(app => app.id !== appId), false)
    
    try {
      await deleteApp(appId)
      mutate()  // 重新验证
    } catch (error) {
      mutate(data)  // 回滚
      throw error
    }
  }
  
  return {
    apps: data,
    create,
    remove,
  }
}

3.4 React Context

typescript
// context/modal-context.tsx
'use client'
import { createContext, useContext, useState } from 'react'

type ModalContextType = {
  showPricingModal: boolean
  setShowPricingModal: (show: boolean) => void
  showAccountModal: boolean
  setShowAccountModal: (show: boolean) => void
}

const ModalContext = createContext<ModalContextType | null>(null)

export function ModalProvider({ children }: { children: React.ReactNode }) {
  const [showPricingModal, setShowPricingModal] = useState(false)
  const [showAccountModal, setShowAccountModal] = useState(false)
  
  return (
    <ModalContext.Provider
      value={{
        showPricingModal,
        setShowPricingModal,
        showAccountModal,
        setShowAccountModal,
      }}
    >
      {children}
    </ModalContext.Provider>
  )
}

export function useModalContext() {
  const context = useContext(ModalContext)
  if (!context) {
    throw new Error('useModalContext must be used within ModalProvider')
  }
  return context
}

// 使用示例
function Header() {
  const { setShowPricingModal } = useModalContext()
  
  return (
    <button onClick={() => setShowPricingModal(true)}>
      查看定价
    </button>
  )
}

四、API 调用封装

4.1 基础请求封装

typescript
// service/base.ts
import { toast } from 'react-hot-toast'

type RequestOptions = RequestInit & {
  params?: Record<string, any>
  needAllResponseContent?: boolean
}

class APIError extends Error {
  code: string
  status: number
  
  constructor(message: string, code: string, status: number) {
    super(message)
    this.code = code
    this.status = status
  }
}

// 基础请求函数
async function request<T>(
  url: string,
  options: RequestOptions = {}
): Promise<T> {
  const { params, needAllResponseContent, ...fetchOptions } = options
  
  // 构建完整 URL
  let fullUrl = `${process.env.NEXT_PUBLIC_API_URL}${url}`
  
  // 添加查询参数
  if (params) {
    const searchParams = new URLSearchParams(params)
    fullUrl += `?${searchParams.toString()}`
  }
  
  // 默认配置
  const config: RequestInit = {
    ...fetchOptions,
    headers: {
      'Content-Type': 'application/json',
      ...fetchOptions.headers,
    },
    credentials: 'include',  // 携带 Cookie
  }
  
  // 添加 Token
  const token = getToken()
  if (token) {
    config.headers = {
      ...config.headers,
      'Authorization': `Bearer ${token}`,
    }
  }
  
  try {
    const response = await fetch(fullUrl, config)
    
    // 处理 HTTP 错误
    if (!response.ok) {
      const error = await response.json()
      throw new APIError(
        error.message || 'Request failed',
        error.code || 'UNKNOWN_ERROR',
        response.status
      )
    }
    
    // 解析响应
    const data = await response.json()
    
    // 返回完整响应或只返回数据
    return needAllResponseContent ? data : data.data
    
  } catch (error) {
    // 错误处理
    if (error instanceof APIError) {
      // API 错误
      toast.error(error.message)
      throw error
    } else {
      // 网络错误
      toast.error('网络请求失败')
      throw new Error('Network error')
    }
  }
}

// GET 请求
export function get<T>(url: string, options?: RequestOptions): Promise<T> {
  return request<T>(url, { ...options, method: 'GET' })
}

// POST 请求
export function post<T>(
  url: string, 
  data?: any, 
  options?: RequestOptions
): Promise<T> {
  return request<T>(url, {
    ...options,
    method: 'POST',
    body: JSON.stringify(data),
  })
}

// PUT 请求
export function put<T>(
  url: string, 
  data?: any, 
  options?: RequestOptions
): Promise<T> {
  return request<T>(url, {
    ...options,
    method: 'PUT',
    body: JSON.stringify(data),
  })
}

// DELETE 请求
export function del<T>(url: string, options?: RequestOptions): Promise<T> {
  return request<T>(url, { ...options, method: 'DELETE' })
}

// Token 管理
function getToken(): string | null {
  if (typeof window === 'undefined') return null
  return localStorage.getItem('token')
}

4.2 API 服务封装

typescript
// service/apps.ts
import { get, post, put, del } from './base'
import type { App, CreateAppData, UpdateAppData } from '@/models/app'

// 获取应用列表
export async function getApps(): Promise<App[]> {
  return get<App[]>('/console/api/apps')
}

// 获取应用详情
export async function getApp(appId: string): Promise<App> {
  return get<App>(`/console/api/apps/${appId}`)
}

// 创建应用
export async function createApp(data: CreateAppData): Promise<App> {
  return post<App>('/console/api/apps', data)
}

// 更新应用
export async function updateApp(
  appId: string, 
  data: UpdateAppData
): Promise<App> {
  return put<App>(`/console/api/apps/${appId}`, data)
}

// 删除应用
export async function deleteApp(appId: string): Promise<void> {
  return del<void>(`/console/api/apps/${appId}`)
}

// 复制应用
export async function duplicateApp(appId: string): Promise<App> {
  return post<App>(`/console/api/apps/${appId}/duplicate`)
}

4.3 SSE 流式请求

typescript
// service/completion.ts
export async function* streamCompletion(
  appId: string,
  data: CompletionData
): AsyncGenerator<CompletionChunk> {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/console/api/apps/${appId}/completion-messages`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${getToken()}`,
      },
      body: JSON.stringify(data),
    }
  )
  
  if (!response.ok) {
    throw new Error('Stream request failed')
  }
  
  const reader = response.body?.getReader()
  const decoder = new TextDecoder()
  
  if (!reader) {
    throw new Error('No reader available')
  }
  
  try {
    while (true) {
      const { done, value } = await reader.read()
      
      if (done) break
      
      // 解码数据
      const chunk = decoder.decode(value, { stream: true })
      
      // 解析 SSE 格式
      const lines = chunk.split('\n')
      
      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const data = line.slice(6)  // 移除 "data: "
          
          if (data === '[DONE]') {
            return
          }
          
          try {
            const parsed = JSON.parse(data)
            yield parsed
          } catch (e) {
            console.error('Failed to parse SSE data:', e)
          }
        }
      }
    }
  } finally {
    reader.releaseLock()
  }
}

// 使用示例
async function handleCompletion() {
  const stream = streamCompletion(appId, { query: 'Hello' })
  
  let fullText = ''
  
  for await (const chunk of stream) {
    if (chunk.event === 'message') {
      fullText += chunk.answer
      // 更新 UI
      setMessage(fullText)
    } else if (chunk.event === 'message_end') {
      // 完成
      console.log('Completed:', fullText)
    }
  }
}

五、国际化(i18n)

5.1 i18next 配置

typescript
// i18n/config.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'

import zhHans from './locales/zh-Hans.json'
import enUS from './locales/en-US.json'

i18n
  .use(LanguageDetector)  // 自动检测语言
  .use(initReactI18next)  // 传递给 react-i18next
  .init({
    resources: {
      'zh-Hans': { translation: zhHans },
      'en-US': { translation: enUS },
    },
    fallbackLng: 'en-US',
    
    interpolation: {
      escapeValue: false,  // React 已经安全了
    },
    
    detection: {
      order: ['localStorage', 'navigator'],
      caches: ['localStorage'],
    },
  })

export default i18n

翻译文件

json
// i18n/locales/zh-Hans.json
{
  "common": {
    "save": "保存",
    "cancel": "取消",
    "delete": "删除",
    "confirm": "确认"
  },
  "app": {
    "create": "创建应用",
    "list": "应用列表",
    "settings": "应用设置",
    "delete_confirm": "确定要删除应用「{{name}}」吗?"
  }
}

5.2 使用翻译

typescript
// components/app/create-modal.tsx
'use client'
import { useTranslation } from 'react-i18next'

function CreateAppModal() {
  const { t } = useTranslation()
  
  return (
    <Modal title={t('app.create')}>
      <Input placeholder={t('app.name_placeholder')} />
      
      <div className="flex gap-2">
        <Button onClick={handleCancel}>
          {t('common.cancel')}
        </Button>
        <Button onClick={handleCreate}>
          {t('common.save')}
        </Button>
      </div>
    </Modal>
  )
}

// 带参数的翻译
function DeleteConfirmModal({ appName }: { appName: string }) {
  const { t } = useTranslation()
  
  return (
    <Modal>
      <p>{t('app.delete_confirm', { name: appName })}</p>
    </Modal>
  )
}

六、实践项目

项目 1:创建一个自定义组件

目标:实现一个带搜索和过滤的列表组件

typescript
function AppListWithSearch() {
  const [search, setSearch] = useState('')
  const [filter, setFilter] = useState<'all' | 'completion' | 'chat'>('all')
  
  const { apps } = useApps()
  
  const filteredApps = apps?.filter(app => {
    const matchSearch = app.name.includes(search)
    const matchFilter = filter === 'all' || app.mode === filter
    return matchSearch && matchFilter
  })
  
  return (
    <div>
      <Input 
        placeholder="搜索应用"
        value={search}
        onChange={(e) => setSearch(e.target.value)}
      />
      
      <Select value={filter} onChange={setFilter}>
        <option value="all">全部</option>
        <option value="completion">文本生成</option>
        <option value="chat">对话</option>
      </Select>
      
      <div className="grid grid-cols-3 gap-4">
        {filteredApps?.map(app => (
          <AppCard key={app.id} app={app} />
        ))}
      </div>
    </div>
  )
}

项目 2:实现 SSE 流式聊天

项目 3:对比分析组件设计差异

📚 扩展阅读

🎓 自测题

  1. Next.js App Router 和 Pages Router 的主要区别是什么?
  2. 什么时候使用服务端组件?什么时候使用客户端组件?
  3. Dify 为什么同时使用 Zustand、SWR 和 Context?
  4. 如何实现乐观更新?
  5. 如何处理 SSE 流式响应?
  6. i18next 的工作原理是什么?

✅ 小结

关键要点

  • ✅ Next.js 15 + App Router 架构
  • ✅ 三层组件设计(基础、业务、页面)
  • ✅ 三种状态管理方案的组合使用
  • ✅ 完善的 API 封装和错误处理
  • ✅ SSE 流式响应实现
  • ✅ 国际化方案

下一步


学习进度: ⬜ 未开始 | 🚧 进行中 | ✅ 已完成