【杂谈】前端组件的功能边界和状态管理
我们都知道组件应该是纯的,无副作用的,幂等的,所以不应该在组件里引入 store。
我这里说的组件是指:一个按钮、一个对话弹窗、一个表单、一个表格。
这些组件应该处理好自己的事:把外界传入的数据渲染出来、需要处理各种事件时再 emit 出去,交给外界处理。
比如:
按钮的文案、主题类型该由外界传入,点击事件就应该 emit 出去。
对话框的内容就应该由外界传入,弹出事件、关闭事件就应该 emit 出去。
表单的数据模型、数据值就应该由外界传入,提交事件就应该 emit 出去。
表格渲染的数据就应该由外界传入,各种交互事件就应该 emit 出去。
这套规范让我代码分层很清晰,且工作良好。但也让我发现维护困难:
组件接受的 props 越来越多。
页面在引入这些组件的同时,自身需要引入的 hooks 也越来越多。
组件保持了简洁,但页面的逻辑逐渐混乱。
我想做一个简单的比喻:组件成了永远长不大的孩子,一直等着喂奶喝。而页面在引入组件的时候不得不负责照顾这些孩子。
container
有时候一个组件一个 props 都没有,但它就是可以解决你的需求,那它就是一个好组件。
这并不是说我要把 store 无节制的接入到各个组件,让状态处处使用、处处修改。而是说应该介于纯函数和滥用状态管理之间应该存在一个中间状态,一种约定。它既不像无状态组件那么纯,又不像一个页面那样引入了大量处理逻辑。假定这类组件叫container。
接下来我将介绍两种container:内聚型和组合型。
内聚型 container
内聚型container无论从设计思想还是实现方案都很像面向对象中的类。
不同的是,为了更好的可测试性和关注点分离,我们通常会把它拆分成 UI 层和 Hooks 层。
const useApplyForm = () => {
// 表单数据
const [formData, setFormData] = useState<ApplyFormData>({
name: "",
age: 0,
email: "",
description: ""
});
// 验证错误信息
const [errors, setErrors] = useState({});
// 提交状态
const [isSubmitting, setIsSubmitting] = useState(false);
// 表单验证
const validateForm = (): boolean => {
...
};
// 字段更新处理
const handleFieldChange = (field: keyof ApplyFormData, value: string | number) => {
...
};
// 表单提交处理
const handleSubmit = async () => {
...
};
// 重置表单
const resetForm = () => {
...
};
return {
...
};
};
export default useApplyForm;
const ApplyForm = ()=>{
const {handleSubmit, handleFieldChange, formData} = useApplyForm()
return <>
<Form>
<Input label="姓名" value={formData.name} onChange={handleFieldChange}></Input>
<Input label="年龄" value={formData.age} onChange={handleFieldChange}></Input>
...
<Button onClick={handleSubmit}>提交</Button>
</Form>
</>
}
组合型 container
组合型的 container 是一些无状态组件的集合。
举一个例子:
有一个功能丰富的表格页,表格每行数据都有若干个操作按钮。这些操作按钮的点击会对应一些弹窗。那么这个页面的代码看起来可能是这样的:
const TablePage = (props) => {
const { tableData, handleClick, flagA, flagB } = useTable();
return (
<>
<Table data={tableData}>
<ButtonA onClick={() => (flagA = true)}></ButtonA>
<ButtonB onClick={() => (flagB = true)}></ButtonB>
...
</Table>
<DialogA show={flagA}></DialogA>
<DialogB show={flagB}></DialogB>
...
</>
);
};
可以预见,随着功能越来越多,按钮的点击事件回调函数越来越多,类似DialogA的组件会越来越多,类似flagA的状态会越来越多。
此时应该做一下简单的分类:DialogGroup和TableContainer,一个针对各个弹窗,另一个集合了各种按钮。
const DialogGroup = () => {
const { flagA, flagB } = useDialogStore();
return (
<>
<DialogA show={flagA}></DialogA>
<DialogB show={flagB}></DialogB>
...
</>
);
};
const TableContainer = () => {
const { flagA, flagB } = useDialogStore();
const { tableData } = useTable();
return (
<Table data={tableData}>
<ButtonA onClick={() => (flagA = true)}></ButtonA>
<ButtonB onClick={() => (flagB = true)}></ButtonB>
...
</Table>
);
};
难以维护的表格页代码变成了这样,简洁、好维护。
const TablePage = (props) => {
return (
<>
<TableContainer />
<DialogGroup />
</>
);
};
总结
在实际项目中,我建议创建专门的 containers 目录来存放这些组件。这不仅是物理上的分离,更是逻辑上的区分:components 目录存放纯组件,containers 目录存放业务容器,pages 目录则用于组织页面级组件。
对于命名,我们可以用功能来命名纯组件(如 Button、Dialog),用业务领域来命名 Container(如 UserModule、OrderFlow)。这样的命名方式能够直观地体现出组件的职责。
Container 模式让我们在保持基础组件纯粹性的同时,也能够更好地组织业务逻辑。关键是要把握好职责边界,既不能让 Container 变得过于复杂,也不能为了追求"纯"而过度拆分。在实践中,我们需要根据具体场景来选择合适的方式,让代码既易于维护,又便于理解。