用 React Suspense 优化加载体验:从骨架屏到并发模式实战

By | 2026年10月13日

为什么需要 Suspense?

在传统 React 应用中,我们通常用 useEffect + useState 来管理异步数据加载,然后手动维护 loading 状态。这种方式虽然可行,但会导致组件逻辑臃肿,并且多个异步请求容易产生“瀑布式”加载,用户体验差。

React 18 正式引入了并发模式(Concurrent Mode),其中 Suspense 成为声明式处理异步加载的核心方案。它允许组件“暂停”渲染,直到所需数据就绪,同时由上层 统一展示加载状态。这不仅简化了代码,还能配合 startTransition 和 useDeferredValue 实现更流畅的交互。

本文将通过一个用户列表页面的实战,带你一步步用 Suspense 优化加载体验,并分享一些容易被忽略的最佳实践。

环境准备

确保你的项目使用 React 18 或更高版本,并启用了并发特性。如果你使用 Create React App,需要升级到 v5;或者直接使用 Vite 创建项目:


npm create vite@latest my-suspense-app -- --template react
cd my-suspense-app
npm install
npm run dev

然后安装一个用于模拟数据请求的库(也可以自己写一个简单的 fetch 封装):


npm install axios

第一步:创建一个可挂起的资源(Resource)

Suspense 的核心是“当组件读取一个尚未准备好的数据时,抛出一个 Promise”。React 会捕获这个 Promise,并显示 fallback,直到 Promise 完成。

我们封装一个简单的 wrapPromise 函数,它接收一个 Promise,并返回一个对象,该对象有一个 read() 方法:


// utils/wrapPromise.js
export function wrapPromise(promise) {
  let status = 'pending';
  let result;
  let suspender = promise.then(
    (r) => {
      status = 'success';
      result = r;
    },
    (e) => {
      status = 'error';
      result = e;
    }
  );

  return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw result;
      } else if (status === 'success') {
        return result;
      }
    },
  };
}

这个函数是 Suspense 的基石。注意:不要在生产环境直接使用这个简易版本,因为它没有缓存,每次调用都会重新请求。后面我们会引入缓存机制。

第二步:创建数据获取函数并缓存资源

为了避免重复请求,我们需要一个缓存。这里用 Map 简单实现:


// utils/fetchData.js
import axios from 'axios';
import { wrapPromise } from './wrapPromise';

const cache = new Map();

export function fetchUserList() {
  const key = 'userList';
  if (cache.has(key)) {
    return cache.get(key);
  }

  const promise = axios.get('https://jsonplaceholder.typicode.com/users')
    .then(res => res.data);

  const resource = wrapPromise(promise);
  cache.set(key, resource);
  return resource;
}

这里我们请求了 JSONPlaceholder 的免费 API。注意缓存是全局的,适合演示;实际项目中你可能需要根据参数做缓存键。

第三步:编写挂起组件

创建一个 UserList 组件,它直接调用 resource.read() 来获取数据。当数据未就绪时,read() 会抛出 Promise,触发 Suspense。


// components/UserList.jsx
import React from 'react';
import { fetchUserList } from '../utils/fetchData';

const resource = fetchUserList();

export default function UserList() {
  const users = resource.read();

  return (
    <ul className="user-list">
      {users.map(user => (
        <li key={user.id}>
          <strong>{user.name}</strong> - {user.email}
        </li>
      ))}
    </ul>
  );
}

注意:resource 在模块顶层创建,这样它只会在模块加载时请求一次。如果你在组件内部调用 fetchUserList(),每次渲染都会创建新的资源,导致无限循环。

第四步:用 Suspense 包裹并添加骨架屏

现在在父组件中使用 ,并提供一个加载中的 fallback。为了更好的体验,我们做一个骨架屏组件。


// components/UserListSkeleton.jsx
import React from 'react';

export default function UserListSkeleton() {
  return (
    <ul className="user-list skeleton">
      {[...Array(5)].map((_, i) => (
        <li key={i}>
          <div className="skeleton-line" style={{ width: '60%' }} />
          <div className="skeleton-line" style={{ width: '40%' }} />
        </li>
      ))}
    </ul>
  );
}

然后在 App 中:


// App.jsx
import React, { Suspense } from 'react';
import UserList from './components/UserList';
import UserListSkeleton from './components/UserListSkeleton';
import './App.css';

function App() {
  return (
    <div className="App">
      <h1>用户列表</h1>
      <Suspense fallback={<UserListSkeleton />}>
        <UserList />
      </Suspense>
    </div>
  );
}

export default App;

添加一些简单的 CSS 让骨架屏有闪烁效果:


