React 极简组件:别让 UI 和逻辑在同一个房间里吵架
各位同学,大家好!欢迎来到今天的“代码重构与架构艺术”专场。
我是你们的老朋友,一个在 React 代码里摸爬滚打多年,头发比发际线退得还慢的技术老鸟。今天我们不聊虚的,也不搞那些花里胡哨的架构图,我们就来聊聊一个极其接地气,但又让无数初级工程师(甚至部分中级工程师)抓耳挠腮的问题——如何把你的“面条代码”变成“意大利面”,然后再变成“精致的意式料理”。
核心话题只有一个:React 极简组件(Presentational Components)。听起来很高大上,其实说白了就是:把 UI 摆在一边,把逻辑关进笼子,让它们互不干扰,各自安好。
为什么?因为如果你不这么做,你的组件就会变成那种“喝多了酒之后写的代码”——逻辑和 UI 混在一起,全是乱码,谁看了都想报警。
第一章:UI 是脸,逻辑是脑子,别把脑子缝在脸上
首先,我们来做个思想实验。
想象一下,你是个装修工。你有一块木板,你想把它变成一个衣柜。为了实现这个功能,你需要锯子、钉子、螺丝,还需要计算怎么切割才能最大化利用空间,还要考虑承重。这所有的计算、决策、工具使用,就是业务逻辑。
然后,你把这块木板刷成白色,装上把手,钉上合页,变成了一个好看的衣柜。这就是UI 展示。
现在,如果有人要求你把那个“计算承重”和“计算切割角度”的算法直接写在那块木板里,你会答应吗?你肯定会说:“神经病啊,那木板怎么放得下算法?而且我以后想把衣柜做成红色的,难道要把算法也涂成红色吗?”
在 React 里,很多初学者就是这么干的。他们写了一个组件,里面既有 fetchData,又有 useState,最后还混进了一大堆 JSX。这就好比把脑子缝在脸上,你说这玩意儿还能看吗?
什么是 Presentational Component(展示层组件)?
它是那个“木板”。
- 只负责: 渲染 HTML/CSS,接收 Props,处理用户交互(但只是交互本身,比如
onClick={() => handleClick()},而不是onClick={apiCall})。 - 不关心: 数据从哪来(它只知道有数据),数据怎么存(它只知道怎么画出来)。
什么是 Container Component(容器层组件)?
它是那个“装修工”。
- 只负责: 管理数据状态,调用 API,处理复杂的业务逻辑,计算数据,然后把数据通过 Props 传给那个“木板”。
- 不关心: 那个“木板”具体画的是什么颜色,是圆的还是方的,它只管把数据喂给它。
第二章:当你把“上帝”写进组件时,世界就乱了
让我们先看看一个典型的“反面教材”。假设我们要做一个用户列表。
场景:一个包含所有功能的 UserList.js
// UserList.js —— 这是典型的“屎山”诞生地
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const UserList = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filterText, setFilterText] = useState('');
// 1. 业务逻辑:获取数据
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const response = await axios.get('https://api.example.com/users');
setUsers(response.data);
} catch (err) {
setError('抓取数据失败,可能是网络挂了');
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
// 2. 业务逻辑:筛选
const handleFilterChange = (e) => {
setFilterText(e.target.value);
};
// 3. UI 渲染:混杂在一起
if (loading) return <div className="spinner">加载中...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="user-list-container">
<input
type="text"
placeholder="输入名字筛选..."
onChange={handleFilterChange}
className="filter-input"
/>
<ul>
{users
.filter(user => user.name.toLowerCase().includes(filterText.toLowerCase()))
.map(user => (
<li key={user.id} className="user-item">
{/* 这里的样式和结构非常脆弱 */}
<img src={user.avatar} alt={user.name} />
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
</li>
))}
</ul>
</div>
);
};
export default UserList;
点评:
看这段代码,是不是很眼熟?它完美地展示了“职责不清”的后果。
- 复用性极差: 假设老板突然说:“UserList 不行,我要一个 UserCard 组件,放在首页里。”
- 你想怎么做? 你得把
useEffect、axios、filter逻辑全删掉,只留下map循环和 JSX。这太痛苦了! - 维护噩梦: 如果 API 返回格式变了,或者筛选逻辑变复杂了,你不仅要改逻辑,还得担心会不会误伤了 UI 样式。
这就是我们今天要解决的问题:解耦。
第三章:Hooks —— 现代解耦的瑞士军刀
React Hooks 的诞生,简直就是给“逻辑复用”和“UI 解耦”打了一针强心剂。我们要做的第一件事,就是从组件里把“脑子”抠出来。
步骤一:提取业务逻辑
我们创建一个文件 useUsers.js。
// useUsers.js —— 这是纯粹的“大脑”
import { useState, useEffect } from 'react';
import axios from 'axios';
export const useUsers = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filterText, setFilterText] = useState('');
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const response = await axios.get('https://api.example.com/users');
setUsers(response.data);
} catch (err) {
setError('抓取数据失败,可能是网络挂了');
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
const handleFilterChange = (e) => {
setFilterText(e.target.value);
};
// 返回筛选后的数据,而不是原始数据
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(filterText.toLowerCase())
);
return {
users: filteredUsers,
loading,
error,
setFilterText, // 暴露 setter 以便外部控制
handleFilterChange
};
};
看! 这个 Hook 里面没有 JSX,没有 className,没有 <div>。它就是一个纯函数,一个纯逻辑单元。它只关心数据流。
步骤二:极简组件登场
现在,原来的 UserList 组件变得非常“极简”了。
// UserList.js —— 现在它只是一个“脸面”
import React from 'react';
import { useUsers } from './useUsers';
const UserList = () => {
// 1. 只管拿数据,不管数据怎么来的
const { users, loading, error, setFilterText } = useUsers();
// 2. 只管处理交互,把结果传给 UI
const handleFilterChange = (e) => {
setFilterText(e.target.value);
};
// 3. 只管渲染
if (loading) return <div className="spinner">加载中...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="user-list-container">
<input
type="text"
placeholder="输入名字筛选..."
onChange={handleFilterChange}
className="filter-input"
/>
<ul>
{users.map(user => (
<UserItem key={user.id} user={user} />
))}
</ul>
</div>
);
};
// 进一步拆分:UserItem 也是展示层组件
const UserItem = ({ user }) => (
<li className="user-item">
<img src={user.avatar} alt={user.name} />
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
</li>
);
export default UserList;
神奇的效果:
- 逻辑复用: 现在你可以把这个
useUsersHook 用在UserCard(首页卡片)、UserTable(后台表格)或者UserChart(数据图表)里。不管 UI 是什么样,逻辑都是那一套。 - UI 复用:
UserItem组件现在非常干净,它只接受user这个 prop。你可以把它放在任何地方,甚至用 React Native 写个UserItem,逻辑完全不用动。
第四章:Props 传递的艺术 —— 别让数据“迷路”
在解耦的过程中,Props 是我们最亲密的战友,也是最尴尬的累赘。为什么尴尬?因为“Props Drilling”(属性钻透)。
想象一下,你的逻辑层在 App.js,UI 层在 ComponentA -> ComponentB -> ComponentC。逻辑层想把 onSubmit 函数传给 ComponentC,你得像接力赛一样,一层层往下传。
// App.js
const App = () => {
const handleSubmit = () => { console.log('提交了'); };
return (
<Container>
<ComponentA handleSubmit={handleSubmit} />
</Container>
);
};
// ComponentA.js
const ComponentA = ({ handleSubmit }) => {
return <ComponentB handleSubmit={handleSubmit} />;
};
// ComponentB.js
const ComponentB = ({ handleSubmit }) => {
return <ComponentC handleSubmit={handleSubmit} />;
};
// ComponentC.js
const ComponentC = ({ handleSubmit }) => {
return <button onClick={handleSubmit}>提交</button>;
};
这简直是代码界的“传声筒游戏”,而且传错了没人负责。
极简组件的解耦之道:
展示层组件(Presentational)不应该关心数据是怎么来的,它只需要知道“我有这个数据”和“我有这个操作方法”。
最佳实践:
- UI Props: 把那些控制 UI 行为的函数(如
onClick,onChange)传给展示层。展示层只负责“触发”。 - Data Props: 把数据传给展示层。展示层只负责“渲染”。
代码示例:
// Presentation 组件:它只知道“我有一个按钮,点击它”
const SubmitButton = ({ onClick, disabled, text }) => (
<button
onClick={onClick}
disabled={disabled}
className="primary-button"
>
{text || '提交'}
</button>
);
// Container 组件:它知道逻辑,并决定何时点击
const UserForm = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
// 调用 API...
await fakeApiCall();
setIsSubmitting(false);
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<InputField label="用户名" />
<InputField label="密码" type="password" />
{/* 把逻辑层的状态(isSubmitting)传给 UI 层,控制 UI 状态 */}
<SubmitButton
onClick={handleSubmit}
disabled={isSubmitting}
text={isSubmitting ? "提交中..." : "注册"}
/>
</form>
);
};
在这里,SubmitButton 是一个极简组件。它不在乎你是在注册还是在登录,也不在乎 API 是什么。它只是根据你传进来的 disabled 和 text,乖乖地显示一个按钮。
第五章:HOC(高阶组件) —— 老派的解耦魔法
虽然 Hooks 很好,但在某些场景下,HOC 依然是一把利器。HOC 本质上就是一个函数,接收一个组件,返回一个新组件。
HOC 的经典用途是“增强”。比如,我们有很多组件都需要“加载中”的状态,难道每个组件都要写一遍 useEffect 吗?不,我们用一个 HOC 包一层。
示例:withLoading HOC
// withLoading.js
import React from 'react';
// 这是一个通用的“加载中”包装器
export const withLoading = (WrappedComponent, LoadingFallback) => {
return class extends React.Component {
render() {
const { isLoading, ...props } = this.props;
if (isLoading) {
// 如果加载中,展示 LoadingFallback
return <LoadingFallback />;
}
// 否则,展示被包装的组件,并透传所有 props
return <WrappedComponent {...props} />;
}
};
};
使用 HOC 解耦
// UserProfile.js (纯展示层)
const UserProfile = ({ user }) => (
<div className="profile-card">
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
// useUserProfile.js (纯逻辑层)
const useUserProfile = (userId) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false));
}, [userId]);
return { user, loading };
};
// App.js (组合层)
const App = () => {
const { user, loading } = useUserProfile(123);
// 把逻辑层的数据和状态传给 HOC
const UserProfileWithLoader = withLoading(UserProfile, <div>Loading...</div>);
return <UserProfileWithLoader user={user} isLoading={loading} />;
};
为什么这样做?
你看,UserProfile 组件完全不知道 loading 是怎么回事,它也不需要知道。它只管渲染 user。所有的状态管理、数据获取逻辑都藏在 useUserProfile 和 withLoading 里。
这就是解耦的精髓:逻辑层负责“我有”,HOC 负责“我能不能显示”,UI 层负责“怎么显示”。
第六章:组件组合 —— UI 的乐高积木
极简组件的终极形态,是利用组合。
不要试图在一个组件里把所有样式、所有功能都写死。要像搭乐高积木一样。
场景:构建一个复杂的表单组件
假设我们要做一个“编辑用户”页面。我们需要名字、邮箱、年龄、性别。
糟糕的做法:
写一个 EditUserForm 组件,里面写满了 <input>,写满了 useState,写满了校验逻辑。想换个布局?重新写。
极简的做法:
-
拆分输入框:
TextInput:处理文本输入。NumberInput:处理数字输入。SelectInput:处理下拉选择。CheckboxInput:处理复选框。- 这些组件只负责 UI,不负责校验逻辑,只负责把值传出来。
-
组合:
const EditUserForm = () => { const [formData, setFormData] = useState({ name: '', age: 0, gender: 'male' }); const handleChange = (field, value) => { setFormData(prev => ({ ...prev, [field]: value })); }; return ( <form className="edit-form"> <TextInput label="姓名" value={formData.name} onChange={(val) => handleChange('name', val)} /> <div className="form-row"> <NumberInput label="年龄" value={formData.age} onChange={(val) => handleChange('age', val)} /> <SelectInput label="性别" value={formData.gender} options={['male', 'female', 'other']} onChange={(val) => handleChange('gender', val)} /> </div> <SubmitButton onClick={() => console.log(formData)}>保存</SubmitButton> </form> ); };
在这个例子中,TextInput、NumberInput 都是极简组件。你可以把 TextInput 拿去用在“搜索框”里,或者用在“评论框”里。只要传入不同的 label 和 onChange,它就能胜任不同的工作。
关键点:
极简组件应该无状态(Stateless)。如果它需要保存数据,那它就不再是“展示”了,它就变成了“容器”或“逻辑组件”。展示层组件只接收 Props,渲染 DOM。
第七章:业务逻辑的边界 —— 什么时候该抽离?
很多同学在抽离逻辑时犹豫不决:“这个逻辑是不是太简单了,没必要抽成 Hook?”
我的建议: 只要这个逻辑涉及到状态的变化,或者副作用(API 调用、定时器、订阅),就把它抽出来。
让我们看一个稍微复杂点的例子:分页逻辑。
假设你有一个列表组件。它需要知道:
- 当前页码。
- 每页显示多少条。
- 总数据量。
- 翻页函数。
不要写死在组件里:
// Bad Example
const ProductList = () => {
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
// 每次翻页都要重新请求 API,逻辑很重
useEffect(() => {
fetchProducts(page, pageSize).then(...);
}, [page, pageSize]);
return (
<div>
<Pagination
current={page}
onChange={setPage} // 传递 setter
total={100}
/>
<ProductGrid products={products} />
</div>
);
};
抽离逻辑:
// usePagination.js
export const usePagination = (totalItems) => {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
// 计算分页数据
const totalPages = Math.ceil(totalItems / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const goToPage = (page) => setCurrentPage(page);
return {
pagination: {
current: currentPage,
pageSize,
total: totalPages,
onChange: goToPage
},
range: {
start: startIndex,
end: endIndex
}
};
};
现在,ProductList 变得非常干净:
const ProductList = () => {
const { pagination, range } = usePagination(100);
const products = fetchProducts(range.start, range.end); // 只传索引,不传页码
return (
<div>
<Pagination {...pagination} /> {/* 传递整个对象,解耦更彻底 */}
<ProductGrid products={products} />
</div>
);
};
为什么要这样做?
因为 usePagination 这个 Hook 现在可以用于任何分页场景:文章列表、商品列表、日志列表。它不关心数据长什么样,它只关心“怎么切分”。
第八章:极致复用 —— 让组件去流浪
当我们把 UI 和逻辑解耦到极致,会发生什么?
会发生组件的“流浪”。
假设你开发了一个漂亮的 SearchBar 组件。它只是一个输入框,带个搜索图标。
场景 A: 在 Web 端,你把它渲染成 <input type="text" />。
场景 B: 你要把这个项目移植到 React Native,或者做一个微信小程序版本。
场景 C: 你想把它做成一个通用的 UI 库,供其他团队使用。
如果逻辑和 UI 紧耦合,你需要重写 90% 的代码。
但如果它们是解耦的:
// SearchBar.js (纯展示层)
const SearchBar = ({ value, onChange, placeholder, icon }) => (
<div className="search-bar">
<span className="search-icon">{icon}</span>
<input
type="text"
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</div>
);
这个组件现在非常轻量。你可以把它放在任何地方。你甚至不需要知道它是怎么被调用的。
自定义 Hooks 的复用性
这是 Hooks 带来的最大红利。你可以写一个 useDebounce Hook。
// useDebounce.js
export const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
现在,你在任何地方需要防抖输入,直接调用它。
const SearchInput = () => {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
// 只有当 debouncedQuery 变化时才执行搜索
if (debouncedQuery) {
searchApi(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
};
注意看,SearchInput 组件里并没有处理“防抖”的 UI 逻辑(比如显示“正在输入…”的 Loading),它只是调用了 Hook。逻辑完全被隔离了。
第九章:CSS 与样式 —— 别让样式污染了逻辑
UI 展示不仅仅是 HTML 结构。样式也是展示层的一部分。
极简组件应该尽可能少地依赖外部样式,或者使用 CSS Modules、Styled Components 这种方式,确保样式作用域隔离。
反模式: 在组件内部写 CSS,或者使用全局类名。
// 糟糕的例子
const MyComponent = () => {
return (
<div style={{ background: 'red', padding: '10px' }}>...</div>
);
};
好的做法:
- CSS Modules:
import styles from './MyComponent.css'。组件只负责className={styles.container}。 - Styled Components:
const Container = styled.div。逻辑和样式在同一个文件里,但依然保持了结构清晰。
为什么?
因为样式通常是视觉层面的。如果逻辑层组件负责渲染样式,那它就变成了“视觉层”。当 UI 设计师要求改颜色时,改的是样式文件,而不是逻辑文件。
第十章:实战演练 —— 从“面条”到“拉面”
让我们回到最开始那个 UserList,做一次彻底的手术。
现状: 500 行代码,逻辑、样式、状态全混在一起。
目标: 拆分成 10 个小文件,逻辑复用,样式复用。
重构计划:
hooks/useUsers.js:管理用户数据、筛选、加载状态。components/UserItem.js:渲染单个用户卡片。components/UserList.js:渲染列表容器,组合useUsers和UserItem。components/UserFilter.js:渲染筛选框(可以复用)。styles/UserList.css:列表样式。styles/UserItem.css:单项样式。
最终效果代码:
// hooks/useUsers.js (逻辑核心)
export const useUsers = () => {
const [data, setData] = useState([]);
const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUsers().then(setData).catch(setError).finally(() => setLoading(false));
}, []);
const filteredData = data.filter(u => u.name.includes(filter));
return { data: filteredData, filter, setFilter, loading, error };
};
// components/UserItem.js (极简展示)
const UserItem = ({ user }) => (
<div className="user-item">
<img src={user.avatar} alt={user.name} />
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
</div>
);
export default UserItem;
// components/UserList.js (容器与组装)
import React from 'react';
import { useUsers } from '../hooks/useUsers';
import UserItem from './UserItem';
import UserFilter from './UserFilter';
const UserList = () => {
const { data, filter, setFilter, loading } = useUsers();
if (loading) return <div className="loading">Loading...</div>;
return (
<div className="user-list">
<UserFilter value={filter} onChange={setFilter} />
{data.map(user => <UserItem key={user.id} user={user} />)}
</div>
);
};
export default UserList;
看! 是不是清爽多了?
UserList只关心“怎么显示列表”和“怎么筛选”。UserItem只关心“怎么显示一个人”。useUsers只关心“怎么获取数据”。- 如果以后你要把
UserList换成UserTable,你只需要改UserList组件的map部分,逻辑层 (useUsers) 和展示层 (UserItem) 一点都不用动。
总结
同学们,今天我们聊了这么多,其实核心思想就一句话:单一职责原则。
- UI 组件(Presentational): 只负责“画”。它像是一个画师,手里拿着画笔(Props),在画布(DOM)上画画。它不关心画的是什么,只关心怎么画得好看。
- 容器/逻辑组件: 只负责“想”。它像是一个策划,决定画什么,画在哪个位置,怎么根据用户的反应调整画笔。
当你把 UI 和逻辑解耦后,你会发现代码变得像乐高积木一样好玩。你可以随意组合,随意替换,甚至可以把一个组件移植到另一个项目里。
记住,不要试图在一个组件里解决所有问题。那个组件会累死的,而且会变得非常丑陋。
把逻辑藏进 Hooks,把 UI 摆在组件里,把样式交给 CSS。 这就是 React 极简组件的艺术。
现在,拿起你的键盘,去把你那堆“屎山”代码拆开吧!祝你好运,别把脑子缝在脸上!