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