GrowingIO Design 组件库搭建之Select组件

370 阅读10分钟

前言

Select 是最频繁使用的UI组件之一,它可以运用在很多场景。大多数情况下,原生HTML的<Select>标签无法满足业务的功能过需求,以及原生HTML的<Select>标签在各个浏览器版本里样式表现不太一样。在这样的情况下,多数人都会选择实现一个符合UI要求以及产品功能需求的Select组件,或者选择使用一些开源组件库提供的 Select 组件。本文主要梳理了 gio-design 中的Select组件在实现过程中遇到的一些阻碍及需要注意的地方,希望能对大家在设计和实现select组件时提供一些帮助。

数据源(dataSource)

Select 组件的两种使用方法:

// 第一种写法
const options = [{label:'a',value:'a'},{label:'b',value:''b}];
<Select options={options} />

// 第二种
<Select>
    <Select.Option value={'a'} >a</Select.Option>
    <Select.Option value={'b'} >b</Select.Option>
</Select>

使用 Select 组件的时候,一般情况下,有两种方式来设定 dataSource

  1. 通过 options 参数,传入纯数据格式。
  2. JSX,通过拦截子组件 <Select.Option/> 的参数,转化为 nodeOptions。相比较 JSX 而言,options 参数形式拥有更好的性能( JSX 方式最终也会转为类似 options 参数的形式)。

该转换方式借鉴了rc-select 中的写法。

export function convertChildrenToData(nodes: React.ReactNode, group = {}): Option[] {
  let nodeOptions: Option[] = [];
  React.Children.forEach(nodes, (node: React.ReactElement & { type: { isSelectOptGroup?: boolean }; props: OptionProps }) => {
    if (!React.isValidElement(node)) return;
    const {
      type: { isSelectOptGroup },
      props: { children, label, value },
    } = node;
    if (!isSelectOptGroup) { // option
      nodeOptions.push(convertNodeToOption(node, group));
    } else { // Group
      nodeOptions = concat(nodeOptions, convertChildrenToData(children, { groupLabel: label, groupValue: value }));
    }
  });
  return nodeOptions;
}

// ReactNode To Options
export function convertNodeToOption(node: React.ReactElement, group: group): Option {
  const {
    props: { value, children, ...restProps },
  } = node as React.ReactElement & { props: OptionProps };
  const { groupValue, groupLabel } = group;
  if (groupLabel && groupLabel) {
    return { value, label: children !== undefined ? children : value, groupValue, groupLabel, ...restProps };
  }
    return { value, label: children !== undefined ? children : value, ...restProps };
}

GroupOption 的定义:

// group

export interface OptionGroupFC extends React.FC<OptGroupProps> {
  isSelectOptGroup: boolean;
}
export const OptGroup: OptionGroupFC = () => null;

OptGroup.isSelectOptGroup = true;

export default OptGroup;

// option

export interface OptionFC extends React.FC<OptionProps> {
  isSelectOption: boolean;
}
const Option: OptionFC = () => null;

Option.isSelectOption = true;

export default Option;

上面这个两个方法思路也比较清晰,用 isSelectOptGroup 来区分 GroupOptionGroup 会在 Option 原有参数上额外增加 groupLabel 和 groupValue 两个 key。

当两种传参方式混用时,会解析并合并成完整的可供 List 组件使用的 options,在 optionsnodeOptions 合并的过程中,还需要生成一个 c``acheOptions

cacheOptions 是一个用来缓存 valueoption 对应关系的对象。(默认:当 value 不发生变化的时候,即认为对应的 option 未发生改变),并提供 getOptionByValue (使用 value 获取 option)、getOptionsByValue (使用_value[]_ 获取option[])方法查询。

dataSource 的来源除了这两种方式外,还有另外一种方式,那就是手动输入选项,像这样:

在允许自定义输入的场景,用户输入无法匹配现有选项时,添加新的选项。

