React Virtual Select

背景

antd提供的select在数据量大于2000的时候, 就能感觉稍有卡顿, 大于20000的时候会有明显卡顿, 而且如果页面上出现多个数据量较大的选择组件会由于页面元素过多导致更卡, 所以需要开发一个使用虚拟滚动实现的选择组件来优化页面性能

虚拟滚动实现原理

虚拟滚动的原理描述起来非常的简单, 获取列表里所有元素各自的高度, 然后相加得到总高度, 用这个总高度渲染一个div作为内容的填充, 再计算滚动的位置和列表的实际高度, 根据滚动位置计算出要渲染的数据是哪一部分, 然后根据这些数据将组件渲染到页面上, 所以可以实现超大数据量的渲染而不会导致卡顿, 实测20w数据的搜索选择无卡顿.

组件选择

react-select

由于antdselect组件没有提供自定义元素渲染接口, 自己开发一个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));
}}
/>
);
};
作者

Mosby

发布于

2019-05-06

许可协议

评论