Skip to main content
frontend2025年12月21日4 分钟阅读

扼杀你的 React 应用的 5 个性能错误

大多数 React 应用都比它们应有的速度慢。以下是我在生产代码库中看到的 5 个最常见的性能错误——以及如何修复它们。

reactperformancenextjs
扼杀你的 React 应用的 5 个性能错误

多年来,我审阅了数百个 React 代码库。客户端项目、开源仓库、内部工具、初创公司 MVP。同样的性能问题反复出现。它们不是奇特的边缘情况——而是基本、可预防的错误,这些错误会累积起来,直到应用变得迟钝,用户开始流失。

以下是我最常看到的五个问题,以及你可以立即应用的实际修复方案。

1. 缺少 Memoization 导致的非必要重新渲染

这是 React 应用中最常见的性能问题。父组件重新渲染时,其所有子组件也会随之重新渲染——即使它们的 props 没有改变。将这种情况乘以一个包含数十个列表项的深层组件树,你就会得到一个在每次按键时都会卡顿的应用。

问题:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = calculateStats(user.transactions);

  return (
    <div>
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <StatsPanel stats={stats} />
      <TransactionList transactions={user.transactions} />
    </div>
  );
}

每次 searchQuery 改变时,calculateStats 都会再次运行——即使 user.transactions 没有改变。StatsPanelTransactionList 也会无故重新渲染。

修复:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = useMemo(
    () => calculateStats(user.transactions),
    [user.transactions]
  );

  const handleSearchChange = useCallback(
    (value: string) => setSearchQuery(value),
    []
  );

  return (
    <div>
      <SearchBar value={searchQuery} onChange={handleSearchChange} />
      <MemoizedStatsPanel stats={stats} />
      <MemoizedTransactionList transactions={user.transactions} />
    </div>
  );
}

const MemoizedStatsPanel = memo(StatsPanel);
const MemoizedTransactionList = memo(TransactionList);

这里发生了三件事:

  1. useMemo 阻止 calculateStats 在每次渲染时都运行。它只在 user.transactions 实际改变时才重新计算。
  2. useCallback 稳定了变更处理函数的引用,这样 SearchBar 就不会在每次渲染时都看到一个“新”函数。
  3. memo 包装了子组件,这样当它们的 props 浅层相等时,它们就会跳过重新渲染。

友情提示:不要盲目地将每个组件都用 memo 包装。首先使用 React DevTools Profiler 进行性能分析。Memoization 是有成本的——即浅层比较本身。将其应用于重要的地方:昂贵的计算、大型列表以及那些 props 未变但频繁重新渲染的组件。

2. 零代码分割导致的臃肿打包

我经常看到 React 应用将所有内容打包在一个 JavaScript 包中。管理仪表盘、设置页面、不常访问的帮助中心——所有这些都在首次页面访问时预先加载。用户只想看一个着陆页,但他们的浏览器却下载了 2 MB 暂时不需要的 JavaScript。

问题:

import AdminDashboard from "./pages/AdminDashboard";
import Settings from "./pages/Settings";
import HelpCenter from "./pages/HelpCenter";
import Landing from "./pages/Landing";

function App() {
  return (
    <Routes>
      <Route path="/" element={<Landing />} />
      <Route path="/admin" element={<AdminDashboard />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/help" element={<HelpCenter />} />
    </Routes>
  );
}

静态导入意味着打包器将所有内容都包含在一个 chunk 中。无论用户访问哪个路由,他们都会下载所有内容。

修复:

import { lazy, Suspense } from "react";

const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const HelpCenter = lazy(() => import("./pages/HelpCenter"));
import Landing from "./pages/Landing"; // Keep critical path static

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Landing />} />
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/help" element={<HelpCenter />} />
      </Routes>
    </Suspense>
  );
}

React.lazy 结合动态 import() 告诉打包器将这些页面分割成单独的 chunk。当用户导航到该路由时,它们会按需加载。你的初始包会保持较小,并且你的可交互时间(Time to Interactive)会显著下降。

如果你正在使用 Next.js,App Router 会免费为你提供基于路由的代码分割。但对于大型第三方库,你仍然需要有意识地进行组件级分割

import dynamic from "next/dynamic";

const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
  loading: () => <EditorSkeleton />,
  ssr: false,
});

这对于富文本编辑器、图表库或 PDF 渲染器等库至关重要,它们很容易为你的包增加 200-500 KB 的大小。

3. 状态放置在组件树过高位置

React 的渲染模型是自上而下的。当一个组件的状态改变时,它会重新渲染——它的整个子树也会随之重新渲染。当你“为了方便”将状态提升到树的顶部时,你实际上是在告诉 React 在每次状态改变时重新渲染其下方的一切。

问题:

function App() {
  const [selectedFilter, setSelectedFilter] = useState("all");
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <Layout>
      <Header />
      <Sidebar filter={selectedFilter} onFilterChange={setSelectedFilter} />
      <MainContent>
        <SearchBar query={searchQuery} onChange={setSearchQuery} />
        <ProductList filter={selectedFilter} />
        <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
      </MainContent>
      <Footer />
    </Layout>
  );
}

