Skip to content

React 进阶学习 ​

本文记录了在React开发过程中遇到的一些进阶知识点和实践经验,涵盖错误边界、性能优化、状态管理等多个方面,持续更新中...

🛡️ ErrorBoundary 错误边界 ​

什么是 ErrorBoundary? ​

在现代 React 应用开发中,错误处理是一个不可忽视的重要环节。一个未处理的 JavaScript 错误可能导致整个应用崩溃,给用户带来极差的体验。React 16 引入的 ErrorBoundary(错误边界) 机制,为我们提供了一种优雅的错误处理方案。

ErrorBoundary 是 React 组件中的一种特殊组件,它可以捕获其子组件树中任何地方的 JavaScript 错误,并记录这些错误,同时显示降级的 UI 而不是让整个组件树崩溃。

简单来说,ErrorBoundary 就像是 JavaScript 的 try-catch 语句,但它专门用于 React 组件。

ErrorBoundary 的核心规则 ​

React 的 ErrorBoundary 有一些严格的"硬规则",这些规则决定了大部分实现都采用相似的固定写法:

1. 🔧 必须使用 Class 组件 ​

ErrorBoundary 必须是 class 组件,因为函数组件无法实现错误捕获所需的生命周期方法:

  • componentDidCatch
  • getDerivedStateFromError

2. ⚙️ 必须实现特定的生命周期方法 ​

至少要实现以下两个生命周期方法之一:

方法作用调用时机
static getDerivedStateFromError(error)更新 state,触发 fallback UI错误发生时,用于渲染降级 UI
componentDidCatch(error, errorInfo)记录错误信息,上报监控render() 之后执行,适合日志记录

3. 🔄 错误处理流程 ​

ErrorBoundary 的工作流程如下:

mermaid
graph TD
    A[组件渲染] --> B{是否发生错误?}
    B -->|否| C[正常渲染 children]
    B -->|是| D[React 捕获错误]
    D --> E[调用 getDerivedStateFromError]
    E --> F[更新 state.hasError = true]
    F --> G[重新调用 render()]
    G --> H[渲染 fallback UI]
    H --> I[调用 componentDidCatch]
    I --> J[记录错误日志]

正常流程:

  • React 调用 render() → this.state.hasError = false → 返回 this.props.children

错误流程:

  1. React 捕获错误,找到最近的 ErrorBoundary
  2. 调用 static getDerivedStateFromError(error),更新 state
  3. React 重新调用 render() → 返回 this.props.fallback(错误 UI)
  4. 调用 componentDidCatch,执行错误记录和上报

💻 完整的 ErrorBoundary 实现 ​

基于上述核心规则,几乎所有 ErrorBoundary 的实现都遵循相同的模式。以下是一个功能完整、生产就绪的 ErrorBoundary 组件:

typescript
/**
 * 🛡️ ErrorBoundary 错误边界组件
 * 
 * 功能特性:
 * - 捕获子组件中的 JavaScript 错误,防止整个应用崩溃
 * - 提供友好的错误提示和恢复机制
 * - 支持自定义 fallback UI 和错误处理回调
 * - 开发环境下显示详细错误信息,生产环境下上报错误
 */

import { Component, ReactNode } from 'react'

interface Props {
  children: ReactNode
  fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
}

interface State {
  hasError: boolean
  error?: Error
}

