antd 的 Select 組件不支持大數據量的下拉列表渲染,下拉列表數量太多會出現性能問題, SuperSelect 基於 antd 封裝實現,替換原組件下拉列表,只渲染幾十條列表數據,隨下拉列表滾動動態刷新可視區列表狀態,實現大數據量列表高性能渲染。基於 antd Select 組件,不修改組件用法。react
dropdownRender
方法自定義原組件下拉列表部分基本使用同 antd Select,只是使用 SuperSelect 代替 Selectgit
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>
);
};
複製代碼
/* eslint-disable react/destructuring-assignment */
import React, { PureComponent } from 'react';
import { Select } from 'antd';
import Wrap from './DropDownWrap';
// 頁面實際渲染的下拉菜單數量,實際爲 2 * ITEM_ELEMENT_NUMBER
const ITEM_ELEMENT_NUMBER = 30;
// Select size 配置
const ITEM_HEIGHT_CFG = {
small: 24,
large: 40,
default: 32,
};
const ARROW_CODE = {
40: 'down',
38: 'up',
};
const DROPDOWN_HEIGHT = 224;
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 / 3;
// 是否拖動滾動條快速滾動狀態
this.isStopReact = false;
// 上一次滾動的 scrollTop 值
this.prevScrollTop = 0;
// 上一次按下方向鍵時 scrollTop 值
this.prevTop = 0;
this.scrollTop = 0;
// className
this.dropdownClassName = `dc${+new Date()}`;
this.id = `sid${+new Date()}`;
}
componentDidMount() {
// defaultOpens=true 時添加滾動事件
setTimeout(() => {
this.addEvent();
}, 500);
}
componentDidUpdate(prevProps) {
const { mode, defaultValue, value, children } = this.props;
if (prevProps.children !== children) {
this.isMultiple = ['tags', 'multiple'].includes(mode);
this.setState({
children: children || [],
filterChildren: null,
});
}
if (prevProps.value !== value) {
// 更新時設置默認 value
let defaultV = this.isMultiple ? [] : '';
defaultV = value || defaultValue || defaultV;
this.setState({ value: defaultV }, () => {
this.scrollToValue();
});
}
}
componentWillUnmount() {
this.removeEvent();
}
// value 存在是須要滾動到 value 所在高度
scrollToValue = () => {
if (!this.scrollEle) return;
const { children } = this.props;
const { value } = this.state;
const index = children.findIndex((item) => item.key === value) || 0;
const y = this.ITEM_HEIGHT * index;
this.scrollEle.scrollTop = y;
setTimeout(() => {
this.forceUpdate();
}, 0);
};
getItemStyle = (i) => ({
position: 'absolute',
top: this.ITEM_HEIGHT * i,
width: '100%',
height: this.ITEM_HEIGHT,
});
addEvent = () => {
this.scrollEle = document.querySelector(`.${this.dropdownClassName}`);
// 下拉菜單未展開時元素不存在
if (!this.scrollEle) return;
this.scrollEle.addEventListener('scroll', this.onScroll, false);
this.inputEle = document.querySelector(`#${this.id}`);
if (!this.inputEle) return;
this.inputEle.addEventListener('keydown', this.onKeyDown, false);
};
// 模擬 antd select 按下 上下箭頭 鍵時滾動列表
onKeyDown = (e) => {
const { keyCode } = e || {};
setTimeout(() => {
const activeItem = document.querySelector(
`.${this.dropdownClassName} .ant-select-dropdown-menu-item-active`,
);
if (!activeItem) return;
const { offsetTop } = activeItem;
const isUp = ARROW_CODE[keyCode] === 'up';
const isDown = ARROW_CODE[keyCode] === 'down';
// 在全部列表第一行按上鍵
if (offsetTop - this.prevTop > DROPDOWN_HEIGHT && isUp) {
this.scrollEle.scrollTo(0, this.allHeight - DROPDOWN_HEIGHT);
this.prevTop = this.allHeight;
return;
}
// 在全部列表中最後一行按下鍵
if (this.prevTop > offsetTop + DROPDOWN_HEIGHT && isDown) {
this.scrollEle.scrollTo(0, 0);
this.prevTop = 0;
return;
}
this.prevTop = offsetTop;
// 向下滾動到下拉框最後一行時,向下滾動一行的高度
if (
offsetTop > this.scrollEle.scrollTop + DROPDOWN_HEIGHT - this.ITEM_HEIGHT + 10 &&
isDown
) {
this.scrollEle.scrollTo(0, this.scrollTop + this.ITEM_HEIGHT);
return;
}
// 向上滾動到下拉框第一一行時,向上滾動一行的高度
if (offsetTop < this.scrollEle.scrollTop && isUp) {
this.scrollEle.scrollTo(0, this.scrollTop - this.ITEM_HEIGHT);
}
}, 100);
};
onScroll = () => this.throttleByHeight(this.onScrollReal);
onScrollReal = () => {
this.allList = this.getUseChildrenList();
const { startIndex, endIndex } = this.getStartAndEndIndex();
this.prevScrollTop = this.scrollTop;
// 從新渲染列表組件 Wrap
const allHeight = this.allList.length * this.ITEM_HEIGHT || 100;
this.wrap.reactList(allHeight, startIndex, endIndex);
};
throttleByHeight = () => {
this.scrollTop = this.scrollEle.scrollTop;
// 滾動的高度
let delta = this.prevScrollTop - this.scrollTop;
delta = delta < 0 ? 0 - delta : delta;
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 };
};
// 須使用 setTimeout 確保在 dom 加載完成以後添加事件
setSuperDrowDownMenu = (visible) => {
if (!visible) return;
this.allList = this.getUseChildrenList();
if (!this.eventTimer) {
this.eventTimer = setTimeout(() => this.addEvent(), 0);
} else {
const allHeight = this.allList.length * this.ITEM_HEIGHT || 100;
// 下拉列表單獨從新渲染
const { startIndex, endIndex } = this.getStartAndEndIndex();
this.wrap && this.wrap.reactList(allHeight, startIndex, endIndex);
}
};
// 在搜索從新計算下拉滾動條高度
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(true);
});
}
}
this.setState({ value });
onChange && onChange(value, opt);
};
onSearch = (v) => {
const { 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(true);
});
}
onSearch && onSearch(v);
};
filterOption = (v, option) => {
// 自定義過濾對應的 option 屬性配置
const filterProps = this.props.optionFilterProp || 'value';
return `${option.props[filterProps]}`.indexOf(v) >= 0;
};
removeEvent = () => {
if (!this.scrollEle) return;
this.scrollEle.removeEventListener('scroll', this.onScroll, false);
if (!this.inputEle) return;
this.inputEle.removeEventListener('keydown', this.onKeyDown, false);
};
render() {
let { dropdownStyle, optionLabelProp, notFoundContent, ...props } = this.props;
this.allList = this.getUseChildrenList();
this.allHeight = this.allList.length * this.ITEM_HEIGHT || 100;
const { startIndex, endIndex } = this.getStartAndEndIndex();
dropdownStyle = {
maxHeight: `${DROPDOWN_HEIGHT}px`,
...dropdownStyle,
overflow: 'auto',
position: 'relative',
};
const { value } = this.state;
// 判斷處於 antd Form 中時不自動設置 value
const _props = { ...props };
// 先刪除 value,再手動賦值,防止空 value 影響 placeholder
delete _props.value;
// value 爲空字符會隱藏 placeholder,改成 undefined
if (typeof value === 'string' && !value) {
_props.value = undefined;
} else {
_props.value = value;
}
optionLabelProp = optionLabelProp || 'children';
return (
<Select {..._props} id={this.id} onSearch={this.onSearch} onChange={this.onChange} dropdownClassName={this.dropdownClassName} optionLabelProp={optionLabelProp} dropdownStyle={dropdownStyle} onDropdownVisibleChange={this.setSuperDrowDownMenu} ref={(ele) => (this.select = ele)} dropdownRender={(menu) => ( <Wrap {...{ startIndex, endIndex, allHeight: this.allHeight, menu, itemHeight: this.ITEM_HEIGHT, }} ref={(ele) => (this.wrap = ele)} /> )} > {this.allList} </Select> ); } } import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; export default class DropDownWrap extends PureComponent { constructor(props) { super(props); const { allHeight, startIndex, endIndex } = props; this.state = { allHeight, startIndex, endIndex, }; } getItemStyle = (i) => { const { itemHeight } = this.props; return { position: 'absolute', top: itemHeight * i, height: itemHeight, width: '100%', }; }; reactList = (allHeight, startIndex, endIndex) => this.setState({ allHeight, startIndex, endIndex }); render() { const { menu } = this.props; const { startIndex, endIndex, allHeight } = this.state; // 截取 Select 下拉列表中須要顯示的部分 const cloneMenu = React.cloneElement(menu, { menuItems: menu.props.menuItems.slice(startIndex, endIndex).map((item, i) => { const realIndex = (startIndex || 0) + Number(i); const style = this.getItemStyle(realIndex); // 未搜到數據提示高度使用默認高度 if (item.key === 'NOT_FOUND') { delete style.height; } return React.cloneElement(item, { style: { ...item.style, ...style }, }); }), dropdownMenuStyle: { ...menu.props.dropdownMenuStyle, height: allHeight, maxHeight: allHeight, overflow: 'hidden', }, }); return cloneMenu; } } DropDownWrap.propTypes = { list: PropTypes.array, allHeight: PropTypes.number, }; 複製代碼