在 React 的生态系统中,组件是构建用户界面的核心。为了更好地组织代码、提高复用性并实现关注点分离(Separation of Concerns),社区沉淀出了一种非常经典的设计模式:展示组件(Presentational Components) 与 容器组件(Container Components)。
这个概念由 Dan Abramov(Redux 的作者之一)推广,虽然 React Hooks 的出现让这种模式的实现方式发生了变化,但其核心思想——分离视图和逻辑——至今仍然是构建高质量 React 应用的基石。
1. 什么是展示组件 (Presentational Components)?
展示组件,也常被称为“傻瓜组件”(Dumb Components)或“UI 组件”,其核心职责是 “如何展示事物” (How things look)。
核心特点:
- 关注 UI: 主要负责页面的结构和样式,不关心数据从哪里来,或者如何改变。
- 通过
props接收数据: 它们所需的所有数据和回调函数都通过props从父组件传递进来。 - 通常无自身状态: 它们很少拥有自己的 state,即使有,也通常是和 UI 相关的瞬时状态(例如,一个动画的开关状态),而不是应用的核心数据。
- 高可复用性: 因为它们不依赖于应用的特定业务逻辑或数据源(如 Redux、API 调用),所以可以在应用的不同地方甚至不同项目中轻松复用。
- 易于测试和开发: 你可以独立地渲染和测试它们,只需提供不同的
props即可查看所有UI变体。
别名:
- Dumb Components (傻瓜组件)
- Pure Components (纯组件)
- UI Components (UI组件)
示例,一个简单的用户列表展示组件:
这个组件只负责渲染一个用户列表。它接收一个 users 数组和 onUserClick 回调函数,然后将它们渲染出来。它完全不知道这些用户数据是怎么来的,也不知道点击用户后会发生什么。
// src/components/UserList.js
import React from 'react';
import PropTypes from 'prop-types';
// 这是一个纯粹的展示组件
const UserList = ({ users, onUserClick }) => {
if (!users || users.length === 0) {
return <p>暂无用户数据。</p>;
}
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{users.map(user => (
<li
key={user.id}
onClick={() => onUserClick(user)}
style={{ padding: '10px', borderBottom: '1px solid #ccc', cursor: 'pointer' }}
>
{user.name}
</li>
))}
</ul>
);
};
// 使用 PropTypes 定义期望的 props 类型,增强组件的健壮性
UserList.propTypes = {
users: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
})).isRequired,
onUserClick: PropTypes.func.isRequired,
};
export default UserList;
2. 什么是容器组件 (Container Components)?
容器组件,也常被称为“聪明组件”(Smart Components)或“逻辑组件”,其核心职责是 “如何驱动事物工作” (How things work)。
核心特点:
- 关注逻辑: 主要负责数据获取、状态管理和业务逻辑。
- 为展示组件提供数据: 它们通常会调用 API、连接 Redux Store 或使用 React Context 来获取数据,然后将这些数据作为
props传递给展示组件。 - 管理状态: 它们经常拥有和管理应用的核心 state(例如,使用
useState或useReducer)。 - 提供行为: 它们定义了各种回调函数(如
handleClick,handleSubmit等),并将这些函数作为props传递给展示组件,以便展示组件可以响应用户交互。 - 渲染展示组件: 它们本身通常不包含复杂的 JSX 结构或样式,而是渲染一个或多个展示组件。
别名:
- Smart Components (聪明组件)
- Controller Components (控制器组件)
示例,一个获取用户数据的容器组件:
这个容器组件负责获取用户数据,并处理用户的点击事件。它将获取到的数据和处理函数传递给 UserList 展示组件。
// src/containers/UserListContainer.js
import React, { useState, useEffect } from 'react';
import UserList from '../components/UserList'; // 引入展示组件
// 模拟一个 API 调用
const fetchUsers = () => {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' },
]);
}, 1000);
});
};
// 这是一个容器组件
const UserListContainer = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
// 在组件挂载时获取数据
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
setLoading(false);
});
}, []); // 空依赖数组表示只在初次渲染时执行
// 定义点击用户的处理逻辑
const handleUserClick = (user) => {
alert(`你点击了用户: ${user.name}`);
};
if (loading) {
return <p>正在加载中...</p>;
}
// 渲染展示组件,并把数据和方法通过 props 传递下去
return <UserList users={users} onUserClick={handleUserClick} />;
};
export default UserListContainer;
3. 核心区别对比
| 特性 | 展示组件 (Presentational) | 容器组件 (Container) |
|---|---|---|
| 关注点 | UI 和样式 (怎么看) | 业务逻辑和数据 (怎么运作) |
| 数据来源 | props |
自身状态、API、Redux、Context |
| 状态管理 | 通常无状态,或只有 UI 相关的瞬时状态 | 管理应用的核心数据和状态 |
| 依赖 | 不依赖应用的其余部分 | 依赖数据源、服务等 |
| 可复用性 | 非常高 | 较低,通常与特定业务场景绑定 |
| 实现方式(传统) | 函数组件、无状态类组件 | 有状态的类组件 |
| 实现方式(现代) | 只负责渲染的函数组件 | 使用 Hooks (useState/useEffect) 的函数组件 |
4. 如何优雅地应用?
将两者结合使用的最终目的是让你的应用结构更清晰、更易于维护。
- 从 UI 入手构建组件: 优先创建可复用的展示组件。在开发时,你可以先用假数据(mock data)把 UI 搭建起来。
- 创建容器包裹展示组件: 当 UI 组件完成后,创建一个容器组件来管理其数据和行为。
- 自上而下的数据流: 容器组件负责获取数据,然后通过
props将数据“倾倒”给子级的展示组件。 - 自下而上的事件流: 展示组件通过调用从
props接收的回调函数来通知容器组件发生了某个事件(如点击),由容器组件决定如何响应。
最终应用:
在你的主应用文件中,你只需要引入并使用容器组件即可。
// src/App.js
import React from 'react';
import UserListContainer from './containers/UserListContainer';
function App() {
return (
<div>
<h1>用户列表</h1>
<UserListContainer />
</div>
);
}
export default App;
5. 现代 React Hooks 带来的演变
React Hooks (useState, useEffect, useContext 等) 的出现,使得函数组件也能够拥有状态和处理副作用。这在一定程度上模糊了“类组件=容器,函数组件=展示”的界限。
然而,分离关注点 的核心思想并未过时,只是实现方式更加灵活了。
- 自定义 Hooks 成为逻辑复用的新方式: 现在,我们倾向于将可复用的业务逻辑(如数据获取、订阅等)提取到 自定义 Hook 中。这部分逻辑其实就是过去容器组件所承担的责任。
使用自定义 Hook 重构示例
我们可以将 UserListContainer 中的逻辑提取到一个名为 useUsers 的自定义 Hook 中。
// src/hooks/useUsers.js
import { useState, useEffect } from 'react';
// 模拟 API
const fetchUsers = () => {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' },
]);
}, 1000);
});
};
// 自定义 Hook,封装了获取用户的逻辑
export const useUsers = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
setLoading(false);
});
}, []);
return { users, loading };
};
然后,我们的组件可以变得更简洁。我们可以创建一个“页面级组件”,它同时扮演了容器的角色,但代码更清晰。
// src/pages/UserListPage.js
import React from 'react';
import { useUsers } from '../hooks/useUsers'; // 引入自定义 Hook
import UserList from '../components/UserList'; // 引入展示组件
const UserListPage = () => {
// 使用自定义 Hook 获取数据和加载状态
const { users, loading } = useUsers();
const handleUserClick = (user) => {
alert(`你点击了用户: ${user.name}`);
};
if (loading) {
return <p>正在加载中...</p>;
}
// 组件本身既使用了逻辑(通过 Hook),也渲染了 UI(通过展示组件)
return (
<div>
<h1>用户列表</h1>
<UserList users={users} onUserClick={handleUserClick} />
</div>
);
};
export default UserListPage;
在这个现代化的模式中:
UserList依然是纯粹的 展示组件。useUsers自定义 Hook 封装了 数据和逻辑。UserListPage作为一个“智能”组件,通过组合 Hook 和展示组件来完成页面功能,它本身就是新时代的“容器”。
简单总结
无论是经典的展示/容器组件模式,还是现代基于 Hooks 的开发模式,其 核心思想 一脉相承:
将组件拆分为负责“外观”和负责“逻辑”的两部分。
这样做的好处是显而易见的:
- 更好的复用性: UI 组件和逻辑可以被独立复用。
- 更清晰的职责: 每个部分只做一件事,代码更容易理解和维护。
- 更轻松的测试: 可以独立测试 UI 的各种展示形态和业务逻辑的正确性。
在你的项目中,优雅地应用这一思想,将极大地提升代码质量和开发效率。
重新渲染或性能问题
Q:将组件拆分成展示和逻辑两部分,会不会因为 props 传递层级变多而导致不必要的重新渲染或性能问题?
简短的回答是:是的,确实有这种可能性,但这通常是一个可以被有效管理和解决的问题。分离关注点带来的架构优势,远远超过了它可能引入的性能开销,前提是我们使用正确的技术来优化它。
问题剖析:为什么会出现性能问题?
在 React 中,当一个组件的 state 或 props 发生变化时,它会默认重新渲染。这个重新渲染过程会递归地触发其所有子组件的重新渲染,无论子组件的 props 是否真的改变了。
将组件拆分为容器和展示两部分后,可能会遇到两种主要的性能相关问题:
1. 不必要的重新渲染 (Unnecessary Re-renders):
这是最核心的问题。考虑我们的 UserListContainer 例子:
const UserListContainer = () => {
const [users, setUsers] = useState([]);
// ... 其他逻辑
// 关键点1: 这个函数在每次 UserListContainer 渲染时都会被重新创建
const handleUserClick = (user) => {
alert(`你点击了用户: ${user.name}`);
};
// 关键点2: 如果这里有其他 state,每次它变化时,UserListContainer 都会重渲染
// 即使 users 和 handleUserClick 的逻辑没变,也会导致 UserList 重新渲染
return <UserList users={users} onUserClick={handleUserClick} />;
};
当 UserListContainer 因为任何原因(比如它自身有其他 state 改变)重新渲染时:
handleUserClick函数会被 重新创建一个新的实例。- 对于 JavaScript 来说,即使两个函数的功能完全一样,它们的引用地址也是不同的 (
() => {} !== () => {})。 UserList组件接收到的onUserClickprop 在每次渲染时都是一个“新”的函数。- 因此,React 会认为
UserList的 props 发生了变化,从而触发UserList的重新渲染,即使users数组本身没有变。
2. 属性钻孔 (Prop Drilling):
当组件层级很深时,一个顶层容器组件的数据可能需要穿透很多中间层组件才能到达最终的展示组件。
<AppContainer data={...}>
<PageLayout>
<SideBar>
<UserProfile data={...} /> // data 从 AppContainer 一路传递下来
</SideBar>
</PageLayout>
</AppContainer>
PageLayout 和 SideBar 可能根本不需要 data,它们只是充当了一个“快递员”。这不仅让代码变得冗长和难以维护,也增加了不必要重渲染的风险,因为中间任何一个组件的重渲染都可能影响到整条链路。
解决方案:如何优雅地应对
React 生态提供了多种强大的工具来解决上述问题。
1. 使用 React.memo 优化展示组件:
React.memo 是一个高阶组件,它可以“记住”一个组件的渲染输出。如果组件的 props 没有发生(浅层)变化,React 将跳过渲染该组件,直接复用上一次的渲染结果。
这是优化展示组件最直接、最有效的方法。
// src/components/UserList.js
import React from 'react';
// ... (PropTypes and component logic)
// 使用 React.memo 包裹你的展示组件
export default React.memo(UserList);
仅仅这样还不够!因为我们还需要确保传递给它的 props(尤其是函数和对象)是稳定的。
2. 使用 useCallback 和 useMemo 稳定 Props:
这两个 Hooks 是在容器组件或父组件中使用的,用来确保传递给子组件的 props 引用保持稳定。
useCallback(fn, deps): 返回一个 memoized(记忆化)的回调函数。只有当依赖项deps数组中的值发生变化时,它才会重新创建一个新的函数实例。useMemo(createFn, deps): 返回一个 memoized 的值。它会执行createFn并记住其结果。只有当依赖项deps数组中的值发生变化时,它才会重新计算。这对于避免在每次渲染时都重新创建复杂的对象或数组非常有用。
优化后的容器组件:
// src/containers/UserListContainer.js
import React, { useState, useEffect, useCallback } from 'react';
import UserList from '../components/UserList'; // 假设 UserList 已经被 React.memo 包裹
const UserListContainer = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// ... fetch users logic
}, []);
// 使用 useCallback 来保证 handleUserClick 函数的引用稳定
// 因为它不依赖任何外部变量,所以依赖项数组为空 []
const handleUserClick = useCallback((user) => {
alert(`你点击了用户: ${user.name}`);
}, []); // 空数组意味着此函数只在组件初次渲染时创建一次
if (loading) {
return <p>正在加载中...</p>;
}
// 现在,即使 UserListContainer 因为其他 state 重渲染,
// 只要 users 和 handleUserClick 的引用不变,
// 被 React.memo 包裹的 UserList 就不会重新渲染。
return <UserList users={users} onUserClick={handleUserClick} />;
};
export default UserListContainer;
组合使用 React.memo, useCallback, useMemo 是解决不必要重渲染问题的黄金搭档。
3. 使用组合 (Composition) 解决属性钻孔:
与其一层层地传递 props,不如使用 React 更强大的特性——组合。最常见的方式就是利用 children prop。
反模式 (Prop Drilling):
function Toolbar({ theme }) {
// Toolbar 把 theme 往下传,但它自己不用
return (
<div>
<ThemedButton theme={theme} />
</div>
);
}
function ThemedButton({ theme }) {
// ... 使用 theme
}
优雅的组合模式:
function Toolbar({ button }) {
// Toolbar 不再关心 button 的内部实现和 props
return (
<div>
{button}
</div>
);
}
function App() {
const theme = 'dark';
return (
// 在顶层就组合好最终的组件,然后把它作为一个整体传递下去
<Toolbar button={<ThemedButton theme={theme} />} />
);
}
// 或者利用 children
function Layout({ children }) {
return <div className="layout">{children}</div>;
}
function App() {
const data = { ... };
return (
<Layout>
<UserProfile data={data} />
</Layout>
);
}
通过组合,中间组件 (Toolbar, Layout) 变得更加通用和解耦,也避免了不必要的 props 传递。
4. 使用 Context API 或状态管理库:
当一个状态需要在组件树中被多个不同层级的组件共享时(例如:主题、用户信息、语言偏好等),“属性钻孔”就变得难以忍受。这时,就应该使用:
-
React Context: React 内置的解决方案,允许你创建一个全局的数据提供者(Provider),任何在它之下的子组件都可以通过消费者(Consumer)或
useContextHook 直接访问这些数据,无需 props 传递。 -
状态管理库 (Redux, Zustand, etc.): 对于更复杂的全局状态,这些库提供了更强大、更可预测的状态管理模式,并内置了高效的更新和选择机制(如 Redux 的
useSelector),可以确保组件只在它关心的那部分 state 变化时才重新渲染。
总结与建议
- 分离依然是王道: 将展示与逻辑分离带来的代码清晰度、可维护性和可测试性是巨大的长期收益。
- 性能问题是可解的: 不要因为担心性能而放弃好的架构。React 提供了完整的工具箱 (
memo,useCallback,useMemo,Context) 来处理这些问题。 - 按需优化: 不要过早优化。一开始,专注于清晰的组件划分。当你通过 React DevTools Profiler 等工具发现确实存在性能瓶颈时,再有针对性地使用
memo,useCallback等进行优化。 - 拥抱现代模式:
- 展示组件 → 默认用
React.memo包裹。 - 容器/逻辑组件 → 传递给子组件的回调函数用
useCallback包裹,传递给子组件的对象/数组用useMemo包裹。 - 深层数据传递 → 优先考虑组件组合 (
children),其次考虑使用Context或状态管理库。
- 展示组件 → 默认用
遵循这些原则,你就可以既享受到组件分离带来的架构好处,又能写出高性能的 React 应用。