export default class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { hasError: false }
  }

  /**
   * 📋 静态方法:从错误中派生状态
   * 当子组件抛出错误时,React 会调用此方法
   * 返回新的 state 来触发错误 UI 的渲染
   */
  static getDerivedStateFromError(error: Error): State {
    // 更新状态,下次渲染将显示错误 UI
    return { hasError: true, error }
  }

  /**
   * 🔍 生命周期方法:捕获并处理错误
   * 在错误边界捕获到错误后调用,适合进行错误记录和上报
   */
  componentDidCatch(error: Error, errorInfo: any) {
    // 控制台记录错误详情
    console.error('🚨 ErrorBoundary caught an error:', error, errorInfo)

    // 生产环境下,将错误信息发送到监控服务
    if (import.meta.env.PROD) {
      this.reportErrorToService(error, errorInfo)
    }
  }

  /**
   * 📊 错误上报方法
   * 将错误信息发送到监控服务(如 Sentry、LogRocket 等)
   */
  private reportErrorToService(error: Error, errorInfo: any) {
    // 示例:发送到错误监控服务
    // Sentry.captureException(error, { contexts: { react: errorInfo } })
    
    // 或者发送到自定义的错误收集 API
    fetch('/api/error-report', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        message: error.message,
        stack: error.stack,
        componentStack: errorInfo.componentStack,
        timestamp: new Date().toISOString(),
        userAgent: navigator.userAgent,
        url: window.location.href
      })
    }).catch(reportError => {
      console.error('Failed to report error:', reportError)
    })
  }

  /**
   * 🔄 重试处理方法
   * 重置错误状态,尝试重新渲染组件
   */
  private handleRetry = () => {
    this.setState({ hasError: false, error: undefined })
  }

  render() {
    // 如果发生错误,渲染错误 UI
    if (this.state.hasError) {
      // 支持函数形式的 fallback,可以接收错误信息和重试方法
      if (typeof this.props.fallback === 'function') {
        return this.props.fallback(this.state.error!, this.handleRetry)
      }
      
      // 支持自定义的 fallback 组件
      if (this.props.fallback) {
        return this.props.fallback
      }

      // 默认的错误 UI - 美观且用户友好
      return (
        <div className="min-h-screen flex items-center justify-center bg-gray-50">
          <div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
            {/* 错误图标 */}
            <div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full mb-4">
              <svg
                className="w-6 h-6 text-red-600"
                fill="none"
                stroke="currentColor"
                viewBox="0 0 24 24"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth={2}
                  d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
                />
              </svg>
            </div>

            <div className="text-center">
              <h1 className="text-lg font-semibold text-gray-900 mb-2">
                🚫 出现了一些问题
              </h1>
              <p className="text-sm text-gray-600 mb-6">
                应用遇到了意外错误,请尝试刷新页面或联系技术支持。
              </p>

              {/* 开发环境下显示错误详情 */}
              {import.meta.env.DEV && this.state.error && (
                <details className="text-left mb-4 p-3 bg-gray-100 rounded text-xs">
                  <summary className="cursor-pointer font-medium text-gray-700">
                    🔍 错误详情 (开发模式)
                  </summary>
                  <pre className="mt-2 text-red-600 whitespace-pre-wrap">
                    {this.state.error.message}
                    {'\n'}
                    {this.state.error.stack}
                  </pre>
                </details>
              )}

              {/* 操作按钮 */}
              <div className="flex gap-3 justify-center">
                <button
                  onClick={this.handleRetry}
                  className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
                >
                  🔄 重试
                </button>
                <button
                  onClick={() => window.location.reload()}
                  className="px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors"
                >
                  🔃 刷新页面
                </button>
              </div>
            </div>
          </div>
        </div>
      )
    }

    // 正常情况下,渲染子组件
    return this.props.children
  }
}

🏗️ 实际应用场景 ​

1. 全局错误边界 ​

通常在应用的最外层包裹一个 ErrorBoundary,作为最后的安全网:

typescript
// App.tsx
function App() {
  return (
    <ErrorBoundary>
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Router>
    </ErrorBoundary>
  )
}

2. 局部错误边界 ​

对于重要或容易出错的组件,可以单独包裹 ErrorBoundary:

typescript
// Dashboard.tsx
function Dashboard() {
  return (
    <div>
      <Header />
      <ErrorBoundary fallback={<ChartErrorFallback />}>
        <ComplexChart />
      </ErrorBoundary>
      <ErrorBoundary fallback={<TableErrorFallback />}>
        <DataTable />
      </ErrorBoundary>
    </div>
  )
}

⚠️ ErrorBoundary 的能力边界 ​

了解 ErrorBoundary 能捕获和不能捕获的错误类型,对于正确使用它至关重要:

✅ 能够捕获的错误 ​

错误类型描述示例
渲染期间的错误组件 render 方法中的 JavaScript 错误undefined.property
生命周期方法错误类组件生命周期方法中的错误componentDidMount 中的异常
构造函数错误整个组件树构造函数中的错误constructor 中的初始化错误

❌ 无法捕获的错误 ​

