react实现个有特殊需求的级联组件(附带思路和优化点)

需求


ant design有个组件cascader组件,但是不同时支持模糊搜索和远程数据加载,那么自己实现个。
需要实现的需求

  • 远程加载所有一级类目录,点击不同的一级类目录,远程请求加载不同的二级类目录。
  • 同时二级类目录还需要一个搜索功能,需要按照用户输入的内容实时获取对应的二级目录
    具体可以粗略的见下图
    在这里插入图片描述

需求分析


  • 最上面的那个选择框,只能让用户点击,不能输入,实现方法太多,我们使用 改改样式即可
  • 下拉框无论是一级还是二级,其实就是个list,这里我们用ul>li实现
  • 二级下拉框上的input输入框,我们使用input即可
  • 这里只需要二级,我们也只实现二级,如果像其他框架的无限制层级,只需要把list做成组件,传值即可,这里就不展开了,有兴趣的童鞋可以自己实现!
  • 二级下拉框因为要按照用户输入的实时搜索,所有我们使用节流,暂定1s搜索一次,如果搜索太频繁,很容易导致发送请求过多,增加服务端压力。在这里我们可以增加个模式,用户可以控制是否使用远程实时加载,如果不使用远程实时加载,那么使用本地模糊匹配。
  • 二级下拉框,如果数据量过大,我们也需要一个滚动加载,所有在二级下拉框中,我们需要来个防抖,不要频繁触发scroll事件。
  • 为了方便用户,我们暂定如果二级数据少,用户下拉框使用本地模糊搜索,也不存在滚动加载!如果数据多,我们就采用滚动加载和远程实时加载相结合!

注意点


  • ant design的组件很多都是需要你触发后,点击组件相关的,组件继续展开,点击组件以为其他地方,组件自动关闭,详见下图:组件展开后,点击红色区域,组件一直处于展开状态,点击其他区域,组件关闭!
    在这里插入图片描述
    这里实现的思路很简单,组件内部只需要状态判断,即可自己控制组件展开和收缩!问题在于如何控制组件以外的地方,如果通过组件获得用户网页的body,再绑定,显然不现实,因为这个需要用户把最外层的元素的this传入,所有我们的实现方案是给组件增加个全局透明的遮罩层,当组件展开后,遮罩层自动出现,这个时候只要点击在红色方框之外,其实就是点击在遮罩层,方便我们收缩组件。说白了,遮罩层也是组件一部分!
  • 第二个问题就是组件展开内容的定位,我们需要让效果真实,所以,我们每次展开组件,都重新计算组件展开内容的位置!

初步实现


代码都是简化的,如果要使用到实际中,请自己加以改造

class CascaderSelf extends React.Component {
constructor(props) {
        super(props);
        this.throttleData = Throttle(this.getChildData);
        this.scrollDataDeb= Debounce(this.scrollData)
    }
	state = {
        show1: false,
        parentValue: '',
        parentText: '',
        childNode: [],
        childText: '',
        childValue: '',
        ParentNode: [],
        loading: false,
        filterValue: '',
        mode: false, 
        page: 1,
        pageSize:20,
        total: 0,
    };
    componentDidMount() {
        this.setState({
            ParentNode: this.props.ParentNode,
            parentText: this.props.parentText,
            mode: this.props.mode
        })
    }

