背景
antd
提供的select
在数据量大于2000
的时候, 就能感觉稍有卡顿, 大于20000
的时候会有明显卡顿, 而且如果页面上出现多个数据量较大的选择组件会由于页面元素过多导致更卡, 所以需要开发一个使用虚拟滚动实现的选择组件来优化页面性能
虚拟滚动实现原理
虚拟滚动的原理描述起来非常的简单, 获取列表里所有元素各自的高度, 然后相加得到总高度, 用这个总高度渲染一个div
作为内容的填充, 再计算滚动的位置和列表的实际高度, 根据滚动位置计算出要渲染的数据是哪一部分, 然后根据这些数据将组件渲染到页面上, 所以可以实现超大数据量的渲染而不会导致卡顿, 实测20w
数据的搜索选择无卡顿.
组件选择
react-select
由于antd
的select
组件没有提供自定义元素渲染接口, 自己开发一个select
组件成本也比较高, 找了一下发现react-select
这个库支持自定义组件, 提供的自定义内容很丰富, 样式修改也比较简单, 所以选用此组件作为基础组件, 只需要将menu-list
组件渲染部分改为使用虚拟渲染组件即可
react-window
虚拟渲染组件使用由react-virtualized
升级的react-window
实现, react-window
里已经封装好了虚拟渲染逻辑, 并提供一系列的辅助操作函数, 使用起来非常简单, 只需要指定最简单的几个参数就行, 就是实现原理上提到的相关参数: height
, itemCount
, itemSize
开发
- 高度计算需要使用
_.min([props.selectProps.menuList.height, props.selectProps.menuList.itemSize * props.children.length])
, 在数据量较少时候避免出现空白选择地方
- 键盘上下选择支持需要使用
react-window
提供的内置api
操作上下移动: virtualListRef.current.scrollToItem(index)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| const MenuListWithRef = forwardRef((props: any, ref: any) => { const Row: ComponentType<ListChildComponentProps> = ({ index, style }) => { let Options = props.children[index]; _.set(Options, 'props.innerProps.style', { ...Options.props.innerProps.style, ...style }); return Options; }; const virtualListRef = useRef(null); const virtualListInnerRef = useRef(null); const currentIndexRef = useRef(null);
const height = _.min([props.selectProps.menuList.height, props.selectProps.menuList.itemSize * props.children.length]);
const scrollToKeyDownSelected = (changeIndex: number) => { let index = currentIndexRef.current + changeIndex; if (index < 0) { index += props.children.length; } else if (index >= props.children.length) { index -= props.children.length; } const startY = index * props.selectProps.menuList.itemSize; const endY = startY + props.selectProps.menuList.itemSize; const currentStartY = virtualListRef.current.state.scrollOffset; const currentEndY = currentStartY + height; if (endY <= currentStartY || startY >= currentEndY) { virtualListRef.current.scrollToItem(index); } };
useImperativeHandle(ref, () => ({ virtualListRef: () => virtualListRef, virtualListInnerRef: () => virtualListInnerRef, setCurrentIndex: (index: number) => { currentIndexRef.current = index; }, onKeyDown: (event: React.KeyboardEvent<HTMLElement>, selectRef: React.LegacyRef<ReactSelect<OptionType, Select<OptionType>>>) => { if (event.key === 'ArrowDown') { scrollToKeyDownSelected(1); } else if (event.key === 'ArrowUp') { scrollToKeyDownSelected(-1); } }, })); return props.children.length ? ( <FixedSizeList height={height} itemCount={props.children.length} itemSize={props.selectProps.menuList.itemSize} width={props.selectProps.menuList.width} ref={virtualListRef} innerRef={virtualListInnerRef} > {Row} </FixedSizeList> ) : ( props.children ); });
const MenuList: ComponentType<MenuListComponentProps<OptionType>> = (props) => { return <MenuListWithRef {...props} ref={props.selectProps.menuList.ref} />; };
|
Option
在点开菜单的时候, 需要将已经选中的项直接显示出来, 所以也需要调用menuList
提供的方法显示选中内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const Option: ComponentType<OptionProps<OptionType>> = (props) => { if (props.isFocused && _.get(props, 'selectProps.menuList.ref.current')) { props.selectProps.menuList.ref.current.setCurrentIndex( _.indexOf( props.options.filter((x: any) => _.includes(x.label, props.selectProps.inputValue)), props.data, ), ); } return ( <ReactSelectComponents.Option {...props}> {props.label} <CheckOutlined className={`${props.selectProps.classNamePrefix}__option-icon`} /> </ReactSelectComponents.Option> ); };
|
SelectWindow
将上面的组件和事件封装到react-select
组件中, 提供选项和对外接口就完成了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const SelectWindow: React.FC< SelectProps<OptionType> & { creatable?: boolean; } > = (props) => { const menuListRef = useRef(null); const innerSelectRef = useRef<ReactSelect<OptionType, Select<OptionType>>>(null); const selectRef: React.LegacyRef<ReactSelect<OptionType, Select<OptionType>> | CreatableSelect<OptionType>> = props.selectRef || innerSelectRef; const keyDownChangeSelectedItem = (event: React.KeyboardEvent<HTMLElement>) => { menuListRef.current && menuListRef.current.onKeyDown(event, selectRef); }; const RealSelect: any = props.creatable ? CreatableSelect : ReactSelect; return ( <RealSelect components={{ MenuList, Option, }} ref={selectRef as any} {...props} menuList={{ ...props.menuList, ref: menuListRef }} onKeyDown={keyDownChangeSelectedItem} className={classNames(props.className, 'react-select-antd')} classNamePrefix='react-select-antd' /> ); };
|
使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const UserSelect: React.FC = () => { return ( <SelectWindow creatable isMulti virtual menuList={{ height: 250, itemSize: 32, width: width }} style={{ width: width }} value={realValue} options={options} onChange={(val: any) => { onChange(_.map(val, (x) => x.value)); }} /> ); };
|