在动手开发一个组件前,最好要先设计好这个组件,有明确的思路为后续的高效开发提供保障。由此本篇文章将介绍几个设计组件的点。
一、组件划分
组件设计的核心在于明确划分组件的作用域(即边界) ,并结合分治思想和单一职责原则,将复杂的界面或功能拆分为多个职责单一、易于维护的组件。
组件的边界,就是要明确每个组件“应该做什么”和“不应该做什么”。
- 复杂组件确定边界,最为推荐的划分方式还是以业务模块来划分组件边界,与该业务模块相关的组件都放在该目录下,包括子组件、状态仓库等。
- 复杂组件可以再通过以下两点 去划分成 子组件/原子组件。
分治思想
将复杂的页面或功能拆解为若干小组件,每个组件独立开发和维护,最后组合成完整的页面。
单一原则
每个组件应只关注一个功能,避免一个组件承担过多职责,导致代码难以维护。
-
****在实际开发中,可以进一步细分为 容器组件 和 UI组件:
- 容器组件:主要负责数据获取、状态管理和业务逻辑,不负责具体的界面展示。它将数据和回调传递给子组件。
- UI组件:只负责界面展示,根据 props 渲染内容,不涉及业务逻辑和数据获取。
二、通用性设计
用性设计意味着组件不再“死死地”掌控自己的 DOM 结构,而是将部分结构和渲染的决定权交给使用者。这样做的好处是大大提升了组件的灵活性和可复用性,使其能够适应更多业务场景。
- 预设默认渲染行为
优秀的通用组件通常会为大多数场景预设合理的默认渲染方式,开发者可以直接使用,无需关心细节。例如,一个 Button 组件默认会渲染一个标准按钮。
- 支持自定义渲染
当业务有特殊需求时,组件还应支持自定义渲染。这通常通过“插槽(slot)”、“render props”、“自定义组件”或“函数式子组件”来实现。
示例代码
// 通用列表组件 List
function List({ data, renderItem }) {
return (
<ul>
{data.map((item, idx) => (
<li key={idx}>
{/* 支持自定义渲染 */}
{renderItem ? renderItem(item) : <span>{item.text}</span>}
</li>
))}
</ul>
);
}
// 使用默认渲染
<List data={[{ text: '苹果' }, { text: '香蕉' }]} />
// 使用自定义渲染
<List
data={[{ text: '苹果', price: 5 }, { text: '香蕉', price: 3 }]}
renderItem={item => (
<div>
<strong>{item.text}</strong> - <em>¥{item.price}</em>
</div>
)}
/>
- 组件内部为每个列表项预设了默认渲染方式
<span>{item.text}</span>。 - 通过
renderItem参数支持自定义渲染,开发者可以决定每一项的 DOM 结构。
组件应为常见用法预设默认渲染,方便快速使用。同时支持通过参数、插槽、render props 等方式自定义渲染,提升灵活性。不强制规定 DOM 结构,保留可扩展的“空位”或“钩子”给业务开发者。通用组件可以适配更多场景,减少重复开发,提升代码复用率。
三、受控组件与非受控组件
1.受控组件
是指组件的表单元素(如<input>、<textarea>、<select>等)的值完全由 React 的 state 控制。用户的任何输入都会通过事件(如onChange)回调,更新 state,从而推动 UI 的变化。组件的“真相”存储在 React 的数据流中,而不是 DOM 节点自身。
适用场景
- 需要对输入过程进行实时校验(如表单校验、格式化)。
- 多个表单项之间需要联动。
- 需要统一管理、重置、回显表单数据。
- 复杂的交互逻辑和数据流。
如下组件,就是典型的数据主要由 React 的 state 控制,更符合单一数据源原则。
function ControlledForm() {
const [username, setUsername] = useState('');
const [age, setAge] = useState('');
function handleSubmit(e) {
e.preventDefault();
alert(`用户名: ${username}, 年龄: ${age}`);
// 可以直接用 state 里的值做校验、提交等操作
}
return (
<form onSubmit={handleSubmit}>
<label>
用户名:
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
/>
</label>
<br />
<label>
年龄:
<input
type="number"
value={age}
onChange={e => setAge(e.target.value)}
/>
</label>
<br />
<button type="submit">提交</button>
</form>
);
}
2.非受控组件
****是指非受控组件是指表单元素的值由 DOM 自己维护,React 并不直接干预输入过程。需要时,可以通过 ref 获取 DOM 节点的当前值。这种方式更接近传统 HTML 表单的处理方式。
适用场景
- 表单较简单,不需要实时校验或联动。
- 仅在表单提交时需要获取数据。
- 需要与第三方非 React 库集成,或对性能要求极高时。
如下组件就是直接通过 ref 去获取 DOM 元素的值,值并不由 React 进行控制,该组件自己输入,减少等等
function UncontrolledForm() {
const usernameRef = useRef();
const ageRef = useRef();
function handleSubmit(e) {
e.preventDefault();
alert(`用户名: ${usernameRef.current.value}, 年龄: ${ageRef.current.value}`);
// 这里直接从 DOM 获取值
}
return (
<form onSubmit={handleSubmit}>
<label>
用户名:
<input type="text" ref={usernameRef} />
</label>
<br />
<label>
年龄:
<input type="number" ref={ageRef} />
</label>
<br />
<button type="submit">提交</button>
</form>
);
}
日常开发优先选用受控组件,保证数据流可控和一致性。非受控组件适合临时性、简单场景,或与第三方库集成时使用。可以混合使用,例如大表单中部分字段为受控,部分为非受控(如文件上传)。
四、数据驱动
数据流主要分两方面。首先是确定数据源头 —> 单一数据源,然后就是数据流动 —> 单向数据流。
1.单一数据源
即存放数据的源头越少越好,一个数据源就相当于一个自变量,越多的数据源,即意味着导致变化的因素会越多,变更的路径也越难追踪。
以下组件就是违背了单一数据源原则,组件内部维护了 searchResult,而外部已经有 options。searchResult 实际上是 options 的一个派生(过滤)结果,但它被单独存储了。这导致同一份数据(当前下拉列表的数据)有两份来源:options 和 searchResult。 OptionList 有时用 searchResult,有时用 options,这让“当前显示的数据”没有唯一权威的数据源。如果 options 变化,searchResult 可能不会自动同步,导致数据不同步和维护困难。
function SelectDropdown({ options = [], onFilter }: SelectDropdownProps) {
// 缓存搜索结果
const [searchResult, setSearchResult] = React.useState<Option[] | undefined>(undefined);
return (
<div>
<Input.Search
onSearch={(keyword) => {
setSearchResult(keyword ? onFilter(keyword) : undefined);
}}
/>
<OptionList options={searchResult ?? options} />
</div>
);
}
以下组件维护了单一数据源的原则,只维护了 keyword 作为状态,currentOptions 是纯计算出来的派生数据,没有存储过滤结果的副本。组件的展示内容始终能从唯一的数据源(options + keyword)推导出来。
function SelectDropdown({ options = [], onFilter }: SelectDropdownProps) {
// 搜索关键词
const [keyword, setKeyword] = React.useState<string | undefined>(undefined);
// 使用过滤条件筛选数据
const currentOptions = React.useMemo(() => {
return keyword && onFilter ? options.filter((n) => onFilter(keyword, n)) : options;
}, [options, onFilter, keyword]);
return (
<div>
<Input.Search
onSearch={(text) => {
setKeyword(text);
}}
/>
<OptionList options={currentOptions} />
</div>
);
}
2.单向数据流
是指数据在组件之间传递时,始终沿着固定的方向流动,通常是“父组件 → 子组件” 。子组件不能直接修改父组件的数据,只能通过回调函数通知父组件,由父组件决定是否更新数据。这种模式保证了数据流动路径的唯一性和可追踪性。
Parent 组件拥有 username 这份数据(state),是数据的唯一来源。Input 组件通过 props 接收 username,只能通过 onChange 通知父组件修改,不能自己直接修改 username。数据流动方向:Parent(username)→ Input(value)→ 用户输入 → Input 调用 onChange → Parent(setUsername)→ 重新渲染。
// 子组件,只负责展示和通知,不维护数据
function Input({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />;
}
function Parent() {
const [username, setUsername] = React.useState('小明');
return (
<div>
<Input value={username} onChange={setUsername} />
<p>当前用户名:{username}</p>
</div>
);
}
结合前面 SelectDropdown 的例子
function SelectDropdown({ options = [], onFilter }) {
const [keyword, setKeyword] = React.useState('');
const currentOptions = React.useMemo(() => {
return keyword && onFilter ? options.filter((n) => onFilter(keyword, n)) : options;
}, [options, onFilter, keyword]);
return (
<div>
<Input.Search onSearch={setKeyword} />
<OptionList options={currentOptions} />
</div>
);
}
SelectDropdown 组件内部维护 keyword 状态,所有选项的展示都由 keyword 和 options 推导。Input.Search 组件通过 props 接收 onSearch 回调,用户输入时通过 onSearch 通知父组件更新 keyword。OptionList 只负责展示 currentOptions,不参与数据的过滤和管理。
数据流动:父组件(状态/props)→ 子组件(展示/通知)→ 父组件(回调更新)→ 子组件(重新渲染) ,始终是单向的。
结语
动手敲代码前强烈建议先设计好组件,可以从以上四个方向去考虑,先确定好边界范围和划分,考虑适当通用性,再去确定设计模式和规范数据流动。