    componentWillReceiveProps(props) {
        const {
            ParentNode,
            childText,
            mode = false,
        } = props;

        this.setState({
            ParentNode,
            childText,
            mode,
        })
    }
    showLabel = (e) => {
        this.setState({
            show1: true,
            left1: e.currentTarget.getBoundingClientRect().left,
            top1: e.currentTarget.getBoundingClientRect().top + e.currentTarget.clientHeight + 2,
            width: e.currentTarget.clientWidth
        })
    }
    showLabel2 = (e) => {
        e.stopPropagation();
        const parentValue = e.target.getAttribute('value');
        const {
            mode,
            pageSize
        } = this.state;
        const params = {
            value: parentValue,
        };
        if (mode) {
            params['page'] = 1;
            params['pageSize'] = pageSize;
        }
        setTimeout(() => {
            this.getChildData({
                ...params,
            });
        }, 2)
        this.setState({
            parentValue,
            show2: true,
            parentText: e.target.innerHTML,
            loading: true,
            childValue: '',
            childText: '',
            childNode: [],
            page: 1,
        })
    }
    closeAll = (flag, e) => {
        if (e.target.nodeName == 'LI') {
            if (flag) {
                const {
                    childNode
                } = this.state;
                const childValue = e.target.getAttribute('value');
                const childText = e.target.innerHTML;
                const chlidOpts = childNode.filter(v => v.value == childValue)
                const {
                    parentValue
                } = this.state;
                this.setState({
                    childValue,
                    childText
                })
                this.props.onChange([parentValue, childValue], chlidOpts[0] || {})
            }
        }
        if (e.target.nodeName != 'Input') {
            this.setState({
                show1: false,
                show2: false,
                loading: false,
                childNode: []
            })
        }
    }
    getChildData = (params) => {
        let {
            page,
            childNode,
        } = this.state;
        this.props.loadData(params, (newNode, total = 0) => {
            this.setState({
                childNode: [...childNode, ...newNode],
                loading: false,
                page: ++page,
                total,
            })
        })
    }
    render(){
		const {
            show1,
            parentValue,
            show2,
            left1,
            top1,
            width,
            childNode,
            childValue,
            childText,
            ParentNode,
            loading,
            filterValue,
            mode
        } = this.state;
        const {
            showLabel,
            closeAll,
            searchChildData,
            scrollDataDeb,
        } = this;
	}
	scrollData = () => {
        let scrollTop = this.listRef.scrollTop;
        let clientHeight = this.listRef.clientHeight;
        let scrollHeight = this.listRef.scrollHeight;
        const {
            loading, 
            page, 
            mode,
            parentValue,
            filterValue,
            pageSize,
            total,
        } = this.state;
        if (total <= (page - 1) * pageSize) return;
        if ((scrollTop + clientHeight >= scrollHeight - 20) && !loading) {
            this.setState({
                loading: true
            });
            setTimeout(() => {
                this.getChildData({
                    value: parentValue,
                    name: filterValue,
                    page,
                    pageSize
                });
            }, 2)
        }
    }
	searchChildData = (e) => {
        const {
            mode,
            parentValue,
            pageSize,
            filterValue: oldfilterValue,
            childNode
        } = this.state;
        const filterValue = e.target.value.trim('');
        if (filterValue == oldfilterValue) return;
        this.setState({
            filterValue,
            page: 1,
            childNode: mode ? [] : childNode
        });
        if (!mode) return;
        setTimeout(() => {
            this.throttleData({
                value: parentValue,
                name: filterValue,
                page:1,
                pageSize
            });
        }, 2)
    }
	return (
            <div style={{ width: '100%' }}>
                <Input
                    type='button'
                    onBlur={e => {

                    }}
                    onFocus={e => {
                        showLabel(e)
                    }}
                    value={childText}
                    className={'onlyButton'}
                />
                {show1 && <div style={{ position: 'fixed', left: 0, right: 0, top: 0, bottom: 0, zIndex: 10000 }} onClick={e => closeAll(false, e)}>
                    <div style={{ position: 'absolute', left: left1 + 'px', top: top1 + 'px', zIndex: 100, display: 'flex' }}>
                        <Spin spinning={loading}>
                            <ul className={styles.box} style={{ minWidth: width + 'px', maxHeight: '300px', overflow: 'auto' }} onClick={this.showLabel2}>
                                {ParentNode.map(v => <li className={classnames({ [styles.common]: true, [styles.active]: parentValue == v.value })} value={v.value}>{v.label}</li>)}
                            </ul>
                        </Spin>
                        {show2 && <Spin spinning={loading}>
                            <div>
                                <Input
                                    onClick={e => {
                                        e.stopPropagation()
                                    }}
                                    onChange={
                                        e => {
                                            searchChildData(e)
                                        }
                                    }
                                    value={filterValue}
                                />
                            </div>
                            {mode&&<ul ref={el => this.listRef = el} onScroll={e => { scrollDataDeb() }} style={{ minWidth: width + 'px', background: '#fff', height: '300px', overflow: 'auto' }} onClick={e => this.closeAll(true, e)}>
                                {childNode.map(v => <li className={classnames({ [styles.common]: true, [styles.active]: childValue == v.value })} value={v.value}>{v.label}</li>)}
                            </ul>}
                            {!mode&&<ul style={{ minWidth: width + 'px', background: '#fff',minHeight: '90px', maxHeight: '300px', overflow: 'auto' }} onClick={e => this.closeAll(true, e)}>
                                {filterValue ?
                                    childNode.filter(v => v.label.indexOf(filterValue) > -1).map(v => <li className={classnames({ [styles.common]: true, [styles.active]: childValue == v.value })} value={v.value}>{v.label}</li>) :
                                    childNode.map(v => <li className={classnames({ [styles.common]: true, [styles.active]: childValue == v.value })} value={v.value}>{v.label}</li>)
                                }
                            </ul>}
                        </Spin>
                        }
                    </div>
                </div>}
            </div>
        );
}

