支持大數據渲染下拉列表組件開發 SuperSelect(基於antd Select)

功能簡介

antd 的 Select 組件不支持大數據量的下拉列表渲染,下拉列表數量太多會出現性能問題,
SuperSelect 基於 antd 封裝實現,替換原組件下拉列表,只渲染幾十條列表數據,隨下拉列表滾動動態刷新可視區列表狀態,實現大數據量列表高性能渲染。

  • 特性

    1. 基於 antd Select 組件,不修改組件用法
    2. 替換 antd Select 組件的下拉列表部分實現動態渲染列表
    3. 初步測試 10w 條數據不卡頓
  • 實現方案

    1. 使用 antd Select dropdownRender 方法自定義原組件下拉列表部分
    2. 對自定義列表項綁定原 Select 組件的各項方法和回調函數支持
    3. 同步使用 antd 組件下拉列表樣式

在線地址

使用

基本使用同 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);
    };
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章