51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

React应用:封装CustomSelect组件思路

React应用:封装CustomSelect组件思路。

需要封装一个通过Popover弹出框里可以自定义渲染内容的组件,渲染内容暂时有: 单选框, 复选框。在封装组件时我们需要权衡组件的灵活性, 拓展性以及代码的优雅规范,总结分享少许经验。

思路和前提

由于考虑组件拆分得比较细,层级比较多,为了方便使用了React.createContext + useContext作为参数向下传递的方式。

首先需要知道antd的Popover组件是继承自Tooltip组件的,而我们的CustomSelect组件是继承自Popover组件的。对于这种基于某个组件的二次封装,其props类型一般有两种方式处理: 继承, 合并。

interface IProps extends XXX;
type IProps = Omit<TooltipProps, 'overlay'> & {...};

对于Popover有个很重要的触发类型: trigger,默认有四种"hover" "focus" "click" "contextMenu", 并且可以使用数组设置多个触发行为。但是我们的需求只需要"hover"和"click", 所以需要对该字段进行覆盖。

对于Select, Checkbox这种表单控件来说,对齐二次封装,很多时候需要进行采用'受控组件'的方案,通过'value' + 'onChange'的方式"接管"其数据的输入和输出。并且value不是必传的,使用组件时可以单纯的只获取操作的数据,传入value更多是做的一个初始值。而onChange是数据的唯一出口,我觉得应该是必传的,不然你怎么获取的到操作的数据呢?对吧。

有一个注意点: 既然表单控件时单选框,复选框, 那我们的输入一边是string, 一边是string[],既大大增加了编码的复杂度,也增加了使用的心智成本。所以我这里的想法是统一使用string[], 而再单选的交互就是用value[0]等方式完成单选值与数组的转换。

