通用组件设计

326 阅读7分钟

在动手开发一个组件前,最好要先设计好这个组件,有明确的思路为后续的高效开发提供保障。由此本篇文章将介绍几个设计组件的点。

一、组件划分

组件设计的核心在于明确划分组件的作用域(即边界) ,并结合分治思想单一职责原则,将复杂的界面或功能拆分为多个职责单一、易于维护的组件。

组件的边界,就是要明确每个组件“应该做什么”和“不应该做什么”。

  • 复杂组件确定边界,最为推荐的划分方式还是以业务模块来划分组件边界,与该业务模块相关的组件都放在该目录下,包括子组件、状态仓库等。
  • 复杂组件可以再通过以下两点 去划分成 子组件/原子组件。

分治思想

将复杂的页面或功能拆解为若干小组件,每个组件独立开发和维护,最后组合成完整的页面。

单一原则

每个组件应只关注一个功能,避免一个组件承担过多职责,导致代码难以维护。

  • ****在实际开发中,可以进一步细分为 容器组件 和 UI组件

    • 容器组件:主要负责数据获取、状态管理和业务逻辑,不负责具体的界面展示。它将数据和回调传递给子组件。
    • UI组件:只负责界面展示,根据 props 渲染内容,不涉及业务逻辑和数据获取。

二、通用性设计

用性设计意味着组件不再“死死地”掌控自己的 DOM 结构,而是将部分结构和渲染的决定权交给使用者。这样做的好处是大大提升了组件的灵活性和可复用性,使其能够适应更多业务场景。

  1. 预设默认渲染行为

优秀的通用组件通常会为大多数场景预设合理的默认渲染方式,开发者可以直接使用,无需关心细节。例如,一个 Button 组件默认会渲染一个标准按钮。

  1. 支持自定义渲染

当业务有特殊需求时,组件还应支持自定义渲染。这通常通过“插槽(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,而外部已经有 optionssearchResult 实际上是 options 的一个派生(过滤)结果,但它被单独存储了。这导致同一份数据(当前下拉列表的数据)有两份来源:optionssearchResultOptionList 有时用 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)→ 子组件(展示/通知)→ 父组件(回调更新)→ 子组件(重新渲染) ,始终是单向的。

结语

动手敲代码前强烈建议先设计好组件,可以从以上四个方向去考虑,先确定好边界范围和划分,考虑适当通用性,再去确定设计模式和规范数据流动。