第七章:React 性能优化与高级架构模式 —— 从性能瓶颈突破到复杂应用架构设计


第七章:React 性能优化与高级架构模式

摘要: 欢迎来到本教程的进阶篇章。在前六章,我们掌握了如何“正确地”构建应用。从本章开始,我们将学习如何“卓越地”构建应用。我们将深入 React 的性能优化核心,学习构建在生产环境中稳定、不会轻易崩溃的健壮应用,并掌握多种高级组件设计模式,让您具备架构复杂 UI 系统的能力。本章是您从“能够实现功能”迈向“能够构建高质量、可维护、高性能应用”的关键一跃。


7.1 性能优化:让你的应用快如闪电

我们热爱 React,因为它能让我们通过状态驱动 UI,轻松构建交互丰富的应用。但这种开发的便捷性背后,隐藏着一个默认行为:当一个组件的状态更新时,它会重新渲染,并且默认情况下,它内部渲染的所有子组件也会一同重新渲染

对于一个简单的应用,这毫无问题。但当你的应用变得复杂,组件树层级加深,任何一个顶层组件的微小状态变化(比如切换主题、打开一个模态框),都可能像投入湖面的石子,激起一圈圈不必要的渲染涟漪,最终导致整个应用变得迟钝和卡顿。

本节,我们将化身性能侦探,通过一个实战案例,亲手揪出这些“渲染刺客”,并学习使用 React 提供的三把原生“手术刀”——React.memo, useCallback, useMemo,来精准地“切除”它们。

第一步:暴露问题——亲眼看见“不必要的渲染”

在优化之前,我们必须先学会如何定位问题。让我们搭建一个简单的“仪表盘”应用场景。

1. 创建项目文件结构

首先,在 src/components 目录下,创建一个新的 Dashboard 文件夹,并建立以下文件:

1
2
3
4
5
# src/components/
└── Dashboard/
├── Header.tsx # 头部组件
├── PerformanceReport.tsx # 核心的“性能报告”组件
└── Dashboard.tsx # 仪表盘父组件

2. 编写子组件代码

我们的子组件非常简单,它们唯一的“任务”就是在被 React 重新渲染时,在控制台打印一条日志,让我们能清楚地看到发生了什么。

文件路径: src/components/Dashboard/Header.tsx

1
2
3
4
5
6
7
8
9
10
const Header = () => {
console.log('... 头部(Header)组件正在被渲染 ...')
return (
<header className="bg-gray-800 text-white p-4 rounded-t-lg">
<h1 className="text-xl">我的仪表盘</h1>
</header>
)
}

export default Header

文件路径: src/components/Dashboard/PerformanceReport.tsx

1
2
3
4
5
6
7
8
9
10
11
const PerformanceReport = () => {
console.log('--- 性能报告(PerformanceReport)组件正在被渲染 ---')
return (
<div className="p-4 bg-gray-100">
<h3 className="font-bold">性能报告</h3>
<p className="mt-2">报告内容:一切正常,性能良好。</p>
</div>
)
}

export default PerformanceReport

3. 编写父组件 Dashboard.tsx

父组件将包含一个可以改变的内部状态——theme(主题),以及一个切换主题的按钮。

文件路径: src/components/Dashboard/Dashboard.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { useState } from 'react'
import Header from './Header'
import PerformanceReport from './PerformanceReport'

const Dashboard = () => {
const [theme, setTheme] = useState<'light' | 'dark'>('light')

const toggleTheme = () => {
setTheme((currentTheme) => (currentTheme === 'light' ? 'dark' : 'light'))
}

console.log('仪表盘(Dashboard)父组件正在被渲染')

return (
<div
className={`
m-8 border rounded-lg shadow-lg
${theme === 'light' ? 'bg-white' : 'bg-gray-700'}
`}
>
<Header />
<main className="p-4">
<button onClick={toggleTheme} className="px-4 py-2 bg-blue-500 text-white rounded">
切换主题
</button>
<div className="mt-4">
<PerformanceReport />
</div>
</main>
</div>
)
}

export default Dashboard

4. 在 App.tsx 中使用并观察

修改你的 App.tsx 来渲染这个仪表盘。

文件路径: src/App.tsx

1
2
3
4
5
6
7
8
9
10
import Dashboard from './components/Dashboard/Dashboard'

function App() {
return (
<div className="min-h-screen bg-gray-200">
<Dashboard />
</div>
)
}
export default App

现在,运行你的应用,并打开浏览器控制台。当你反复点击“切换主题”按钮时,你会看到如下输出:

1
2
3
4
5
6
仪表盘(Dashboard)父组件正在被渲染
Dashboard.tsx:12 仪表盘(Dashboard)父组件正在被渲染
Header.tsx:2 ... 头部(Header)组件正在被渲染 ...
Header.tsx:2 ... 头部(Header)组件正在被渲染 ...
PerformanceReport.tsx:2 --- 性能报告(PerformanceReport)组件正在被渲染 ---
PerformanceReport.tsx:2 --- 性能报告(PerformanceReport)组件正在被渲染 ---

这就是我们的痛点! HeaderPerformanceReport 组件根本不关心 theme 的变化,它们的内容是完全静态的。然而,仅仅因为父组件 Dashboardtheme 状态更新导致其自身重渲染,这两个无辜的子组件也被迫进行了完全不必要的重渲染。当这些子组件变得复杂时,就会造成严重的性能浪费。


第二步:第一把手术刀 React.memo —— 阻断渲染

React.memo 是一个高阶组件(HOC),你可以用它来包裹你的函数组件。它像一个“守卫”,在组件准备重渲染之前,会对其接收到的新旧 props 进行一次 浅比较。如果 props 没有变化,memo 就会阻止这次重渲染,并直接复用上一次的渲染结果。

让我们来为 PerformanceReport 组件动一次“手术”。

文件路径: src/components/Dashboard/PerformanceReport.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from 'react' // 引入 React 来使用 memo

const PerformanceReport = () => {
console.log('--- 性能报告(PerformanceReport)组件正在被渲染 ---')
return (
<div className="p-4 bg-gray-100">
<h3 className="font-bold">性能报告</h3>
<p className="mt-2">报告内容:一切正常,性能良好。</p>
</div>
)
}

// 使用 React.memo 包裹组件并导出
export default React.memo(PerformanceReport)

刷新页面,再次点击“切换主题”按钮。现在控制台的输出变成了:

1
2
3
4
5
6
7
仪表盘(Dashboard)父组件正在被渲染
... 头部(Header)组件正在被渲染 ...
--- 性能报告(PerformanceReport)组件正在被渲染 --- // 只有首次渲染时打印
仪表盘(Dashboard)父组件正在被渲染
... 头部(Header)组件正在被渲染 ...
仪表盘(Dashboard)父组件正在被渲染
... 头部(Header)组件正在被渲染 ...

成功了!PerformanceReport 的渲染日志消失了。因为它没有接收任何 props,memo 每次都判断 props 未变,从而成功阻止了重渲染。但是,Header 组件仍在被渲染,为什么?因为我们还没对它做任何处理。

什么是浅比较?
对于原始类型(string, number, boolean),浅比较会检查值是否相等('a' === 'a')。对于复杂类型(object, array, function),它只比较 引用地址 是否相同,而不会深入比较内部的内容。这就是问题的关键所在。


第三步:第二把手术刀 useCallback —— 稳定函数

现在,我们给 Header 组件增加一个功能:它接收一个 onRefresh 函数,点击按钮时调用。

1. 修改 Header 组件

文件路径: src/components/Dashboard/Header.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from 'react'

type HeaderProps = {
onRefresh: () => void;
}

const Header = ({ onRefresh }: HeaderProps) => {
console.log('... 头部(Header)组件正在被渲染 ...')
return (
<header className="bg-gray-800 text-white p-4 rounded-t-lg flex justify-between items-center">
<h1 className="text-xl">我的仪表盘</h1>
<button onClick={onRefresh} className="px-3 py-1 bg-indigo-500 rounded">刷新</button>
</header>
)
}

export default React.memo(Header) // 我们也给它加上 memo

2. 在 Dashboard 中传递函数

文件路径: src/components/Dashboard/Dashboard.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...
import { useState, useCallback } from 'react'
// ...

const Dashboard = () => {
// ...
const handleRefresh = () => {
console.log('手动刷新数据...')
}
// ...
return (
<div /* ... */>
<Header onRefresh={handleRefresh} />
{/* ... */}
</div>
)
}

export default Dashboard

刷新页面,再次点击“切换主题”按钮。你会绝望地发现,Header 的渲染日志又回来了!

新的痛点出现了:明明我们已经给 Header 加了 React.memo,为什么它还在重渲染?

原因就在于“浅比较”。Dashboard 组件每次重渲染时,const handleRefresh = () => {...} 这行代码都会创建一个 全新的函数对象。虽然这两个函数长得一模一样,但在 JavaScript 的世界里,它们的内存地址是不同的(() => {} !== () => {})。因此,React.memo 认为 onRefresh 这个 prop 每次都在“变化”,优化就此失效。

useCallback 正是解决这个问题的“函数稳定器”。它会缓存一个函数定义,在组件多次渲染之间返回同一个函数实例,除非它的依赖项改变了。

3. 使用 useCallback 修复问题

文件路径: src/components/Dashboard/Dashboard.tsx (最终修复)

1
2
3
4
5
6
import { useState, useCallback } from 'react' // 引入 useCallback
// 使用 useCallback 包裹函数
// 空的依赖数组 `[]` 意味着这个函数在组件的整个生命周期内都将是同一个实例
const handleRefresh = useCallback(() => {
console.log('手动刷新数据...')
}, [])

现在,一切都完美了。再次点击“切换主题”,HeaderPerformanceReport 的渲染日志都消失了。我们通过 React.memouseCallback 的组合拳,实现了对子组件渲染的精准控制。


第四步:第三把手术刀 useMemo —— 缓存结果

我们已经解决了不必要的 组件渲染,但还有一种性能浪费:不必要的 昂贵计算

新的痛点: 假设我们的 PerformanceReport 组件需要根据一些复杂数据计算出一个最终得分,这个计算过程非常耗时。

1. 升级 PerformanceReport 组件

文件路径: src/components/Dashboard/PerformanceReport.tsx (引入昂贵计算)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import React, { useState } from 'react'

// 模拟一个非常耗时的计算
const calculateComplexScore = (data: { value: number }): number => {
console.log('--- 正在执行极其耗时的分数计算... ---')
// 模拟复杂计算,实际项目中可能是处理大量数据的循环
let i = 0
while (i < 1000000000) {
i++
}
return data.value * 100
}

const PerformanceReport = () => {
// 我们给组件增加一个不相关的内部状态,用于触发它自身的重渲染
const [internalRefresh, setInternalRefresh] = useState(0)

// 假设这个 data 是从 props 或 context 中获取的
const reportData = { value: 5 }

// 每次组件重渲染,无论 reportData 变没变,这个昂贵的计算都会执行
const score = calculateComplexScore(reportData)

return (
<div className="p-4 bg-gray-100">
<h3 className="font-bold">性能报告</h3>
<p className="mt-2">报告内容:一切正常,性能良好。</p>
<p className="font-bold text-lg mt-2">
系统得分: <span className="text-green-600">{score}</span>
</p>
<button
onClick={() => setInternalRefresh(c => c + 1)}
className="mt-2 px-3 py-1 bg-gray-500 text-white rounded text-sm"
>
强制组件内部刷新 ({internalRefresh})
</button>
</div>
)
}

export default React.memo(PerformanceReport)

现在,PerformanceReport 不会再因为父组件 Dashboard 的主题切换而重渲染了。但是,请点击它自己新增的“强制组件内部刷新”按钮。你会发现,UI 会有明显的卡顿,并且控制台每次都会打印“正在执行极其耗时的分数计算…”。

这显然是错误的。我们的 reportData 根本没有变,但这个耗时的计算却在每次内部刷新时都被执行了一遍。

useMemo 就是解决这个问题的“结果缓存器”。它会执行一个函数,并将其 返回值 缓存起来。只有当其依赖项发生变化时,它才会重新执行函数并缓存新的结果。

2. 使用 useMemo 修复问题

文件路径: src/components/Dashboard/PerformanceReport.tsx (最终修复)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React, { useState, useMemo } from 'react' // 引入 useMemo

// ... calculateComplexScore 函数保持不变

const PerformanceReport = () => {
const [internalRefresh, setInternalRefresh] = useState(0)
const reportData = { value: 5 }

// 使用 useMemo 包裹昂贵的计算
const score = useMemo(() => {
// 只有当 reportData.value 变化时,这个函数才会重新执行
return calculateComplexScore(reportData)
}, [reportData.value]) // 注意:依赖项是真正影响计算的值

return (
<div className="p-4 bg-gray-100">
<h3 className="font-bold">性能报告</h3>
<p className="mt-2">报告内容:一切正常,性能良好。</p>
<p className="font-bold text-lg mt-2">
系统得分: <span className="text-green-600">{score}</span>
</p>
<button
onClick={() => setInternalRefresh(c => c + 1)}
className="mt-2 px-3 py-1 bg-gray-500 text-white rounded text-sm"
>
强制组件内部刷新 ({internalRefresh})
</button>
</div>
)
}

