共计 8976 个字符,预计需要花费 23 分钟才能阅读完成。
前言
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。
- 通过 options 参数,传入纯数据格式。
- 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};
}
Group 和 Option 的定义:
// 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 来辨别 Group 和 Option。Group 会在 Option 原有参数上额定减少 groupLabel 和 groupValue 两个 key。
当两种传参形式混用时,会解析并合并成残缺的可供 List 组件应用的 options,在 options 与 nodeOptions 合并的过程中,还须要生成一个 cacheOptions。
cacheOptions 是一个用来缓存 value 和 option 对应关系的对象。(默认:当 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 中 (value 和 tempValue 中都存在代表了已选中的选项在以后这次中被勾销抉择),如果不存在,从 tempValue 中移除即可。 确认 时,将 tempValue 与 value 合并,移除 tempValue 与 value 中都存在的选项,生成新的 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 渲染到同一层级下时,在某些状况下,可能会呈现以下两种状况:
- 当 dropdown 开展时,父组件容器外部呈现滚动条。
- 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-trigger、rc-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 组件反对自定义 optionRender,rc-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 来查看每个 key 的 offsetHeight,如果 heightsRef.current.get(key) !== offsetHeight,则更新以后 key 的 height。
最初
当咱们设计组件时,首先要确认的是参数设计,对于一个开源的组件来说,频繁的批改参数对于使用者来说十分苦楚,参数的设计就尤为重要。在重构代码逻辑时,要保障既有的参数性能不能失落,基于现有的状况来重构代码,尽量不要重复删减参数。Select 实现起来并不简单,但也不简略,作为应用最频繁的组件之一,有很多细节、额定的性能须要实现。正当的管制需要、性能能力使得组件更为强壮,而不是一味的减少参数,使得组件变得臃肿,慢慢无奈保护。
援用
- rc-trigger
- rc-util
- rc-virtual-list
- ant Design