组件化(一):重新思考"组件":状态、视图和逻辑的"最佳"分离实践
引子:组件的"内忧"与"外患"
至此,我们的前端内功修炼之旅已经硕果累累。我们掌握了组件化的架构思想,拥有了高效的渲染引擎,还探索了从集中式到原子化的两种状态管理模式。我们似乎已经拥有了构建任何复杂应用所需的全套"武器"。
但魔鬼往往藏在细节中。今天,我们要将视线从宏观的架构和状态管理,拉回到我们日常工作中接触最频繁的单元------**组件(Component)**本身。
我们每天都在写组件,但我们是否真正思考过:一个"好"的组件,应该是什么样的?
想象一个常见的UserProfile
组件,它需要:
- 根据
userId
从API获取用户数据。 - 在获取数据时,显示一个加载中的
Spinner
。 - 数据获取成功后,显示用户的头像、姓名、简介。
- 如果获取失败,显示一条错误信息。
- 用户点击"关注"按钮时,调用另一个API,并更新按钮状态为"已关注"。
我们可以把所有这些逻辑都写在一个巨大的组件文件里。一开始,这似乎很方便。但随着时间的推移,问题暴露了:
- 复用性极差:如果另一个页面需要一个样式不同、但数据源相同的用户卡片,我们几乎无法复用这个组件的任何部分,只能复制粘贴代码。
- 测试困难 :要测试这个组件,你需要模拟
fetch
API、处理各种异步情况,还要断言最终渲染出的DOM结构。测试用例会变得异常复杂和脆弱。 - 职责不清:这个组件既关心"数据从哪来"(业务逻辑),又关心"数据长啥样"(UI渲染)。当需求变更时,比如只是想改个样式,你却可能要在一大堆数据处理逻辑中小心翼翼地穿行,反之亦然。这种混合的职责,让维护成为一场噩梦。
这就是一个组件的"内忧"------内部逻辑的混乱与耦合。而"外患",则是它与其他组件、与数据源之间纠缠不清的关系。
为了解决这个问题,Dan Abramov(Redux的作者之一)在2015年提出了一种影响深远的设计模式,它建议我们将组件清晰地一分为二:容器组件(Container Components)和展示组件(Presentational Components)。
今天,我们将不谈具体框架语法,只用最纯粹的JavaScript,来重新思考一个组件的"最佳"分离实践。
第一幕:两种"人格" - 容器与展示
这个模式的核心,就是将一个复杂的"智能"组件,拆分成两种不同"人格"的组件,让它们各司其职。
展示组件 (Presentational Components)
你可以把它想象成一个"UI木偶"或者一个"哑巴组件"(Dumb Component)。它的特点是:
- 只关心"如何展示"(How things look) :它的全部职责就是根据接收到的
props
来渲染UI。 - 不拥有自身的状态:它通常是无状态的(Stateless),除非是管理一些纯UI相关的、与业务无关的状态(比如一个动画的开关)。
- 不直接依赖数据源 :它不知道Redux、不知道Atom、也不知道API。它所需的所有数据,都必须由父组件通过
props
明确地传递给它。 - 通过回调函数与外界通信 :当需要触发某个业务操作时(如点击按钮),它不直接执行逻辑,而是调用一个从
props
中接收的回调函数(如props.onFollowClick
)。 - 高度可复用 :由于它与业务逻辑完全解耦,你可以轻松地在任何地方复用它,只要给它传入符合预期的
props
。它就像一个"皮肤",可以套在不同的"灵魂"上。
容器组件 (Container Components)
这则是那个"聪明的操偶师"(Smart Component)。它的特点是:
- 只关心"如何工作"(How things work):它的主要职责是管理状态和逻辑。
- 拥有和管理状态 :它可以是Class组件中的
state
,也可以是连接到Redux Store或原子化Store的逻辑。 - 与数据源通信 :它负责调用API、
dispatch
action、read/write
atom。 - 不包含复杂的UI结构 :它通常不包含自己的HTML标签(除了最外层的包裹
div
)。它的render
方法里,主要是渲染一个或多个展示组件 ,并将状态和回调函数作为props
传递给它们。 - 复用性较低:它通常是为特定业务场景定制的,与应用的特定部分紧密相关。
清晰的职责划分
特性 | 展示组件 (Presentational) | 容器组件 (Container) |
---|---|---|
主要目的 | UI渲染 (How things look) | 业务逻辑 (How things work) |
数据来源 | 从props 接收 |
管理自身状态,或从Store/API获取 |
状态感知 | 无(或只有纯UI状态) | 有 |
| 数据修改 | 调用props
中的回调函数 | 执行业务逻辑,调用API,dispatch
action |
| 依赖 | 无(除了UI库) | 依赖状态管理库、API服务等 |
| 可复用性 | 高 | 低 |
这种分离,就像是把一个人的"灵魂"(逻辑)和"肉体"(外表)分开。我们可以给同一个"灵魂"换上不同的"肉体"(比如Web版的UI和移动版的UI),也可以让同一个"肉体"被不同的"灵魂"所驱使(比如一个通用的Button
组件,可以用在登录、注册、购买等不同场景)。
第二幕:用纯JS模拟"组件分离"
现在,让我们回到最初那个UserProfile
组件的例子,用纯粹的、不依赖任何UI框架的JavaScript类和函数来实践这种分离模式。
我们将使用上一章的createElement
来描述UI,用renderToString
来"看到"结果。
步骤一:设计"哑巴"的UserProfileDisplay
组件
首先,我们来创建展示组件。它是一个纯函数,接收props
,返回一个描述UI的VNode。
presentational/UserProfileDisplay.js
javascript
// CSDN @ 你的用户名
// 系列: 前端内功修炼:从零构建一个"看不见"的应用
//
// 文件: /src/v7/presentational/UserProfileDisplay.js
// 描述: 一个纯粹的展示组件,负责渲染用户资料。
const { createElement } = require('../../v3/createElement');
/**
* 一个"哑巴"组件,它只知道如何根据props渲染UI。
* @param {object} props
* @param {boolean} props.isLoading - 是否正在加载
* @param {object | null} props.error - 错误对象
* @param {object | null} props.user - 用户数据 { avatar, name, bio }
* @param {boolean} props.isFollowing - 是否已关注
* @param {Function} props.onFollow - 点击关注按钮的回调
* @returns {object} VNode
*/
function UserProfileDisplay({ isLoading, error, user, isFollowing, onFollow }) {
if (isLoading) {
return createElement('div', { class: 'profile-card loading' }, 'Loading profile...');
}
if (error) {
return createElement('div', { class: 'profile-card error' }, `Error: ${error.message}`);
}
if (!user) {
return createElement('div', { class: 'profile-card empty' }, 'No user data.');
}
return createElement('div', { class: 'profile-card' },
createElement('img', { class: 'avatar', src: user.avatar }),
createElement('h2', { class: 'name' }, user.name),
createElement('p', { class: 'bio' }, user.bio),
createElement(
'button',
{
class: `follow-btn ${isFollowing ? 'following' : ''}`,
onClick: onFollow // 直接调用从props传来的回调
},
isFollowing ? 'Following' : 'Follow'
)
);
}
module.exports = { UserProfileDisplay };
看看这个组件有多么"纯粹":
- 它不包含任何
fetch
、setTimeout
或任何异步逻辑。 - 它没有自己的
state
。所有的动态数据(isLoading
,error
,user
...)都来自props
。 - 它完全不知道这些数据从何而来,也不知道点击"Follow"按钮后会发生什么。它只是一个忠实的"渲染仆人"。
- 你可以轻易地为它编写测试:只需传入不同的
props
组合,然后断言返回的VNode结构是否符合预期。
步骤二:创建"聪明"的UserProfileContainer
组件
接下来,是我们的"操偶师"------容器组件。它将负责所有的脏活累活。我们将用一个Class来模拟它,因为它需要管理内部状态(比如isLoading
)。
containers/UserProfileContainer.js
javascript
// CSDN @ 你的用户名
// 系列: 前端内功修炼:从零构建一个"看不见"的应用
//
// 文件: /src/v7/containers/UserProfileContainer.js
// 描述: 一个容器组件,负责用户资料的业务逻辑。
const { createElement } = require('../../v3/createElement');
const { UserProfileDisplay } = require('../presentational/UserProfileDisplay');
const { fetchUserData, followUserApi } = require('../api'); // 模拟的API服务
class UserProfileContainer {
constructor(props) {
this.props = props;
// 容器组件管理着所有的状态
this.state = {
isLoading: true,
error: null,
user: null,
isFollowing: false
};
// (在真实React中,这些会由生命周期方法和事件处理器处理)
// 这里我们手动调用来模拟流程
this._loadUserData();
}
// 模拟setState
setState(newState) {
this.state = { ...this.state, ...newState };
console.log('[Container] State changed:', this.state);
// 在真实应用中,setState会触发重新渲染
// 这里我们可以想象 render() 会被再次调用
}
// --- 业务逻辑 ---
async _loadUserData() {
try {
const user = await fetchUserData(this.props.userId);
this.setState({ user, isLoading: false });
} catch (error) {
this.setState({ error, isLoading: false });
}
}
_handleFollow = async () => {
// 防止重复点击
if (this.state.isFollowing) return;
console.log('[Container] Handling follow action...');
try {
await followUserApi(this.props.userId);
this.setState({ isFollowing: true });
} catch (err) {
console.error('Failed to follow user', err);
// 可以在这里设置一个短暂的错误提示状态
}
}
/**
* render方法的核心:
* 1. 准备好所有的props
* 2. 渲染展示组件
* 3. 把props传递下去
*/
render() {
console.log('[Container] Rendering UserProfileDisplay with props:', {
...this.state,
onFollow: 'a function' // 打印时简化函数
});
return createElement(UserProfileDisplay, {
...this.state, // 将所有状态作为props传递
onFollow: this._handleFollow, // 将逻辑处理函数作为回调传递
});
}
}
module.exports = { UserProfileContainer };
分析这个容器组件:
- 它不包含任何具体的HTML标签(除了在
createElement
中调用了UserProfileDisplay
)。它的UI完全委托给了展示组件。 - 它管理着所有与业务相关的状态:
isLoading
,error
,user
,isFollowing
。 - 它负责调用
fetchUserData
和followUserApi
这两个API,处理异步逻辑和错误。 - 最关键的是它的
render
方法:它做的唯一一件事就是渲染UserProfileDisplay
组件,并把自己的state
和_handleFollow
方法作为props
传递下去。
步骤三:组装与运行
最后,我们创建一个入口文件来"运行"我们的容器组件。
main.js
javascript
// CSDN @ 你的用户名
// 系列: 前端内功修炼:从零构建一个"看不见"的应用
//
// 文件: /src/v7/main.js
// 描述: 组装并"渲染"我们的组件。
const { renderToString } = require('../../v3/render');
const { UserProfileContainer } = require('./containers/UserProfileContainer');
const { createElement } = require('../../v3/createElement');
// 模拟API
jest.mock('./api', () => ({
fetchUserData: jest.fn().mockResolvedValue({
avatar: 'avatar.png',
name: 'Dan Abramov',
bio: 'Working on @reactjs. The demo king.'
}),
followUserApi: jest.fn().mockResolvedValue({ success: true })
}));
async function main() {
console.log('--- Initial Render ---');
// 1. 实例化容器组件
const app = new UserProfileContainer({ userId: 'dan_abramov' });
// 2. 第一次render (isLoading: true)
let vnode = app.render();
// 注意:我们的createElement现在支持函数作为type
// 为了渲染,我们需要"执行"这个函数组件来获取真正的VNode
if (typeof vnode.type === 'function') {
vnode = vnode.type(vnode.props);
}
console.log('\n--- HTML Output (Loading State) ---');
console.log(renderToString(vnode)); // 输出 loading...
// 3. 模拟异步数据加载完成
await new Promise(resolve => setTimeout(resolve, 100)); // 等待API调用
// 4. 第二次render (isLoading: false, user: data)
vnode = app.render();
if (typeof vnode.type === 'function') {
vnode = vnode.type(vnode.props);
}
console.log('\n--- HTML Output (Success State) ---');
console.log(renderToString(vnode)); // 输出用户信息
// 5. 模拟点击关注按钮
console.log('\n--- Simulating Follow Click ---');
// 在真实DOM中,onClick会绑定这个函数
await app._handleFollow();
// 6. 第三次render (isFollowing: true)
vnode = app.render();
if (typeof vnode.type === 'function') {
vnode = vnode.type(vnode.props);
}
console.log('\n--- HTML Output (Following State) ---');
console.log(renderToString(vnode)); // 输出 "Following" 按钮
}
main();
通过这个模拟流程,我们可以看到清晰的分工:UserProfileContainer
负责在不同的状态间切换(loading -> success -> following),而UserProfileDisplay
则忠实地根据传递下来的props
渲染出对应的UI。
结论:分离带来的巨大收益
我们为什么要费这么大劲,把一个组件拆成两个?这种分离模式,给我们带来了不可估量的好处:
-
极致的可复用性 :
UserProfileDisplay
成了一个UI"万金油"。我们可以用另一个完全不同的容器组件(比如MyProfileContainer
,它从本地localStorage读取数据)来包裹它,实现不同的业务逻辑,而UI保持一致。我们也可以在项目的故事书(Storybook)中,独立地测试和展示UserProfileDisplay
的各种UI状态。 -
清晰的关注点 :设计师和对UI/UX更感兴趣的前端工程师,可以专注于
presentational
目录下的组件,他们不需要关心任何业务逻辑。而负责业务逻辑和数据流的工程师,可以专注于containers
,他们不需要写太多的HTML/CSS。这促进了团队内部的协作。 -
惊人的可测试性 :测试
UserProfileDisplay
变得极其简单,它就是个纯函数,输入props
,断言输出的VNode。测试UserProfileContainer
也变得更聚焦,你可以模拟props
,然后断言它的内部state
变化是否正确,或者它是否调用了正确的API,而无需关心它到底渲染了什么DOM。 -
逻辑与视图解耦:这是最重要的。当应用的业务逻辑需要重构时(比如从REST API迁移到GraphQL),你可能只需要修改容器组件,而所有的展示组件都无需改动。反之,当应用需要进行UI改版时,你只需修改展示组件,容器组件可以保持不变。
但是,这个模式是银弹吗?
不是。随着React Hooks的出现,函数组件也能轻松地管理状态和副作用。组件的逻辑部分可以通过自定义Hooks(Custom Hooks)来抽离,这使得"容器/展示"的分离不再是唯一选择,甚至在某些场景下显得有些"过度设计"。
然而,这种分离的思想------将"如何工作"与"如何展示"解耦------是永恒的。无论你用的是类组件、Hooks还是其他框架,理解了这个核心思想,你就能写出更清晰、更健壮、更易于维护的组件。
在下一章 《组件化(二):Hook的本质:一个优雅的"副作用"管理模式》 中,我们将深入探讨React Hooks是如何从根本上改变组件逻辑复用方式的。我们将亲手模拟一个useState
和useEffect
的实现,揭示其在闭包和链表之上构建的优雅设计,看看它是如何成为比"容器/展示"模式更轻量、更灵活的逻辑分离方案的。敬请期待!