下拉列表組件Select能夠是前端使用頻率最高的UI組件之一。正所以,原生HTML也存在這一標籤。但因爲對UI的較高追求及統一規範,咱們每每不會去使用即很差看又不統一的原生Select標籤,而是本身實現。可以寫出一個「多數場景下能用」的Select組件,並無什麼難度。直到遇到一些特殊的場景,才意識到要想完成一個組件庫級別的做品,並不是易事。本文將會闡述在實際生產環境中由於遇到的問題,並分享Antd的rc-select源碼中解決問題的方式。html
近期在工做的項目開發中,須要實現一個Select組件。本着「重複造輪子使我開心」的原則,打開VSCode就是一頓自我感受良好的操做。 直到感受不太好的用戶給我發來一張gif圖: 前端
「BUG」版Select組件實現比較簡單,一個相對定位的Selection + 一個絕對定位的DropdownMenu便可。 針對以上實現,我大體總結了在如下三種場景下會有問題:react
overflow: auto
,Select組件位於較下方。overflow: hidden
,Select組件位於較下方。針對以上場景,分別作了一個簡單的demo。 git
鑑於以上場景都不屬於小衆場景,因此這個「BUG版」的Select組件顯然是不合格。github
其實若是經驗相對豐富的小夥伴,面對這樣的問題應該會條件反射到「render in body」這一律念。(啥是「render in body」呢?React項目中針對須要最高層級展現的組件,便可避開其餘組件的影響,同時保留組件化寫法的實現方式。最典型的爲Modal組件,具體細節可參考我以前寫的相關總結) 可是Select組件的問題會比通常的「render in body」複雜許多,咱們姑且以這種方式實現,把須要解決的問題總結爲如下兩點,並以此爲目標探究Ant Design中相關組件源碼。算法
(爲了便於行文,下文將統一稱呼Select組件的觸發區域爲Selection,下拉菜單爲DropdownMenu)app
「render in body」做爲React項目一系列問題的最佳實踐,雖然我已經屢次領教它的好處。但在具體實現上,Ant Design的拆分粒度仍是很是值得學習的。Portal.js是Ant Design庫中專門實現這一功能的抽象。在Select組件中,DropdownMenu將會經過Portal.js渲染,以此解決上述問題1。 具體邏輯可簡化爲如下幾點:dom
this._container
。return ReactDOM.createPortal(this.props.children, this._container)
(其中this.props.children
包含着DropdownMenu)this._container
如下是一些關鍵的代碼// Portal.js
export default class Portal extends React.Component {
componentDidMount() {
this.createContainer();
}
componentWillUnmount() {
this.removeContainer();
}
createContainer() {
this._container = this.props.getContainer();
this.forceUpdate();
}
render() {
if (this._container) {
return ReactDOM.createPortal(this.props.children, this._container);
}
return null;
}
}
// 上述組件的this.props.getContainer
getContainer = () => {
const { props } = this;
const popupContainer = document.createElement('div');
popupContainer.style.position = 'absolute';
popupContainer.style.top = '0';
popupContainer.style.left = '0';
popupContainer.style.width = '100%';
// mountNode: 劃重點,後文詳細敘述
const mountNode = props.getPopupContainer ?
props.getPopupContainer(findDOMNode(this)) : props.getDocument().body;
mountNode.appendChild(popupContainer);
return popupContainer;
}
複製代碼
因爲DropdownMenu位於body節點位置,因此就涉及到Selection與DropdownMenu的位置計算問題。渲染DropdownMenu的源碼可簡化爲以下結構:組件化
<Protal>
<Animate>
<Align>
<DropdownMenu/>
</Align>
</Animate>
</Protal>
複製代碼
其中Protal
是將Children渲染至body下,Animate
是控制展現/收起動畫,而Align
這個包,就是用於計算位置的。 多數狀況下,Selection相對頁面的位置是靜態的,自然隨着頁面的滾動而滾動。而DropdownMenu以絕對定位的形式存在於body下,也是自然隨着頁面的滾動而滾動的,所以只要計算好Selection相對頁面的位置,根據用戶須要略微調整賦值給DropdownMenu便可。 計算思路: 元素相對可視區的距離element.getBoundingClientRect.top/left
+ 頁面滾動距離documentElement.scrollTop/Left
便可。(具體計算細節十分巧妙且複雜,下文統一展開) 關鍵代碼以下:學習
// dom-align src/utils.js
function getOffset(el) {
// 獲取相對可視區的距離
const pos = getClientPosition(el);
const doc = el.ownerDocument;
const w = doc.defaultView || doc.parentWindow;
// 加等頁面滾動距離
pos.left += getScrollLeft(w);
pos.top += getScrollTop(w);
return pos;
}
複製代碼
上文在解決位置計算與同步滾動的問題上,爲了便於理解,咱們默認了一個觀點:
多數狀況下,Selection相對頁面的位置是靜態的,自然隨着頁面的滾動而滾動。
實際場景中,Selection頗有可能處在獨立的滾動區域,並不是自然隨着頁面的滾動而滾動。
如何解決呢? 在Ant Design Select組件的文檔中,有一個特殊的props:
上文在渲染DropdownMenu的代碼中,有一處註釋讓你們留意的:
getContainer = () => {
// ...
const mountNode = props.getPopupContainer ?
props.getPopupContainer(findDOMNode(this)) : props.getDocument().body;
mountNode.appendChild(popupContainer);
return popupContainer;
}
複製代碼
若是用戶設置了propsgetPopupContainer
,此處的mountNode
將會是Selection所處的滾動父級,即DropdownMenu將會被渲染在Selection的滾動父級下,而再也不是「render in body」。 放一張設置了正確的getPopupContainer
Chrome Element截圖你們感覺一下:
在計算DropdownMenu的位置上,dom-align的算法策略十分巧妙,避免了區分滾動父級是不是body的問題,但略顯得過於複雜。 (如下過程均以top
值爲例,left
值同理)
element.getBoundingClientRect
計算出Selection的相對可視區的絕對位置top1
。top2
。element.getBoundingClientRect
獲取DropdownMenu當前top值top3
。top3 = 0 - 9999
。top3 = 滾動父級至body的距離 - 9999
。top4
= top2 - top3
= top2 - (滾動父級至body的距離 - 9999)
= top2 - 滾動父級至body的距離 + 9999
top5
= -9999 + top4
= -9999 + top2 - 滾動父級至body的距離 + 9999
= top2 - 滾動父級至body的距離
最終,top5
將會是設置給DropdownMenu的真實style值。鑑於源碼拆分較細,實現複雜,就不具體展現了。源碼地址,github.com/yiminghe/do…
閱讀源碼的收穫不少,鑑於篇幅有限,列出重點與你們分享,共同探討。水平有限,若是錯誤歡迎你們指出。
相關開源庫: