Skip to content

Next.js 客户端渲染 (CSR) 🖥️

🎯 概述

客户端渲染(Client-Side Rendering,CSR)是在浏览器中使用 JavaScript 渲染页面内容的方式。Next.js 支持通过客户端组件实现 CSR,适用于需要交互性和实时更新的场景。

📦 什么是 CSR?

CSR 工作原理

  1. 服务器返回最小的 HTML
  2. 浏览器下载 JavaScript
  3. JavaScript 在客户端执行
  4. 动态渲染页面内容
  5. 处理用户交互

CSR 适用场景

  • 🎮 高度交互的应用
  • 📊 实时数据更新
  • 🔐 需要用户认证的内容
  • 💬 聊天和消息应用
  • 🎨 复杂的用户界面

🚀 客户端组件

'use client' 指令

tsx
// components/Counter.tsx
'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        增加
      </button>
    </div>
  )
}

使用 React Hooks

tsx
'use client'

import { useState, useEffect } from 'react'

export default function UserProfile() {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  
  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        setUser(data)
        setLoading(false)
      })
  }, [])
  
  if (loading) return <div>加载中...</div>
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

📝 数据获取

使用 useEffect

tsx
'use client'

import { useState, useEffect } from 'react'

export default function Posts() {
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  
  useEffect(() => {
    fetch('https://api.example.com/posts')
      .then(res => {
        if (!res.ok) throw new Error('获取失败')
        return res.json()
      })
      .then(data => {
        setPosts(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }, [])
  
  if (loading) return <div>加载中...</div>
  if (error) return <div>错误: {error}</div>
  
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

使用 SWR

tsx
'use client'

import useSWR from 'swr'

const fetcher = (url: string) => fetch(url).then(res => res.json())

export default function Posts() {
  const { data, error, isLoading } = useSWR(
    'https://api.example.com/posts',
    fetcher
  )
  
  if (isLoading) return <div>加载中...</div>
  if (error) return <div>加载失败</div>
  
  return (
    <div>
      {data.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

使用 React Query

tsx
'use client'

import { useQuery } from '@tanstack/react-query'

export default function Posts() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await fetch('https://api.example.com/posts')
      return res.json()
    }
  })
  
  if (isLoading) return <div>加载中...</div>
  if (error) return <div>错误: {error.message}</div>
  
  return (
    <div>
      {data.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

🎨 事件处理

基本事件

tsx
'use client'

export default function Form() {
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    console.log('表单提交')
  }
  
  const handleClick = () => {
    console.log('按钮点击')
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" />
      <button type="submit">提交</button>
      <button type="button" onClick={handleClick}>
        取消
      </button>
    </form>
  )
}

表单处理

tsx
'use client'

import { useState } from 'react'

export default function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  })
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    })
  }
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    
    const res = await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData)
    })
    
    if (res.ok) {
      alert('发送成功')
      setFormData({ name: '', email: '', message: '' })
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={formData.name}
        onChange={handleChange}
        placeholder="姓名"
      />
      <input
        name="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="邮箱"
      />
      <textarea
        name="message"
        value={formData.message}
        onChange={handleChange}
        placeholder="消息"
      />
      <button type="submit">发送</button>
    </form>
  )
}

🔄 状态管理

本地状态

tsx
'use client'

import { useState } from 'react'

export default function TodoList() {
  const [todos, setTodos] = useState([])
  const [input, setInput] = useState('')
  
  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, { id: Date.now(), text: input }])
      setInput('')
    }
  }
  
  const removeTodo = (id: number) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }
  
  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && addTodo()}
      />
      <button onClick={addTodo}>添加</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.text}
            <button onClick={() => removeTodo(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

Context API

tsx
'use client'

import { createContext, useContext, useState } from 'react'

const ThemeContext = createContext(null)

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  return useContext(ThemeContext)
}

// 使用
export default function ThemeToggle() {
  const { theme, setTheme } = useTheme()
  
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      切换到 {theme === 'light' ? '暗色' : '亮色'} 模式
    </button>
  )
}

📊 实战示例

实时搜索

tsx
'use client'

import { useState, useEffect } from 'react'
import { useDebounce } from '@/hooks/useDebounce'

export default function Search() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)
  
  const debouncedQuery = useDebounce(query, 500)
  
  useEffect(() => {
    if (debouncedQuery) {
      setLoading(true)
      fetch(`/api/search?q=${debouncedQuery}`)
        .then(res => res.json())
        .then(data => {
          setResults(data)
          setLoading(false)
        })
    } else {
      setResults([])
    }
  }, [debouncedQuery])
  
  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
      />
      {loading && <div>搜索中...</div>}
      <ul>
        {results.map(result => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  )
}

无限滚动

tsx
'use client'

import { useState, useEffect, useRef } from 'react'

export default function InfiniteScroll() {
  const [items, setItems] = useState([])
  const [page, setPage] = useState(1)
  const [loading, setLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)
  const observerRef = useRef(null)
  
  useEffect(() => {
    loadMore()
  }, [page])
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !loading) {
          setPage(prev => prev + 1)
        }
      },
      { threshold: 1.0 }
    )
    
    if (observerRef.current) {
      observer.observe(observerRef.current)
    }
    
    return () => observer.disconnect()
  }, [hasMore, loading])
  
  const loadMore = async () => {
    setLoading(true)
    const res = await fetch(`/api/items?page=${page}`)
    const data = await res.json()
    
    setItems(prev => [...prev, ...data.items])
    setHasMore(data.hasMore)
    setLoading(false)
  }
  
  return (
    <div>
      {items.map(item => (
        <div key={item.id}>{item.title}</div>
      ))}
      {loading && <div>加载中...</div>}
      <div ref={observerRef} />
    </div>
  )
}

实时聊天

tsx
'use client'

import { useState, useEffect, useRef } from 'react'

export default function Chat() {
  const [messages, setMessages] = useState([])
  const [input, setInput] = useState('')
  const wsRef = useRef(null)
  
  useEffect(() => {
    // 连接 WebSocket
    wsRef.current = new WebSocket('ws://localhost:3001')
    
    wsRef.current.onmessage = (event) => {
      const message = JSON.parse(event.data)
      setMessages(prev => [...prev, message])
    }
    
    return () => {
      wsRef.current?.close()
    }
  }, [])
  
  const sendMessage = () => {
    if (input.trim() && wsRef.current) {
      wsRef.current.send(JSON.stringify({
        text: input,
        timestamp: Date.now()
      }))
      setInput('')
    }
  }
  
  return (
    <div>
      <div className="messages">
        {messages.map((msg, i) => (
          <div key={i}>{msg.text}</div>
        ))}
      </div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
      />
      <button onClick={sendMessage}>发送</button>
    </div>
  )
}

📚 最佳实践

1. 混合使用服务端和客户端组件

tsx
// app/page.tsx (服务端组件)
import ClientComponent from './ClientComponent'

async function getData() {
  const res = await fetch('https://api.example.com/data')
  return res.json()
}

export default async function Page() {
  const data = await getData()
  
  return (
    <div>
      <h1>{data.title}</h1>
      <ClientComponent initialData={data} />
    </div>
  )
}

// ClientComponent.tsx (客户端组件)
'use client'

export default function ClientComponent({ initialData }) {
  const [data, setData] = useState(initialData)
  
  return <div>{/* 交互式内容 */}</div>
}

2. 优化性能

tsx
'use client'

import { memo, useMemo, useCallback } from 'react'

const ExpensiveComponent = memo(({ data }) => {
  const processedData = useMemo(() => {
    return data.map(item => /* 复杂计算 */)
  }, [data])
  
  const handleClick = useCallback(() => {
    // 处理点击
  }, [])
  
  return <div onClick={handleClick}>{/* ... */}</div>
})

3. 错误边界

tsx
'use client'

import { Component } from 'react'

class ErrorBoundary extends Component {
  state = { hasError: false }
  
  static getDerivedStateFromError() {
    return { hasError: true }
  }
  
  render() {
    if (this.state.hasError) {
      return <div>出错了</div>
    }
    return this.props.children
  }
}

🔗 相关资源


下一步:学习 Next.js 流式渲染