返回文章列表

【杂谈】前端组件的功能边界和状态管理


我们都知道组件应该是纯的,无副作用的,幂等的,所以不应该在组件里引入 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的状态会越来越多。

此时应该做一下简单的分类:DialogGroupTableContainer,一个针对各个弹窗,另一个集合了各种按钮。

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 变得过于复杂,也不能为了追求"纯"而过度拆分。在实践中,我们需要根据具体场景来选择合适的方式,让代码既易于维护,又便于理解。