在搜索栏中输入会触发 App 中的状态更新。这会重新渲染 HeaderSidebarFooterProductListModal——而它们都不关心搜索查询。

修复:

将状态下推到实际使用它的地方:

function App() {
  return (
    <Layout>
      <Header />
      <SidebarWithFilter />
      <MainContent>
        <SearchSection />
        <ProductList />
      </MainContent>
      <Footer />
    </Layout>
  );
}

function SearchSection() {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <SearchResults query={searchQuery} />
    </div>
  );
}

现在,在搜索栏中输入只会重新渲染 SearchSection 及其子组件。其他一切都保持不变。

原则很简单:状态应尽可能靠近读取它的组件。 如果两个兄弟组件需要相同的状态,将其提升到它们最近的共同父组件——但不要再高了。对于真正的全局状态(认证、主题、语言环境),请使用支持选择性订阅的状态管理库,例如 Zustand 或 Jotai,这样只有当特定状态切片改变时,消费该切片的组件才会重新渲染。

4. Next.js 中未优化的图片

图片通常是网页上最重的资源。我见过一些 Next.js 应用,开发者使用普通的 <img> 标签加载巨大的、未压缩的 PNG 图片。结果是:页面加载 8 MB 的图片,零懒加载,无响应式尺寸,以及一个让 Lighthouse 哭泣的最大内容绘制时间(Largest Contentful Paint)。

问题:

function ProjectCard({ project }) {
  return (
    <div className="card">
      <img src={`/images/${project.cover}`} alt={project.title} />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

这会以原始尺寸提供全分辨率图片,无论用户的视口或设备如何。没有格式转换,没有懒加载,没有响应式 srcset。

修复:

import Image from "next/image";

function ProjectCard({ project }) {
  return (
    <div className="card">
      <Image
        src={`/images/${project.cover}`}
        alt={project.title}
        width={600}
        height={400}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        placeholder="blur"
        blurDataURL={project.blurHash}
      />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

Next.js 的 Image 组件会自动为你提供以下几点:

  • 自动格式转换:根据浏览器支持转换为 WebP 或 AVIF
  • 响应式尺寸:通过 sizes 属性——浏览器只下载它需要的尺寸
  • 默认懒加载:折叠下方的图片直到用户滚动到它们附近才会被获取
  • 模糊占位符:在完整图片加载时显示轻量级预览,消除布局偏移

对于首屏图片(英雄横幅、主要内容),添加 priority 以禁用懒加载并预加载它们:

<Image src={heroImage} alt="Hero" priority sizes="100vw" />

仅此一项更改就可以将页面权重减少 60-80%,并显著提高你的核心 Web Vitals 分数。

5. 使用繁重计算阻塞主线程

浏览器的主要线程处理渲染、用户输入、动画和 JavaScript 执行——所有这些都在一个单线程上。当你同步运行繁重计算时,其他一切都会冻结。用户点击一个按钮,500 毫秒内没有任何反应。动画卡顿。滚动停止响应。

问题:

function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);

  useEffect(() => {
    // This blocks the main thread for 200-800ms
    const result = processLargeDataset(rawData); // Sorting, filtering, aggregating 50k+ rows
    setProcessedData(result);
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

processLargeDataset 在主线程上同步运行。如果它需要 400 毫秒,那么整个 UI 就会冻结 400 毫秒。用户无法滚动、输入或与任何东西交互。

修复——将其移至 Web Worker:

// workers/dataProcessor.ts
self.onmessage = (event: MessageEvent) => {
  const rawData = event.data;
  const result = processLargeDataset(rawData);
  self.postMessage(result);
};
function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/dataProcessor.ts", import.meta.url)
    );

    workerRef.current.onmessage = (event: MessageEvent) => {
      setProcessedData(event.data);
    };

    workerRef.current.postMessage(rawData);

    return () => {
      workerRef.current?.terminate();
    };
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

繁重计算现在在单独的线程上运行。主线程保持空闲,可以处理用户交互、动画和渲染。UI 在整个过程中保持响应。

对于计算时间为 50-150 毫秒的非极端情况,你可以使用 requestIdleCallbackscheduler.yield() 将工作分解成更小的块,而不是启动一个完整的 Worker。但对于任何处理大型数据集、生成报告或运行复杂算法的任务,Web Workers 不是可选的——它们是必需的。

复合效应

这些错误中的任何一个都不会单独导致你的应用崩溃。这正是它们危险的地方。每个错误都会增加 100-300 毫秒的延迟,用户体验到的是总和。一个不必要的重新渲染的组件加上一个臃肿的包,再加上未优化的图片,再加上一个主线程计算,意味着你的应用需要 4 秒才能变得可交互,而不是 1 秒。

优化前先进行性能分析。 使用 React DevTools Profiler 查找重新渲染热点。使用 Lighthouse 和 Web Vitals 衡量真实用户影响。使用浏览器 Network 标签页捕获过大的包和图片。使用 Performance 标签页识别阻塞主线程的长时间任务。

这些是我在构建的每个项目中都会应用的模式——从餐厅平台到医疗 SaaS 系统。性能不是你稍后添加的功能。它是你从第一次提交开始就应该维护的质量。

DU

Danil Ulmashev

Full Stack Developer

有兴趣一起合作吗?