const extendedOptions = () => {
  const result: Option[] = [];

  if (Array.isArray(value) && allowCustomOption) {
    value.forEach((v) => {
      const op = getOptionByValue(v);
      if (!op) {
        result.push(CustomOption(v, hasGroup, customOptionKey));
      }
    });
  }
  return [...mergedFlattenOPtions, ...result];
};

最后,我们还要针对 group 的情况进行分组排序,来处理 dataSource 排序问题,需要将相同group的数据放到一起,数据可能是像这样的:

const options = [
    {label:'a',value:'a',groupLabel:'A',groupValue:'A'}
    {label:'b',value:'b',groupLabel:'B',groupValue:'B'}
    {label:'aa',value:'aa',groupLabel:'A',groupValue:'A'}
]

整体流程大概是这样的:

 options and nodeOptions -> cacheOptions -> extendedOptions -> filterOptions -> sortedOptions

整个数据的流向比较清晰,将数据分批处理,比如说数据合并、搜索、过滤、排序等等,根据组件需求,将每一步处理为单独的逻辑,能较好的控制每一层逻辑所处理的内容。切勿将一些不相干的逻辑封装在一起,使得整体流程变得臃肿,将来在进行拓展时不好处理。处理完毕 dataSource 后,就可以将数据传入 List 组件中。

值(value and tempValue)

当 Select 组件已经成型,整体逻辑已经设计好后,由于一些业务场景的特殊性,Select组件需要额外支持一个 useFooter 的方法,开启useFooter后会默认出现 确定取消 按钮,如图所示:

当点击 确认 时,触发 onChange 方法,当点击 取消 时,需要取消已选中的选项(但不可以影响上一次选中的结果),点击页面空白区域关闭下拉菜单时,逻辑与 取消 相同。遇到这样的情况,我们该怎么样在尽量不更改原有已经设计好的结构的情况下,去处理这样类似于 预选中 的情况?

可以来新增一个 tempValue 来支持对应预选中的情况,将预选中与已选中进行区分。

  • value 对应已选中的选项
  • tempValue 对应预选中的选项
  • selectorValue 对应的是展示的选项。

每当选中一个选项时,将选项添加到 tempValue 中。当反选一个选项时,该选项如果在 value 中存在,那就将它添加到 tempValue 中( valuetempValue 中都存在代表了已选中的选项在当前这次中被取消选择),如果不存在,从 tempValue 中移除即可。确认 时,将 tempValue 与_value_ 合并,移除 tempValuevalue 中都存在的选项,生成新的 value取消 时,只要将 tempValue 重置为空数组即可。

value

tempValue

selectorValue

没有

展示

没有

展示

不展示

const selectorValue = () => {
  if (Array.isArray(value)) {
        // filter: if v in value and tempValue 
    return value.concat(tempValue).filter((v) => !value.includes(v) || !tempValue.includes(v));
  }
  if (multiple) {
    return tempValue;
  }
  return value;
}

这样我们仅仅是新增了一个 tempValue (及对应的选中逻辑) 和 selectorValue 就达到目标,对原有有关 value 的逻辑并没有修改,用较小的代价就完成了预选中的功能。

Portal

bug 版 Select:将selector 和 dropdown 渲染到同一层级下时,在某些情况下,可能会出现以下两种情况:

  1. 当dropdown展开时,父组件容器内部出现滚动条。

  1. dropdown 的部分甚至全部都会被父级元素遮挡。

首先,当 dropdown 展开时,dropdown 位置不应该影响其他组件位置。其次,其他组件也不应该影响到 dorpdown 展示。基于这样的条件下,我们该如果展示 dropdown 呢?第一个想到的是,利用 position 定位,来解决 dropdown 对组件内其他组件的影响。脱离了正常文档流后,就不会影响其他组件的位置。那如何解决父组件对 dropdown 影响呢?可以将 dropdown 渲染到 父组件之外,这样父组件就无法影响到 dropdown。可以借助 React 官方的 **Portal,**实现一个类似的 Portal。

React 是这样介绍 Portal 的:

Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。

