需求
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 的积木思想,大家可以把一个个组件看成乐高积木,自由搭配成你想要的组件即可!