共计 10028 个字符,预计需要花费 26 分钟才能阅读完成。
功能简介
antd 的 Select 组件不支持大数据量的下拉列表渲染,下拉列表数量太多会出现性能问题,
SuperSelect 基于 antd 封装实现,替换原组件下拉列表,只渲染几十条列表数据,随下拉列表滚动动态刷新可视区列表状态,实现大数据量列表高性能渲染。
-
特性
- 基于 antd Select 组件,不修改组件用法
- 替换 antd Select 组件的下拉列表部分实现动态渲染列表
- 初步测试 10w 条数据不卡顿
-
实现方案
- 使用 antd Select
dropdownRender
方法自定义原组件下拉列表部分 - 对自定义列表项绑定原 Select 组件的各项方法和回调函数支持
- 同步使用 antd 组件下拉列表样式
- 使用 antd Select
在线地址
使用
基本使用同 antd Select,只是使用 SuperSelect 代替 Select
import SuperSelect from 'components/SuperSelect'; | |
import {Select} from 'antd'; | |
const Option = Select.Option; | |
const Example = () => {const children = []; | |
for (let i = 0; i < 100000; i++) { | |
children.push(<Option value={i + ''} key={i}> | |
{i} | |
</Option> | |
); | |
} | |
return ( | |
<SuperSelect | |
showSearch | |
// mode="multiple" | |
// onChange={onChange} | |
// onSearch={onSearch} | |
// onSelect={onSelect} | |
> | |
{children} | |
</SuperSelect> | |
); | |
}; |
问题
-
多选模式选择后鼠标点击输入框中删除等图标时不能直接 hover 时获取焦点直接删除,需要点击两次
Warning: the children of `Select` should be `Select.Option` or `Select.OptGroup`, instead of `li` - 重写的下拉菜单没有 isSelectOption(rc-select 源码判断下拉列表元素)属性,控制台会有 warning 提示下拉列表元素不符合 Select 组件要求
大佬们有啥更好的做法或建议请多多指教
附上代码
import React, {PureComponent, Fragment} from 'react'; | |
import {Select} from 'antd'; | |
// 页面实际渲染的下拉菜单数量,实际为 2 * ITEM_ELEMENT_NUMBER | |
const ITEM_ELEMENT_NUMBER = 20; | |
// Select size 配置 | |
const ITEM_HEIGHT_CFG = { | |
small: 24, | |
large: 40, | |
default: 32, | |
}; | |
class Wrap extends PureComponent { | |
state = { | |
list: this.props.list, | |
allHeight: this.props.allHeight, | |
}; | |
reactList = (list, allHeight) => this.setState({list, allHeight}); | |
render() {const { list} = this.state; | |
const {notFoundContent} = this.props; | |
// 搜索下拉列表为空时显示 no data | |
const noDataEle = ( | |
<li | |
role="option" | |
unselectable="on" | |
className="ant-select-dropdown-menu-item ant-select-dropdown-menu-item-disabled" | |
aria-disabled="true" | |
aria-selected="false" | |
> | |
<div className="ant-empty ant-empty-normal ant-empty-small"> | |
<div className="ant-empty-image"> | |
<img | |
alt="No Data" | |
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNDEiIHZpZXdCb3g9IjAgMCA2NCA0MSIgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAxKSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgIDxlbGxpcHNlIGZpbGw9IiNGNUY1RjUiIGN4PSIzMiIgY3k9IjMzIiByeD0iMzIiIHJ5PSI3Ii8+CiAgICA8ZyBmaWxsLXJ1bGU9Im5vbnplcm8iIHN0cm9rZT0iI0Q5RDlEOSI+CiAgICAgIDxwYXRoIGQ9Ik01NSAxMi43Nkw0NC44NTQgMS4yNThDNDQuMzY3LjQ3NCA0My42NTYgMCA0Mi45MDcgMEgyMS4wOTNjLS43NDkgMC0xLjQ2LjQ3NC0xLjk0NyAxLjI1N0w5IDEyLjc2MVYyMmg0NnYtOS4yNHoiLz4KICAgICAgPHBhdGggZD0iTTQxLjYxMyAxNS45MzFjMC0xLjYwNS45OTQtMi45MyAyLjIyNy0yLjkzMUg1NXYxOC4xMzdDNTUgMzMuMjYgNTMuNjggMzUgNTIuMDUgMzVoLTQwLjFDMTAuMzIgMzUgOSAzMy4yNTkgOSAzMS4xMzdWMTNoMTEuMTZjMS4yMzMgMCAyLjIyNyAxLjMyMyAyLjIyNyAyLjkyOHYuMDIyYzAgMS42MDUgMS4wMDUgMi45MDEgMi4yMzcgMi45MDFoMTQuNzUyYzEuMjMyIDAgMi4yMzctMS4zMDggMi4yMzctMi45MTN2LS4wMDd6IiBmaWxsPSIjRkFGQUZBIi8+CiAgICA8L2c+CiAgPC9nPgo8L3N2Zz4K" | |
/> | |
</div> | |
<p className="ant-empty-description"> | |
{notFoundContent || '没有匹配到数据'} | |
</p> | |
</div> | |
</li> | |
); | |
return (<div style={{ overflow: 'hidden', height: this.state.allHeight}}> | |
<ul | |
role="listbox" | |
className="ant-select-dropdown-menu ant-select-dropdown-menu-root ant-select-dropdown-menu-vertical" | |
style={{ | |
height: this.state.allHeight, | |
maxHeight: this.state.allHeight, | |
overflow: 'hidden', | |
}} | |
tabIndex="0" | |
> | |
{list.length > 0 ? list : noDataEle} | |
</ul> | |
</div> | |
); | |
} | |
} | |
export default class SuperSelect extends PureComponent {constructor(props) {super(props); | |
const {mode, defaultValue, value} = props; | |
this.isMultiple = ['tags', 'multiple'].includes(mode); | |
// 设置默认 value | |
let defaultV = this.isMultiple ? [] : ''; | |
defaultV = value || defaultValue || defaultV; | |
this.state = {children: props.children || [], | |
filterChildren: null, | |
value: defaultV, | |
}; | |
// 下拉菜单项行高 | |
this.ITEM_HEIGHT = ITEM_HEIGHT_CFG[props.size || 'default']; | |
// 可视区 dom 高度 | |
this.visibleDomHeight = this.ITEM_HEIGHT * ITEM_ELEMENT_NUMBER; | |
// 滚动时重新渲染的 scrollTop 判断值,大于 reactDelta 则刷新下拉列表 | |
this.reactDelta = (this.visibleDomHeight * 2) / 3; | |
// 是否拖动滚动条快速滚动状态 | |
this.isStopReact = false; | |
// 上一次滚动的 scrollTop 值 | |
this.prevScrollTop = 0; | |
this.scrollTop = 0; | |
} | |
componentDidUpdate(prevProps, prevStates) {if (prevProps.children !== this.props.children) {const { mode, defaultValue, value} = this.props; | |
this.isMultiple = ['tags', 'multiple'].includes(mode); | |
// 更新时设置默认 value | |
let defaultV = this.isMultiple ? [] : ''; | |
defaultV = value || defaultValue || defaultV; | |
this.setState({children: this.props.children || [], | |
filterChildren: null, | |
value: defaultV, | |
}); | |
} | |
} | |
getItemStyle = i => ({ | |
position: 'absolute', | |
top: this.ITEM_HEIGHT * i, | |
width: '100%', | |
height: this.ITEM_HEIGHT, | |
}); | |
addEvent = () => {this.scrollEle = document.querySelector('.my-select'); | |
// 下拉菜单未展开时元素不存在 | |
if (!this.scrollEle) return; | |
this.scrollEle.addEventListener('scroll', this.onScroll, false); | |
}; | |
onScroll = () => this.throttleByHeight(this.onScrollReal); | |
onScrollReal = () => {this.allList = this.getUseChildrenList(); | |
this.showList = this.getVisibleOptions(); | |
this.prevScrollTop = this.scrollTop; | |
// 重新渲染列表组件 Wrap | |
let allHeight = this.allList.length * this.ITEM_HEIGHT || 100; | |
this.wrap.reactList(this.showList, allHeight); | |
}; | |
throttleByHeight = () => { | |
this.scrollTop = this.scrollEle.scrollTop; | |
// 滚动的高度 | |
let delta = this.prevScrollTop - this.scrollTop; | |
delta = delta < 0 ? 0 - delta : delta; | |
// TODO: 边界条件优化,滚动约 2/3 可视区 dom 高度时刷新 dom | |
delta > this.reactDelta && this.onScrollReal();}; | |
// 列表可展示所有 children | |
getUseChildrenList = () => this.state.filterChildren || this.state.children; | |
getStartAndEndIndex = () => { | |
// 滚动后显示在列表可视区中的第一个 item 的 index | |
const showIndex = Number((this.scrollTop / this.ITEM_HEIGHT).toFixed(0) | |
); | |
const startIndex = | |
showIndex - ITEM_ELEMENT_NUMBER < 0 | |
? 0 | |
: showIndex - ITEM_ELEMENT_NUMBER / 2; | |
const endIndex = showIndex + ITEM_ELEMENT_NUMBER; | |
return {startIndex, endIndex}; | |
}; | |
getVisibleList = () => { | |
// 搜索时使用过滤后的列表 | |
const {startIndex, endIndex} = this.getStartAndEndIndex(); | |
// 渲染的 list | |
return this.allList.slice(startIndex, endIndex); | |
}; | |
getVisibleOptions = () => {const visibleList = this.getVisibleList(); | |
const {startIndex} = this.getStartAndEndIndex(); | |
// 显示中的列表元素添加相对定位样式 | |
return visibleList.map((item, i) => {let props = { ...item.props}; | |
const text = props.children; | |
const realIndex = startIndex + Number(i); | |
const key = props.key || realIndex; | |
const {value} = this.state; | |
const valiValue = text || props.value; | |
const isSelected = | |
value && value.includes | |
? value.includes(valiValue) | |
: value == valiValue; | |
const classes = `ant-select-dropdown-menu-item ${isSelected ? 'ant-select-dropdown-menu-item-selected' : ''}`; | |
// antd 原素,下拉列表项右侧 √ icon | |
const selectIcon = ( | |
<i | |
aria-label="icon: check" | |
className="anticon anticon-check ant-select-selected-icon" | |
> | |
<svg | |
viewBox="64 64 896 896" | |
className=""data-icon="check"width="1em"height="1em"fill="currentColor"aria-hidden="true"focusable="false" | |
> | |
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" /> | |
</svg> | |
</i> | |
); | |
props._childrentext = text; | |
return ( | |
<li | |
className={classes} | |
key={key} | |
onMouseDown={() => this.onClick(props, item)} | |
{...props} | |
style={this.getItemStyle(realIndex)} | |
> | |
{text} | |
{/* 多选项选中状态 √ 图标 */} | |
{this.isMultiple ? selectIcon : null} | |
</li> | |
); | |
}); | |
}; | |
render() { | |
let { | |
children, | |
dropdownStyle, | |
optionLabelProp, | |
notFoundContent, | |
...props | |
} = this.props; | |
this.allList = this.getUseChildrenList(); | |
this.showList = this.getVisibleOptions(); | |
let allHeight = this.allList.length * this.ITEM_HEIGHT || 100; | |
dropdownStyle = { | |
maxHeight: '250px', | |
...dropdownStyle, | |
overflow: 'auto', | |
position: 'relative', | |
}; | |
const {value} = this.state; | |
// 判断处于 antd Form 中时不自动设置 value | |
let _props = {...props}; | |
// 先删除 value,再手动赋值,防止空 value 影响 placeholder | |
delete _props.value; | |
if (!this.props['data-__field'] && value && value.length > 0) {_props.value = value;} | |
// 设置显示在输入框的文本,替换 children 为自定义 childrentext, 默认 children 会包含 √ icon | |
optionLabelProp = optionLabelProp ? optionLabelProp : '_childrentext'; | |
optionLabelProp = | |
optionLabelProp === 'children' ? '_childrentext' : optionLabelProp; | |
return ( | |
<Select | |
{..._props} | |
onSearch={this.onSearch} | |
onChange={this.onChange} | |
onSelect={this.onSelect} | |
dropdownClassName="my-select" | |
optionLabelProp={optionLabelProp} | |
dropdownStyle={dropdownStyle} | |
onDropdownVisibleChange={this.setSuperDrowDownMenu} | |
ref={ele => (this.select = ele)} | |
dropdownRender={menu => ( | |
<Fragment> | |
<Wrap | |
ref={ele => (this.wrap = ele)} | |
allHeight={allHeight} | |
list={this.showList} | |
notFoundContent={notFoundContent} | |
/> | |
</Fragment> | |
)} | |
> | |
{this.showList} | |
</Select> | |
); | |
} | |
// 须使用 setTimeout 确保在 dom 加载完成之后添加事件 | |
setSuperDrowDownMenu = () => {this.allList = this.getUseChildrenList(); | |
this.allList = this.getUseChildrenList(); | |
if (!this.eventTimer) {this.eventTimer = setTimeout(() => this.addEvent(), 0); | |
} else { | |
let allHeight = this.allList.length * this.ITEM_HEIGHT || 100; | |
// 下拉列表单独重新渲染 | |
this.wrap && this.wrap.reactList(this.showList, allHeight); | |
} | |
}; | |
/** | |
* 替换了 antd Select 的下拉列表,手动实现下拉列表项的点击事件,* 绑定原组件的各项事件回调 | |
* itemProps: li react 元素的 props | |
* item: li 元素 | |
*/ | |
onClick = (itemProps, item) => {let { value} = itemProps; | |
const {onDeselect} = this.props; | |
let newValue = this.state.value || []; | |
let option = item; | |
// 多选 | |
if (this.isMultiple) {newValue = [...newValue]; | |
// 点击选中项取消选中操作 | |
if (newValue.includes(value)) {newValue = newValue.filter(i => i !== value); | |
onDeselect && onDeselect(value, item); | |
} else {newValue.push(value); | |
} | |
// 获取原 onChange 函数第二个参数 options,react 元素数组 | |
option = this.state.children.filter(i => | |
newValue.includes(i.props.value) | |
); | |
} else {newValue = value;} | |
// 多选模式点击选择后下拉框持续显示 | |
this.isMultiple && this.focusSelect(); | |
this.onChange(newValue, option); | |
this.onSelect(newValue, option); | |
}; | |
// 非 antd select 定义元素点击后会失去焦点,手动再次获取焦点防止多选时自动关闭 | |
focusSelect = () => setTimeout(() => this.select.focus(), 0); | |
// 绑定 onSelect 事件 | |
onSelect = (v, opt) => {const { onSelect} = this.props; | |
onSelect && onSelect(v, opt); | |
}; | |
onChange = (value, opt) => { | |
// 删除选中项时保持展开下拉列表状态 | |
if (Array.isArray(value) && value.length < this.state.value.length) {this.focusSelect(); | |
} | |
const {showSearch, onChange, autoClearSearchValue} = this.props; | |
if (showSearch || this.isMultiple) { | |
// 搜索模式下选择后是否需要重置搜索状态 | |
if (autoClearSearchValue !== false) {this.setState({ filterChildren: null}, () => { | |
// 搜索成功后重新设置列表的总高度 | |
this.setSuperDrowDownMenu();}); | |
} | |
} | |
this.setState({value}); | |
onChange && onChange(value, opt); | |
}; | |
onSearch = v => {let { showSearch, onSearch, filterOption, children} = this.props; | |
if (showSearch && filterOption !== false) { | |
// 须根据 filterOption(如有该自定义函数)手动 filter 搜索匹配的列表 | |
let filterChildren = null; | |
if (typeof filterOption === 'function') {filterChildren = children.filter(item => filterOption(v, item)); | |
} else if (filterOption === undefined) { | |
filterChildren = children.filter(item => | |
this.filterOption(v, item) | |
); | |
} | |
// 设置下拉列表显示数据 | |
this.setState({ filterChildren: v === '' ? null : filterChildren}, | |
() => { | |
// 搜索成功后需要重新设置列表的总高度 | |
this.setSuperDrowDownMenu();} | |
); | |
} | |
onSearch && onSearch(v); | |
}; | |
filterOption = (v, option) => { | |
// 自定义过滤对应的 option 属性配置 | |
const filterProps = this.props.optionFilterProp || 'value'; | |
return `${option.props[filterProps]}`.indexOf(v) >= 0; | |
}; | |
componentWillUnmount() {this.removeEvent(); | |
} | |
removeEvent = () => {if (!this.scrollEle) return; | |
this.scrollEle.removeEventListener('scroll', this.onScroll, false); | |
}; | |
} |
正文完
发表至: javascript
2019-05-22