在这样的情况下,Select 组件的 dropdown 部分为了避开父组件对于它的影响,默认情况下是渲染到 body 上。不仅仅是将 dropdown 渲染到 _body_上后就结束了,还需要考虑当 scrollView 、 resize 时,我们需要来计算 dropdown 的相对位置。我们来看一下 rc-trigger 的部分源码, 它帮助我们实现了下层的一些相对位置计算和类似 React 官方的Portal

// rc-trigger 删除部分无用代码

// 如果传入了getPopupContainer方法则使用getPopupContainer方法来挂载dom,如果没有则默认挂载在body上。
attachParent = (popupContainer: HTMLDivElement) => {
  const { getPopupContainer, getDocument } = this.props;
  const domNode = this.getRootDomNode();
  let mountNode: HTMLElement;
  if (!getPopupContainer) {
    mountNode = getDocument(this.getRootDomNode()).body;
  } else if (domNode || getPopupContainer.length === 0) {
    mountNode = getPopupContainer(domNode);
  }
  if (mountNode) {
    mountNode.appendChild(popupContainer);
  }
};

// 创建一个dom,设定了绝对定位,利用attachParent方法将dom挂载到对应的dom元素上
getContainer = () => {
  const { getDocument } = this.props;
  const popupContainer = getDocument(this.getRootDomNode()).createElement('div',);
  popupContainer.style.position = 'absolute';
  popupContainer.style.top = '0';
  popupContainer.style.left = '0';
  popupContainer.style.width = '100%';
  this.attachParent(popupContainer);
  return popupContainer;
};

  let portal: React.ReactElement;
  if (popupVisible || this.popupRef.current || forceRender) {
    portal = (
      <PortalComponent // 这里的PortalComponent 指的就是rc-util/portal
        key="portal"
        getContainer={this.getContainer}
        didUpdate={this.handlePortalUpdate}
      >
        {this.getComponent()}
      </PortalComponent>
    );
 }

  return (
    <TriggerContext.Provider value={this.triggerContextValue}>
      {trigger}
      {portal}
    </TriggerContext.Provider>
  );
}

其实 rc-trigger 最终就是渲染了一个 trigger (也就是我们说的 selector )以及 portal(dropdown) 。默认情况下(不传 getPopupContainer 方法)时,默认挂载到 body 上。 并设定绝对定位,大多数情况下,Select组件是相对于页面静止的,当我们利用绝对定位布局挂载到 body 上时,只要计算好 trigger(selector) 的位置,通过 trigger(selector) 的位置来确定 portal(dropdown) 的位置即可。

在这里,只是简单的介绍了一下实现思路,其实有关这部分的问题比这里描述的要复杂的多,感兴趣的同学可以研究一下 rc-triggerrc-util 的源码,相信你会有一些新的发现。

键盘交互

键盘交互可谓是比较复杂的一个设计了,其中也是针对键盘事件重构了多个版本,才达到了目前这样的效果。

在没有考虑键盘交互事件之前,元素的选中、聚焦、悬浮等效果是交给浏览器来处理,但是 Select 是由 selector 和 dropdown 两个部分组成的虚拟合成元素,在考虑到定制键盘事件后,就需要组件内部去模拟浏览器提供的元素的选中、聚焦、悬浮等效果。需要注意的是,我们使用了 Portal,将 selector 和 dorpdown 渲染到了不同的 dom 层级上,React 官方文档有这样一句话:

当在使用 portal 时, 记住管理键盘焦点就变得尤为重要。

Tab 切换焦点时,是根据 dom 元素的顺序来进行切换的,下拉列表默认是渲染到 body 上的(render in body),当 focus 状态移交到 List 上时,Tab 切换会导致 focus 丢失问题(跳过原有的 focus 切换顺序,使得 focus 顺序看上去与正常的表现不一)。所以,在 List 失去焦点时,需要将 focus 重新移交到selector 后再执行 onBlur()。

虚拟列表(virtualList)