错误类型描述解决方案
事件处理器错误点击、输入等事件回调中的错误使用 try-catch
异步代码错误setTimeout、Promise、async/await使用 try-catch 或 .catch()
服务端渲染错误SSR 过程中的错误服务端错误处理机制
ErrorBoundary 自身错误错误边界组件内部的错误上级 ErrorBoundary

🛠️ 异步错误处理示例 ​

typescript
// ❌ ErrorBoundary 无法捕获
const handleClick = async () => {
  const data = await fetchData() // 如果这里出错,ErrorBoundary 捕获不到
}

// ✅ 正确的异步错误处理
const handleClick = async () => {
  try {
    const data = await fetchData()
    setData(data)
  } catch (error) {
    console.error('Fetch failed:', error)
    setError(error.message)
  }
}

⏳ Suspense 异步组件处理 ​

核心概念 ​

Suspense 是 React 16.6 引入的组件,用于处理异步操作的加载状态。它让我们可以声明式地处理组件的"等待"状态,而不需要手动管理 loading 状态。

常见用法 ​

1. use hook (React 18+) ​

jsx
import { use, Suspense } from 'react'

// 创建 Promise
const userPromise = fetch('/api/user').then(res => res.json())

function UserProfile() {
  // use hook 直接消费 Promise
  const user = use(userPromise)
  
  return <div>Hello, {user.name}</div>
}

// 使用时必须包裹 Suspense
function UserPage() {
  return (
    <Suspense fallback={<div>加载用户信息...</div>}>
      <UserProfile />
    </Suspense>
  )
}

2. 动态 Promise 处理 ​

jsx
function UserProfile({ userId }) {
  // 根据 props 动态创建 Promise
  const userPromise = useMemo(() => 
    fetch(`/api/users/${userId}`).then(res => res.json()), 
    [userId]
  )
  
  const user = use(userPromise)
  return <div>Hello, {user.name}</div>
}

3. 代码分割 (最常见) ​

jsx
import { lazy, Suspense } from 'react'

const Dashboard = lazy(() => import('../pages/Dashboard'))

function App() {
  return (
    <Routes>
      <Route path="/dashboard" element={
        <Suspense fallback={<PageSkeleton />}>
          <Dashboard />
        </Suspense>
      } />
    </Routes>
  )
}

🔄 与传统方式对比 ​

❌ 传统的 Loading 处理 ​

jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [userId])

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
  return <div>Hello, {user.name}</div>
}

✅ Suspense + 数据获取 ​

jsx
// 使用 Suspense 配合数据获取库(如 SWR、React Query)
function UserProfile({ userId }) {
  const { data: user } = useSWR(`/api/users/${userId}`, fetcher, {
    suspense: true // 启用 Suspense 模式
  })
  
  return <div>Hello, {user.name}</div> // 无需处理 loading 状态
}

function App() {
  return (
    <ErrorBoundary fallback={<ErrorUI />}>
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile userId="123" />
      </Suspense>
    </ErrorBoundary>
  )
}

🎯 核心优势 ​

对比维度传统方式Suspense 方式
状态管理手动管理 loading/error 状态自动处理,组件专注业务逻辑
代码复杂度每个组件都需要重复的状态逻辑声明式,逻辑集中
用户体验各组件独立 loading,体验割裂统一的 loading 边界,体验连贯
错误处理需要在每个组件处理错误配合 ErrorBoundary 统一处理

🚀 实际项目应用场景 ​

1. 列表页面的数据加载 ​

jsx
// ❌ 传统方式 - 每个组件管理自己的 loading
function UserList() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  
  useEffect(() => {
    fetchUsers().then(data => {
      setUsers(data)
      setLoading(false)
    })
  }, [])
  
  if (loading) return <div>加载中...</div>
  return users.map(user => <UserCard key={user.id} user={user} />)
}

function UserCard({ user }) {
  const [profile, setProfile] = useState(null)
  const [loading, setLoading] = useState(true)
  
  useEffect(() => {
    fetchUserProfile(user.id).then(data => {
      setProfile(data)
      setLoading(false)
    })
  }, [user.id])
  
  if (loading) return <div>加载用户详情...</div>
  return <div>{profile.name} - {profile.role}</div>
}
jsx
// ✅ Suspense 方式 - 声明式处理
function UserList() {
  const usersPromise = useMemo(() => fetchUsers(), [])
  const users = use(usersPromise)
  
  return users.map(user => (
    <Suspense key={user.id} fallback={<UserCardSkeleton />}>
      <UserCard user={user} />
    </Suspense>
  ))
}