/* App.css */
.user-list {
  list-style: none;
  padding: 0;
}
.user-list li {
  padding: 12px;
  border-bottom: 1px solid #eee;
}
.skeleton-line {
  height: 16px;
  background: #e0e0e0;
  margin: 8px 0;
  border-radius: 4px;
  animation: pulse 1.5s infinite ease-in-out;
}
@keyframes pulse {
  0% { opacity: 0.6; }
  50% { opacity: 1; }
  100% { opacity: 0.6; }
}

现在运行应用,你会看到骨架屏,然后数据加载完成后替换为真实列表。

第五步:处理错误与重试

Suspense 本身不处理错误,需要配合 Error Boundary。我们创建一个简单的错误边界组件,并提供重试按钮。


// components/ErrorBoundary.jsx
import React from 'react';

class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    console.error('ErrorBoundary caught:', error, info);
  }

  handleRetry = () => {
    // 清除缓存并重新加载页面(简单粗暴,但有效)
    window.location.reload();
  };

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-box">
          <p>加载失败:{this.state.error.message}</p>
          <button onClick={this.handleRetry}>重试</button>
        </div>
      );
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

然后在 App 中包裹 Suspense:


<ErrorBoundary>
  <Suspense fallback={<UserListSkeleton />}>
    <UserList />
  </Suspense>
</ErrorBoundary>

注意:重试时最好清除缓存,否则会再次抛出同样的错误。更优雅的做法是让资源本身支持重置。

第六步:使用 startTransition 避免不必要的加载态

当你在一个已经展示内容的页面上切换数据时,如果直接更新 state,Suspense 会立刻显示 fallback,导致内容闪烁。React 18 的 startTransition 可以标记非紧急更新,让 Suspense 在后台等待新数据,同时保持旧内容可见。

假设我们有一个按钮切换用户列表和文章列表:


import React, { Suspense, useState, startTransition } from 'react';
import UserList from './components/UserList';
import PostList from './components/PostList';
import UserListSkeleton from './components/UserListSkeleton';

function App() {
  const [tab, setTab] = useState('users');

  const handleTabChange = (newTab) => {
    startTransition(() => {
      setTab(newTab);
    });
  };

  return (
    <div className="App">
      <div className="tabs">
        <button onClick={() => handleTabChange('users')}>用户</button>
        <button onClick={() => handleTabChange('posts')}>文章</button>
      </div>
      <Suspense fallback={<UserListSkeleton />}>
        {tab === 'users' ? <UserList /> : <PostList />}
      </Suspense>
    </div>
  );
}

这样切换时,如果新数据未加载完,旧列表会继续显示,直到新数据就绪。这大大提升了感知性能。

第七步:结合 useDeferredValue 优化搜索过滤

另一个常见场景是搜索过滤大量数据。如果每次输入都触发重渲染,可能会卡顿。useDeferredValue 可以延迟更新非紧急部分。


import React, { useState, useDeferredValue } from 'react';

function SearchList({ items }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  const filtered = items.filter(item =>
    item.name.toLowerCase().includes(deferredQuery.toLowerCase())
  );

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="搜索..."
      />
      <ul>
        {filtered.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

注意 useDeferredValue 不是防抖,它只是让 React 在空闲时更新,适合计算量大的渲染。

常见坑与最佳实践

💡 资源缓存:务必缓存资源,避免每次渲染都重新请求。可以使用 Map 或更专业的库如 react-query、swr,它们内部已经集成了 Suspense 支持。

⚠️ 不要过度使用 Suspense:对于简单的、快速的请求,直接使用 useEffect 可能更简单。Suspense 最适合那些可能较慢、需要占位符的请求。

💡 嵌套 Suspense:可以在不同层级放置 Suspense,实现细粒度的加载状态。例如,页面整体一个 Suspense,每个卡片再包一个。

⚠️ 避免在 Suspense 内部使用 useEffect 获取数据:这会导致双重加载,应该直接在渲染时读取资源。

💡 服务端渲染(SSR):React 18 的 renderToPipeableStream 支持 Suspense,可以流式传输 HTML,进一步提升首屏速度。

总结

通过本文,你学会了如何用 React Suspense 声明式地处理异步加载,包括:

  • 创建可挂起的资源
  • 使用 Suspense 和骨架屏
  • 错误边界与重试
  • 利用 startTransition 和 useDeferredValue 优化交互

Suspense 不仅简化了代码,还让加载体验更加流畅。建议在实际项目中结合 react-query 或 swr 等库,它们已经很好地集成了 Suspense 模式。下一步,你可以探索 React Server Components 与 Suspense 的结合,那将是未来的趋势。

延伸阅读

  • React 官方文档:Concurrent Mode 与 Suspense
  • react-query 的 Suspense 模式
  • 使用 useTransition 实现搜索优化