export default React.memo(PerformanceReport)

刷新页面,再次点击“强制组件内部刷新”按钮。现在,UI 响应瞬间完成,控制台也只在首次渲染时打印了一次计算日志。我们成功地避免了不必要的计算!


本章小结

在本节中,我们通过一个层层递进的案例,掌握了 React 性能优化的三驾马车:

  1. 当问题是“不必要的组件重渲染”时,首先想到的是用 React.memo 来包裹子组件。
  2. React.memo 因函数/对象 props 而失效时,使用 useCallback 来稳定函数引用,或使用 useMemo 来稳定对象/数组引用。
  3. 当问题是“组件内部有昂贵的计算”时,使用 useMemo 来缓存计算结果,确保它只在必要时才执行。

掌握它们,是区分 React 新手和资深玩家的重要分水岭。请务必牢记:不要过度优化。只在你通过分析(如 console.log 或 React DevTools)确认存在性能瓶颈时,才去使用这些工具。


7.2 应用健壮性:构建生产级的稳定应用

一个“能用”的应用和一个“健壮”的应用之间,隔着两条鸿沟:

  1. 当应用遇到未预期的错误时,是“一键崩溃”还是能“优雅降级”?
  2. 当应用功能越来越庞大时,是让用户“耐心等待”一个巨大的文件加载,还是“按需加载”,提供流畅的访问体验?