虚拟列表这项功能肯定是每一个跟列表相关的组件都需要去考虑的一件事情,大多数情况下,数据的来源是从服务端请求过来的一些数据,有可能是1000条数据,也有可能是10条数据。当数据量过大时,可以考虑 虚拟列表 来进行优化。

简单说明一下 虚拟列表 的逻辑就是,仅仅只是渲染 可视区域,随着滚动的高度不断变化,不断的变更可视区域内的元素。在网络上已经有很多非常棒的有关 虚拟列表 实现的文章,在这里我就不展开描述了,下面主要是说明一些特殊情况。

在库的选择上,我们采用了 rc-virtual-list 这个库,这个库的优势相比较其他一些虚拟列表的库来说,体积小、参数很少(传入很少的参数即可达到目的)。我们的 Select 组件支持自定义optionRenderrc-virtual-list 支持自动计算每一个 Item 的高度。需要将每一个 Item 用 React.forwardRef() 来进行包裹。看一下 rc-virtual-list 的中有关自动获取 Item 真实高度的部分源码:

export default function useChildren<T>(
  list: T[],
  startIndex: number,
  endIndex: number,
  setNodeRef: (item: T, element: HTMLElement) => void,
  renderFunc: RenderFunc<T>,
  { getKey }: SharedConfig<T>,
) {
  return list.slice(startIndex, endIndex + 1).map((item, index) => {
    const eleIndex = startIndex + index;
    const node = renderFunc(item, eleIndex, {}) as React.ReactElement;
    const key = getKey(item);
    return (
      <Item key={key} setRef={ele => setNodeRef(item, ele)}> // setInstanceRef方法
        {node}
      </Item>
    );
  });
}


  const listChildren = useChildren(mergedData, start, end, setInstanceRef, children, sharedConfig);
  

listChildren 主要作用是生成范围在 Start---End 的 Data 数据,<Item> 组件采取了React.cloneElement 方法来创建 item, 它会单独给当前数据设置 ref (这也是我们为什么要用 React.forwardRef()),并触发setInstanceRef方法。

  function setInstanceRef(item: T, instance: HTMLElement) {
    const key = getKey(item);
    const origin = instanceRef.current.get(key);

    if (instance) {
      instanceRef.current.set(key, instance);
      collectHeight();
    } else {
      instanceRef.current.delete(key);
    }

    // Instance changed
    if (!origin !== !instance) {
      if (instance) {
        onItemAdd?.(item);
      } else {
        onItemRemove?.(item);
      }
    }
  }

每当setInstanceRef执行时,都会将当前 item 存储起来,并触发collectHeight方法,该方法会触发多次,但是只会currentId === heightUpdateRef.current 时才会执行 。

  function collectHeight() {
    heightUpdateIdRef.current += 1;
    const currentId = heightUpdateIdRef.current;

    Promise.resolve().then(() => {
      // Only collect when it's latest call
      if (currentId !== heightUpdateIdRef.current) return;

      instanceRef.current.forEach((element, key) => {
        if (element && element.offsetParent) {
          const htmlElement = findDOMNode<HTMLElement>(element);
          const { offsetHeight } = htmlElement;
          if (heightsRef.current.get(key) !== offsetHeight) {
            heightsRef.current.set(key, htmlElement.offsetHeight);
          }
        }
      });
    });
  }

遍历当前的instanceRef 来检查每个keyoffsetHeight,如果heightsRef.current.get(key) !== offsetHeight,则更新当前key的height。

最后

当我们设计组件时,首先要确认的是参数设计,对于一个开源的组件来说,频繁的修改参数对于使用者来说非常痛苦,参数的设计就尤为重要。在重构代码逻辑时,要保证既有的参数功能不能丢失,基于现有的情况来重构代码,尽量不要反复删减参数。Select 实现起来并不复杂,但也不简单,作为使用最频繁的组件之一,有很多细节、额外的功能需要实现。合理的控制需求、功能才能使得组件更为健壮,而不是一味的增加参数,使得组件变得臃肿,渐渐无法维护。

引用

  1. rc-trigger
  2. rc-util
  3. rc-virtual-list
  4. ant Design