function UserCard({ user }) {
  const profilePromise = useMemo(() => fetchUserProfile(user.id), [user.id])
  const profile = use(profilePromise)
  
  return <div>{profile.name} - {profile.role}</div>
}

// 页面组件
function UsersPage() {
  return (
    <div>
      <h1>用户列表</h1>
      <Suspense fallback={<ListSkeleton />}>
        <UserList />
      </Suspense>
    </div>
  )
}

2. 模态框中的异步内容 ​

jsx
// ❌ 传统方式
function UserModal({ userId, open }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(false)
  
  useEffect(() => {
    if (open && userId) {
      setLoading(true)
      fetchUser(userId).then(data => {
        setUser(data)
        setLoading(false)
      })
    }
  }, [open, userId])
  
  return (
    <Modal open={open}>
      {loading ? <Spinner /> : user && <UserDetails user={user} />}
    </Modal>
  )
}
jsx
// ✅ Suspense 方式
function UserModal({ userId, open }) {
  return (
    <Modal open={open}>
      {userId && (
        <Suspense fallback={<ModalSkeleton />}>
          <UserDetails userId={userId} />
        </Suspense>
      )}
    </Modal>
  )
}

function UserDetails({ userId }) {
  const userPromise = useMemo(() => fetchUser(userId), [userId])
  const user = use(userPromise)
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.department}</p>
    </div>
  )
}

3. Tab 切换的懒加载 ​

jsx
// 实际项目中的 Tab 组件
function ProjectTabs({ projectId }) {
  const [activeTab, setActiveTab] = useState('overview')
  
  const tabs = [
    { id: 'overview', label: '概览', component: ProjectOverview },
    { id: 'members', label: '成员', component: ProjectMembers },
    { id: 'settings', label: '设置', component: ProjectSettings }
  ]
  
  return (
    <div>
      <div className="tab-header">
        {tabs.map(tab => (
          <button 
            key={tab.id}
            onClick={() => setActiveTab(tab.id)}
            className={activeTab === tab.id ? 'active' : ''}
          >
            {tab.label}
          </button>
        ))}
      </div>
      
      <div className="tab-content">
        <Suspense fallback={<TabContentSkeleton />}>
          {tabs.find(tab => tab.id === activeTab)?.component && 
            React.createElement(
              tabs.find(tab => tab.id === activeTab).component, 
              { projectId }
            )
          }
        </Suspense>
      </div>
    </div>
  )
}

// 各个 Tab 组件只关注数据展示
function ProjectMembers({ projectId }) {
  const membersPromise = useMemo(() => 
    fetchProjectMembers(projectId), [projectId]
  )
  const members = use(membersPromise)
  
  return (
    <div>
      {members.map(member => (
        <MemberCard key={member.id} member={member} />
      ))}
    </div>
  )
}

⚡ 关键注意事项 ​

1. use hook 的限制 ​

jsx
// ❌ 错误用法 - 不能在条件语句中使用
function UserProfile({ shouldLoad }) {
  if (shouldLoad) {
    const user = use(userPromise) // 违反 Hook 规则
  }
  return null
}

// ✅ 正确用法 - 条件渲染整个组件
function UserPage({ shouldLoad }) {
  return (
    <div>
      {shouldLoad && (
        <Suspense fallback={<UserSkeleton />}>
          <UserProfile />
        </Suspense>
      )}
    </div>
  )
}

2. Promise 缓存问题 ​

jsx
// ❌ 每次渲染都创建新 Promise
function UserProfile({ userId }) {
  const user = use(fetch(`/api/users/${userId}`)) // 会重复请求
  return <div>{user.name}</div>
}

// ✅ 使用 useMemo 缓存 Promise
function UserProfile({ userId }) {
  const userPromise = useMemo(() => 
    fetch(`/api/users/${userId}`).then(res => res.json()), 
    [userId]
  )
  const user = use(userPromise)
  return <div>{user.name}</div>
}