本节,我们将学习 React 原生提供的两大“法宝”—— 错误边界(Error Boundaries)代码分割(React.lazy + Suspense,来跨越这两条鸿沟。

第一步:制造一场“生产事故”——体验白屏崩溃

为了理解“健壮性”的重要性,我们必须先亲手制造一次“灾难”。我们将创建一个故意会出错的组件。

1. 创建一个会崩溃的组件

src/components/Dashboard 文件夹中,创建一个新文件 BuggyWidget.tsx。这个组件在点击按钮后,会尝试渲染一个 null 值的属性,这在 JavaScript 中会立即导致一个运行时错误。

文件路径: src/components/Dashboard/BuggyWidget.tsx (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState } from 'react'

// 这是一个故意会出错的组件
const BuggyWidget = () => {
const [data, setData] = useState<{ message: string } | null>({
message: '一切正常!',
})

const createError = () => {
setData(null) // 将 state 设置为 null
}

// 当 data 为 null 时,尝试访问 data.message 将抛出 TypeError
return (
<div className="border border-red-500 bg-red-100 p-4 rounded-lg">
<h3 className="font-bold text-red-700">危险的小组件</h3>
<p className="mt-2 text-gray-800">当前数据: {data!.message}</p>
<button onClick={createError} className="mt-2 px-3 py-1 bg-red-600 text-white rounded">
点我制造一个错误
</button>
</div>
)
}

export default BuggyWidget

我们在 data!.message 中使用了 ! (非空断言操作符),这是在告诉 TypeScript:“我确定 data 在这里不会是 null”。这本身就是一种危险的信号,为我们的“事故”埋下了伏笔。

2. 将“定时炸弹”放入仪表盘

现在,我们将这个危险的组件添加到我们的 Dashboard 中。

文件路径: src/components/Dashboard/Dashboard.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import BuggyWidget from "./BuggyWidget";
const Dashboard = () => {
console.log("仪表盘(Dashboard)父组件正在被渲染");

return (
<div className={` m-8 border rounded-lg shadow-lg`}>
<main className="p-4">
<BuggyWidget />
</main>
</div>
);
};

export default Dashboard;

3. 运行并触发“爆炸”

刷新你的应用。仪表盘看起来一切正常。现在,勇敢地点一下那个红色的“点我制造一个错误”按钮。

BOOM! 你的整个应用瞬间白屏。

打开浏览器控制台,你会看到一个红色的错误,类似 TypeError: Cannot read properties of null (reading 'message')

这就是我们的痛点:React 的默认行为是,任何一个组件在渲染期间发生的未被捕获的 JavaScript 错误,都会导致整个 React 组件树被卸载,最终呈现给用户的就是一片空白。这是一个极其糟糕的用户体验。我们绝不希望因为一个小组件的 Bug,导致整个页面都无法使用。


第二步:构建“安全气囊”——错误边界 (Error Boundaries)

为了防止这种“一损俱损”的情况,React 提供了错误边界(Error Boundaries)这一原生机制。它像一个“安全气囊”或“防火墙”,你可以用它包裹你的任何组件。当被包裹的组件及其子组件发生渲染错误时,错误会被这个“边界”捕获,同时,它会渲染一个你预设好的“降级 UI”,而不会让错误继续传播导致整个应用崩溃。

1. 创建 ErrorBoundary 组件

一个非常关键的点是:截止到目前,错误边界只能通过类组件来实现。这是 React 中少数几个函数组件无法完全替代类组件的场景之一。

src/components 目录下创建一个新文件 ErrorBoundary.tsx

文件路径: src/components/ErrorBoundary.tsx (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import React, { Component, type ErrorInfo, type ReactNode } from "react";

interface Props {
children: ReactNode;
}

interface State {
hasError: boolean;
}

class ErrorBoundary extends Component<Props, State> {
// 1. 在构造函数中初始化 state
public state: State = {
hasError: false,
};

// 2. 使用静态生命周期 getDerivedStateFromError 来捕获错误
// 当后代组件抛出错误后,它会被调用,并允许我们更新 state
public static getDerivedStateFromError(_: Error): State {
// 更新 state,以便下一次渲染可以展示降级 UI
return { hasError: true };
}

// 3. 使用 componentDidCatch 来记录错误信息
// 它也会在错误发生后被调用,但主要用于副作用,如将错误上报给监控服务
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary 捕获到一个错误:", error, errorInfo);
// 在实际项目中,你可以在这里调用错误上报服务,例如 Sentry, LogRocket 等
// logErrorToMyService(error, errorInfo);
}

public render() {
// 4. 根据 state 中的 hasError 标志,决定渲染什么
if (this.state.hasError) {
// 如果有错误,就渲染我们预设的降级 UI
return (
<div className="p-4 bg-yellow-100 border border-yellow-400 text-yellow-700 rounded-lg">
<h3 className="font-bold">糟糕!</h3>
<p>这个组件好像出了点问题,我们已经记录了错误,请稍后重试。</p>
</div>
);
}

// 5. 如果没有错误,就渲染子组件
return this.props.children;
}
}

export default ErrorBoundary;

这个可复用的 ErrorBoundary 组件现在就像一个装备了安全气囊的座椅,我们可以把它放在任何我们认为可能“颠簸”的地方。

2. 安装“安全气囊”

回到 Dashboard.tsx,用我们刚创建的 ErrorBoundary 把“危险”的 BuggyWidget 包裹起来。

文件路径: src/components/Dashboard/Dashboard.tsx (最终修复)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import BuggyWidget from "./BuggyWidget";
import ErrorBoundary from "./ErrorBoundary";
const Dashboard = () => {
return (
<div className={` m-8 border rounded-lg shadow-lg`}>
<main className="p-4">
<ErrorBoundary>
<BuggyWidget />
</ErrorBoundary>
</main>
</div>
);
};

export default Dashboard;

3. 再次触发“事故”并观察

刷新页面,再次点击“点我制造一个错误”按钮。

这一次,奇迹发生了!页面不再白屏崩溃。BuggyWidget 的位置被我们预设的黄色警告框替代了,而应用的其余部分——头部、切换主题按钮、性能报告——完好无损,功能完全正常

我们成功地将错误的“火情”控制在了“防火墙”内部,保证了应用主体的健壮性。

在现代的 React 元框架(如 Next.js)中,通常提供了基于文件的、更强大的错误处理机制(如 error.js 文件),但其底层思想与 React 的 Error Boundary 完全一致。掌握它,你就掌握了 React 错误处理的精髓。


第三步:为应用“减负”—— React.lazySuspense

我们已经解决了“崩溃”的问题,现在来解决“缓慢”的问题。

新的痛点: 我们的 PerformanceReport 组件现在非常成功,产品经理要求在里面加入一个巨大的、功能复杂的图表库(比如 D3.js, Chart.js, ECharts)。这将导致 PerformanceReport.tsx 及其依赖的库文件变得非常庞大。

问题在于,即使用户根本不看性能报告,甚至它还没出现在屏幕上,它的全部代码(包括那个巨大的图表库)在用户首次访问仪表盘页面时,就已经被打包进主文件并下载了。这严重拖慢了我们应用的初始加载速度,对于网络环境不好的用户尤其不友好。

解决方案:代码分割

代码分割是一种将代码拆分成多个小包(chunks),然后按需或并行加载它们的技术。React 原生提供了 React.lazy<Suspense> 这一对“黄金搭档”来实现组件级别的代码分割。

  • React.lazy: 一个函数,它允许你像渲染普通组件一样渲染一个动态导入(dynamic import())的组件。
  • <Suspense>: 一个组件,它允许你在等待懒加载组件下载完成时,声明式地指定一个加载状态(fallback)。

1. 改造 Dashboard 以实现懒加载

我们将改造 Dashboard.tsx,让 PerformanceReport 组件只在它需要被渲染时才开始下载。

文件路径: src/components/Dashboard/Dashboard.tsx (懒加载改造)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, { lazy, Suspense } from "react"; // 1.引入 lazy 和 Suspense
import ErrorBoundary from "./ErrorBoundary";

// 2.使用Lazy加载组件
const PerformanceReport = lazy(() => import("./PerformanceReport"));
const Dashboard = () => {
return (
<div className={` m-8 border rounded-lg shadow-lg`}>
<main className="p-4">
<ErrorBoundary>
{/* 3. 使用 Suspense 包裹懒加载组件 */}
{/* 当 React 尝试渲染 PerformanceReport,但它的代码还未下载完成时,*/}
{/* Suspense 会显示 fallback 中指定的内容 */}
<Suspense fallback={<div>Loading...</div>}>
<PerformanceReport />
</Suspense>
</ErrorBoundary>
</main>
</div>
);
};

export default Dashboard;

2. 观察效果

刷新你的应用。这一次,你会看到“性能报告”区域先是显示“正在加载性能报告…”,然后很快(在本地开发环境中可能一闪而过)替换为真正的组件内容。

如果你打开浏览器的开发者工具,切换到“网络(Network)”面板并刷新页面,你会看到除了主文件外,还有一个新的 .js 文件在稍后被加载——这正是被我们分离出去的 PerformanceReport 组件的代码块!

我们成功地为应用实现了“减负”。现在,初始加载时用户只需下载核心功能代码,而像性能报告这样的重量级、非首屏关键组件,则被推迟到真正需要时才加载,极大地优化了应用的启动性能和用户体验。


本章小结

在本节中,我们为应用装备了两大“安全系统”,使其更加健壮和专业:

  1. 错误边界 (Error Boundaries): 我们的“安全气囊”,通过捕获渲染错误并提供降级 UI,防止了局部问题导致整个应用崩溃。
  2. 代码分割 (React.lazy + <Suspense>): 我们的“智能加载器”,通过按需加载组件,显著提升了应用的初始加载性能。

掌握了这些,你的 React 应用不仅功能强大,而且在面对生产环境的各种复杂情况时,也能表现得像一个“不死小强”一样稳定可靠。


7.3 高级抽象:设计可复用且强大的组件

一个应用的质量,很大程度上取决于其基础组件的质量。本节,我们将深入学习 React 提供的高级 API 和社区沉淀的设计模式,让你具备构建“组件库”级别高质量组件的能力。

7.3.1 打破组件壁垒:Ref 转发与实例暴露

我们已经知道如何使用 useRef 来获取一个 DOM 元素的引用,但那仅限于在组件 内部。当你试图将 ref 传递给一个你自己的函数组件时,会发生什么?

痛点:ref 无法作为普通 prop 传递

让我们来创建一个自定义的输入框组件。

1. 创建 CustomInput 组件

文件路径: src/components/Dashboard/CustomInput.tsx (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type CustomInputProps = {
label: string;
};

const CustomInput = ({ label }: CustomInputProps) => {
return (
<div>
<label className="block text-sm font-medium" htmlFor="custom-input">
{label}
</label>
<input
type="text"
className="mt-1 p-2 border rounded w-full text-black"
placeholder={label}
/>
</div>
);
};
export default CustomInput;

2. 在 Dashboard 中尝试使用 ref

现在,我们希望在 Dashboard 父组件中,通过一个按钮来让这个自定义输入框聚焦。

文件路径: src/components/Dashboard/Dashboard.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import CustomInput from "./CustomInput";
import { useRef } from "react";
const Dashboard = () => {
const customInputRef = useRef<HTMLInputElement>(null);
const focusCustomInput = () => {
// 我们的目标是调用 ref.current.focus()
// @ts-ignore - 我们暂时忽略 TypeScript 错误来观察 React 的行为
customInputRef.current.focus();
};
return (
<div className="my-4 p-4 border rounded">
<CustomInput label="我的自定义输入框" ref={customInputRef} />
<button
onClick={focusCustomInput}
className="mt-2 px-4 py-2 bg-green-500 text-white rounded"
>
聚焦自定义输入框
</button>
</div>
);
};
export default Dashboard;

运行应用,你会立刻在控制台看到一个 明确的警告Dashboard.tsx: 8 Uncaught TypeError: Cannot read properties of null (reading 'focus') at focusCustomInput (Dashboard.tsx:8:28)

当你点击按钮时,应用会崩溃。这就是痛点:React 为了避免组件内部实现被意外暴露,默认禁止将 ref 直接传递给函数组件。

解决方案一:React.forwardRef

React.forwardRef 像一个“管道”,它能接收父组件传递过来的 ref,并将其“转发”到组件内部的某个具体 DOM 元素上。

1. 改造 CustomInput 组件

文件路径: src/components/Dashboard/CustomInput.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { forwardRef } from 'react' // 1. 引入 forwardRef

type CustomInputProps = {
label: string
}

// 2. 使用 forwardRef 包裹组件
// 它会为你的组件提供第二个参数:ref
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(({ label }, ref) => {
return (
<div>
<label className="block text-sm font-medium">{label}</label>
{/* 3. 将接收到的 ref 附加到真正的 input 元素上 */}
<input
ref={ref}
type="text"
className="mt-1 p-2 border rounded w-full text-black"
/>
</div>
)
})

export default CustomInput

现在,Dashboard 组件中的代码无需任何修改,再次点击“聚焦自定义输入框”按钮,它就能完美工作了!

新的痛点:过度暴露

forwardRef 很棒,但它直接暴露了整个 input DOM 节点。这意味着父组件现在可以为所欲为,比如 customInputRef.current.style.backgroundColor = 'red'。这破坏了组件的封装性。我们希望只暴露我们想让父组件调用的方法,比如 focus(),或者一个自定义的 shake() 动画。

解决方案二:useImperativeHandle

这个 Hook 让你在使用 ref 时,可以 自定义 暴露给父组件的实例值。它总是和 forwardRef 一起使用。

1. 再次升级 CustomInput

文件路径: src/components/Dashboard/CustomInput.tsx (最终升级)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { forwardRef, useImperativeHandle, useRef, useState } from "react"; // 1. 引入 forwardRef

type CustomInputProps = {
label: string;
};

// 定义我们想要暴露给父组件的 ref 句柄的类型
export type CustomInputRef = {
focus: () => void;
shake: () => void;
};

const CustomInput = forwardRef<CustomInputRef, { label: string }>(
({ label }, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isShaking, setIsShaking] = useState(false);

// 使用 useImperativeHandle
useImperativeHandle(ref, () => ({
// 1. 只暴露 focus 方法
focus: () => {
inputRef.current?.focus();
},
// 2. 暴露一个自定义的 shake 方法
shake: () => {
setIsShaking(true);
setTimeout(() => setIsShaking(false), 500); // 动画持续 500ms
},
}));

return (
<div>
<label className="block text-sm font-medium">{label}</label>
<input
placeholder={label}
ref={inputRef}
type="text"
className={`mt-1 p-2 border rounded w-full text-black ${
isShaking ? "animate-bounce" : ""
}`}
/>
</div>
);
}
);
export default CustomInput;

现在,父组件的 ref 只能访问到我们明确定义的 focusshake 方法,实现了完美的 封装API 设计

7.3.2 优雅的 API 设计:复合组件模式

当你需要构建一组相互关联、共同协作的组件时(比如一个选项卡 Tabs 系统),如何设计它们的 API?

痛点: 一种常见的方式是“配置式”,通过庞大的 props 对象来传递所有数据:<Tabs tabs={[{title: 'Tab 1', content: '...'}, ...]} />
这种方式简单,但 极不灵活。用户无法自定义单个 Tab 的样式,也无法在 Tab 标题中插入图标或其他组件。

解决方案:复合组件模式

这种模式模仿了 HTML <select><option> 的工作方式:将状态管理和逻辑放在父组件中,并通过 Context 在内部共享给子组件。这让用户可以通过 组合 的方式来构建 UI,获得了极大的灵活性。

1. 创建 Tabs 组件家族

我们将创建 Tabs, TabList, Tab, TabPanels, TabPanel 几个组件。在 src/components 下新建 Tabs 文件夹并创建以下文件。

文件路径: src/components/Tabs/TabsContext.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { createContext, useContext } from 'react'

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

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

export const useTabs = () => {
const context = useContext(TabsContext)
if (!context) {
throw new Error('useTabs 必须在 Tabs 组件内部使用')
}
return context
}

export default TabsContext

文件路径: src/components/Tabs/Tabs.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { useState } from 'react'
import TabsContext from './TabsContext'

const Tabs = ({ children, initialTab }: { children: React.ReactNode, initialTab: string }) => {
const [activeTab, setActiveTab] = useState(initialTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}

const TabList = ({ children }: { children: React.ReactNode }) => <div className="flex border-b">{children}</div>
const Tab = ({ label }: { label: string }) => {
const { activeTab, setActiveTab } = useTabs()
const isActive = activeTab === label
return (
<button
onClick={() => setActiveTab(label)}
className={`px-4 py-2 -mb-px border-b-2 ${isActive ? 'border-blue-500 text-blue-500' : 'border-transparent'}`}
>
{label}
</button>
)
}
const TabPanels = ({ children }: { children: React.ReactNode }) => <div className="pt-4">{children}</div>
const TabPanel = ({ children, whenActive }: { children: React.ReactNode, whenActive: string }) => {
const { activeTab } = useTabs()
return activeTab === whenActive ? <div>{children}</div> : null
}

// 将所有组件作为 Tabs 的属性导出,方便使用
Tabs.TabList = TabList
Tabs.Tab = Tab
Tabs.TabPanels = TabPanels
Tabs.TabPanel = TabPanel

export default Tabs

2. 在 Dashboard 中使用复合组件

现在,我们可以在 Dashboard 中以一种极其声明式和灵活的方式来使用 Tabs 组件。

文件路径: src/components/Dashboard/Dashboard.tsx (添加 Tabs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ...
import Tabs from '../Tabs/Tabs'

const Dashboard = () => {
// ...
return (
<div /* ... */ >
{/* ... */}
<main className="p-4">
{/* ... */}
<div className="my-4">
<Tabs initialTab="report">
<Tabs.TabList>
<Tabs.Tab label="report">性能报告</Tabs.Tab>
<Tabs.Tab label="settings">设置</Tabs.Tab>
</Tabs.TabList>
<Tabs.TabPanels>
<Tabs.TabPanel whenActive="report">
<Suspense fallback={<div>正在加载...</div>}>
<PerformanceReport />
</Suspense>
</Tabs.TabPanel>
<Tabs.TabPanel whenActive="settings">
<p>这里是设置面板的内容。</p>
</Tabs.TabPanel>
</Tabs.TabPanels>
</Tabs>
</div>
</main>
</div>
)
}

export default Dashboard

现在,我们的仪表盘拥有了一个功能齐全且 API 优美的选项卡系统。这种模式将 状态管理(在 Tabs 内部)和 UI 渲染(由用户自由组合)完美分离,是构建可复用组件库的 核心思想

7.3.3 防患于未然:严格模式 <React.StrictMode>

最后,我们来了解一个不渲染任何 UI,但在开发过程中至关重要的“纪律委员”——严格模式。

痛点: 我们在开发时,可能会不自觉地使用一些过时的 API,或者编写出带有“不纯”副作用的函数,这些都是未来应用的潜在隐患。

解决方案: <React.StrictMode> 是一个辅助组件,它会为其后代组件开启额外的检查和警告(仅在开发模式下生效)。

它能帮助你发现:

  • 不安全的生命周期方法。
  • 过时的 ref API 用法。
  • 意料之外的副作用(它会故意调用两次渲染相关的函数来帮助你发现不纯的操作)。
  • 过时的 context API。

如何使用?

你只需要在应用的根部,用 <React.StrictMode> 包裹你的 <App /> 组件即可。这个操作通常在项目的入口文件 main.tsx 中完成,一般来说我们创建 vite 项目时他已经帮我们配置好了

文件路径: src/main.tsx (修改)

1
2
3
4
5
6
7
8
9
10
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

启用后,它不会带来任何可见的 UI 变化,但它会在你的开发控制台中,像一位严格的导师一样,指出你代码中不规范或有风险的地方。开启严格模式是所有现代 React 项目的最佳实践。


本章小结

在本节中,我们完成了从“组件使用者”到“组件设计者”的思维转变:

  1. 通过 forwardRefuseImperativeHandle,我们学会了如何设计组件的命令式 API,在封装和暴露之间找到完美平衡。
  2. 通过 复合组件模式,我们掌握了构建灵活、声明式组件家族的强大武器,将状态和视图彻底解耦。
  3. 通过 <React.StrictMode>,我们为项目聘请了一位免费的、全天候的代码质量“审查员”。

至此,您已经掌握了构建高质量、可维护、可扩展的 React 组件所需的绝大部分原生高级知识。