上文中的节流和防抖大家自行实现,主要讲讲下面几点

  • mode,当mode是true的时候,是数据比较多的情况,所以匹配了二级滚动加载和远程模糊搜索,mode为false的时候,则是数据比较少的,可以本地模糊匹配
  • 文中出现了this.props.loadData是客户自己用于请求数据的,所以有很多的回调方法,修改组件的state
  • 用户loadData的实现,可以用promise,在then中调用回调函数!这个和ant design的思路有点不同,ant design是修改了所有的数据源,也就是options,然后继续重新渲染cascader组件,我们的方法是把重新获取的数据通过组件回调函数处理,更新组件的state,再重新渲染!
  • 用户onChange实现没啥要求,唯一的要求是更新props或者state,触发组件的渲染
  • ant design是统一了数据渲染,外界传入的数据修改了,就重新渲染。我们这个组件其实也是同样的方法,只是我们用了回调函数,传新增给组件,让组件重新渲染。大家可以对比下ant design 的loadData方法,发现多了个cb回调给客户使用,虽然耦合有点强,但是却减少了组件数据的解析!有兴趣的童鞋可以修改成类似cascader那种,直接修改options的方式处理!

下面是部分样式

.common {
    background: #fff;
    min-height: 30px;
    line-height: 30px;
    padding: 0 8px;
    cursor: pointer;
    &:hover {
        background: #ecf6fd;
    }
}

.active {
    background-color: #f7f7f7;
    font-weight: 600;
    color: rgba(0,0,0,.65);
    &:hover {
        background: #f7f7f7;
    }
}

.box {
    background-color: #fff;
    box-shadow: 0 1px 6px rgba(0,0,0,.2);
    border-radius: 4px;
    box-sizing: border-box;
}

后记


  • 组件的思路其实很多,写代码理顺思路大概要花70%的时间,真的实现起来,也就只有30%
  • 上述组件其实还有几个地方可以优化
    1.通用性,就像一开始提到的,把组件ul>li这段修改成组件,同时loadData不再使用cb更新数据,而是像ant design一样,每次传入全部数据,然后解析
    2.用户定制化,用户可以自己传入valueKey和labelKey,来定制化获得的值,而不是像现在组件这里都使用固定value和label
    3.契合ant design的form表单,思路很简单,看下form表单对组件做了哪些处理,按照form表单接受的形式,改造下组件即可
  • 这个组件其实很多子组件是基于ant Design,大家可以看到组件中的Input,Spin等等,这也契合了react 的积木思想,大家可以把一个个组件看成乐高积木,自由搭配成你想要的组件即可!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章