{#_label1}

编码与实现

// types.ts
import type { TooltipProps } from 'antd';

interface OptItem {     id: string;     name: string;     disabled: boolean; // 是否不可选     children?: OptItem[]; // 递归嵌套 } // 组件调用的props传参 export type IProps = Omit<TooltipProps, 'overlay' | 'trigger'> & {     /** 选项类型: 单选, 复选 /     type: 'radio' | 'checkbox';     /* 选项列表 /     options: OptItem[];     /* 展示文本 /     placeholder?: string;     /* 触发行为 /     trigger?: 'click' | 'hover';     /* 受控组件: value + onChange 组合 /     value?: string[];     onChange?: (v: string[]) => void;     /* 样式间隔 */     size?: number; }

处理createContext与useContext {#heading-2}

import type { Dispatch, MutableRefObj, SetStateAction } from 'react';
import { createContext } from 'react';
import type { IProps } from './types';
export const Ctx = createContext<{
  options: IProps['options'];
  size?: number;
  type: IProps['type'];
  onChange?: IProps['onChange'];
  value?: IProps['value'];
  
  // 这里有两个额外的状态: shadowValue表示内部的数据状态
  shadowValue: string[];
  setShadowValue?: Dispatch<SetStateAction<string[]>>;
  // 操作弹出框
  setVisible?: (value: boolean) => void;
  // 复选框的引用, 暴露内部的reset方法
  checkboxRef?: MutableRefObject<{
    reset: () => void;
  } | null>;
}>({ options: [], shadowValue: [], type: 'radio' });
// index.tsx

/**  * 自定义下拉选择框, 包括单选, 多选。  */ import { FilterOutlined } from '@ant-design/icons'; import { useBoolean } from 'ahooks'; import { Popover } from 'antd'; import classnames from 'classnames'; import { cloneDeep } from 'lodash'; import type { FC, ReactElement } from 'react'; import { memo, useEffect, useRef, useState } from 'react'; import { Ctx } from './config'; import Controls from './Controls'; import DispatchRender from './DispatchRender'; import Styles from './index.less'; import type { IProps } from './types';

const Index: FC<IProps> = ({   type,   options,   placeholder = '筛选文本',   trigger = 'click',   value,   onChange,   size = 6,   style,   className,   ...rest }): ReactElement => {   // 弹窗显示控制(受控组件)   const [visible, { set: setVisible }] = useBoolean(false);

  // checkbox专用, 用于获取暴露的reset方法   const checkboxRef = useRef<{ reset: () => void } | null>(null);

  // 内部维护的value, 不对外暴露. 统一为数组形式   const [shadowValue, setShadowValue] = useState<string[]>([]);

  // value同步到中间状态   useEffect(() => {     if (value && value?.length) {       setShadowValue(cloneDeep(value));     } else {       setShadowValue([]);     }   }, [value]);

  return (     <Ctx.Provider       value={{         options,         shadowValue,         setShadowValue,         onChange,         setVisible,         value,         size,         type,         checkboxRef,       }}     >       <Popover         visible={visible}         onVisibleChange={(vis) => {           setVisible(vis);           // 这里是理解难点: 如果通过点击空白处关闭了弹出框, 而不是点击确定关闭, 需要额外触发onChange, 更新数据。           if (vis === false && onChange) {             onChange(shadowValue);           }         }}         placement="bottom"         trigger={trigger}         content={           <div className={Styles.content}>             {/* 分发自定义的子组件内容 /}             <DispatchRender type={type} />             {/ 控制行 */}             <Controls />           </div>         }         {...rest}       >         <span className={classnames(Styles.popoverClass, className)} style={style}>           {placeholder ?? '筛选文本'}           <FilterOutlined style={{ marginTop: 4, marginLeft: 3 }} />         </span>       </Popover>     </Ctx.Provider>   ); };

const CustomSelect = memo(Index);

export { CustomSelect }; export type { IProps };

对content的封装和拆分: DispatchRender, Controls {#heading-3}

{#_label4}

先说Controls, 包含控制行: 重置, 确定 {#heading-4}

/** 控制按钮行: "重置", "确定" */
import { Button } from 'antd';
import { cloneDeep } from 'lodash';
import type { FC } from 'react';
import { useContext } from 'react';
import { Ctx } from './config';
import Styles from './index.less';

const Index: FC = () => {   const { onChange, shadowValue, setShadowValue, checkboxRef, setVisible, value, type } =     useContext(Ctx);   return (     <div className={Styles.btnsLine}>       <Button         type="primary"         ghost         size="small"         onClick={() => {           // radio: 直接重置为value           if (type === 'radio') {             if (value && value?.length) {               setShadowValue?.(cloneDeep(value));             } else {               setShadowValue?.([]);             }           }           // checkbox: 因为还需要处理全选, 需要交给内部处理           if (type === 'checkbox') {             checkboxRef?.current?.reset();           }         }}       >         重置       </Button>       <Button         type="primary"         size="small"         onClick={() => {           if (onChange) {             onChange(shadowValue); // 点击确定才触发onChange事件, 暴露内部数据给外层组件           }           setVisible?.(false); // 关闭弹窗         }}       >         确定       </Button>     </div>   ); };

export default Index;

DispatchRender 用于根据type分发对应的render子组件,这是一种编程思想,在次可以保证父子很大程度的解耦,再往下子组件不再考虑type是什么,父组件不需要考虑子组件有什么。 {#heading-5}

/** 分发详情的组件,保留其可拓展性 */
import type { FC, ReactElement } from 'react';
import CheckboxRender from './CheckboxRender';
import RadioRender from './RadioRender';
import type { IProps } from './types';

const Index: FC<{ type: IProps['type'] }> = ({ type }): ReactElement => {   let res: ReactElement = <></>;   switch (type) {     case 'radio':       res = <RadioRender />;       break;     case 'checkbox':       res = <CheckboxRender />;       break;     default:       // never作用于分支的完整性检查       ((t) => {         throw new Error(Unexpected type: ${t}!);       })(type);   }   return res; };

export default Index;

单选框的render子组件的具体实现 {#heading-6}

import { Radio, Space } from 'antd';
import type { FC, ReactElement } from 'react';
import { memo, useContext } from 'react';
import { Ctx } from './config';

const Index: FC = (): ReactElement => {   const { size, options, shadowValue, setShadowValue } = useContext(Ctx);

  return (     <Radio.Group       value={shadowValue?.[0]} // Radio 接受单个数据       onChange={({ target }) => {         // 更新数据         if (target.value) {           setShadowValue?.([target.value]);         } else {           setShadowValue?.([]);         }       }}     >       <Space direction="vertical" size={size ?? 6}>         {options?.map((item) => (           <Radio key={item.id} value={item.id}>             {item.name}           </Radio>         ))}       </Space>     </Radio.Group>   ); };

export default memo(Index);

总结

  • 用好typescript作为你组件设计和一点点推进的好助手,用好:继承,合并,, 类型别名,类型映射(Omit, Pick, Record), never分支完整性检查等. 一般每个组件单独有个types.ts文件统一管理所有的类型

  • 组件入口props有很大的考虑余地,是整个组件设计的根本要素之一,传什么参数决定了你后续的设计,以及这个组件是否显得"很傻",是否简单好用,以及后续如果想添加功能是否只能重构

  • 另一个核心要素是数据流: 组件内部的数据流如何清晰而方便的控制,又如何与外层调用组件交互,也直接决定了组件的复杂度。

  • 一些组件封装的经验和模式:比如复杂的核心方法可以考虑使用柯里化根据参数重要性分层传入;复杂的多类别的子组件可以用分发模式解耦;以及一些像单一职责,高内聚低耦合等灵活应用这些理论知识。


赞(4)
未经允许不得转载:工具盒子 » React应用:封装CustomSelect组件思路