前言
下拉選單元件Select可以是前端使用頻率最高的UI元件之一。正因此,原生HTML也存在這一標籤。但由於對UI的較高追求及統一規範,我們往往不會去使用即不好看又不統一的原生Select標籤,而是自己實現。能夠寫出一個“多數場景下能用”的Select元件,並沒有什麼難度。直到遇到一些特殊的場景,才意識到要想完成一個元件庫級別的作品,並非易事。本文將會闡述在實際生產環境中因為遇到的問題,並分享Antd的rc-select原始碼中解決問題的方式。
錯誤的例子
近期在工作的專案開發中,需要實現一個Select元件。本著“重複造輪子使我開心”的原則,開啟VSCode就是一頓自我感覺良好的操作。直到感覺不太好的使用者給我發來一張gif圖:
“BUG”版Select元件實現比較簡單,一個相對定位的Selection + 一個絕對定位的DropdownMenu即可。針對以上實現,我大致總結了在以下三種場景下會有問題:
- 父級容器
overflow: auto
,Select元件位於較下方。 - 父級容器
overflow: hidden
,Select元件位於較下方。 - 父級容器的層級較低時,高層級元素與DropdownMenu位置重合。
針對以上場景,分別做了一個簡單的demo。
鑑於以上場景都不屬於小眾場景,所以這個“BUG版”的Select元件顯然是不合格。
第一直覺
其實如果經驗相對豐富的小夥伴,面對這樣的問題應該會條件反射到“render in body”這一概念。(啥是“render in body”呢?React專案中針對需要最高層級展示的元件,即可避開其他元件的影響,同時保留元件化寫法的實現方式。最典型的為Modal元件,具體細節可參考我之前寫的相關總結)但是Select元件的問題會比一般的“render in body”複雜許多,我們姑且以這種方式實現,把需要解決的問題總結為以下兩點,並以此為目標探究Ant Design中相關元件原始碼。
- 如何避免其他元素對DropdownMenu的影響?及對DropdownMenu其他元素的影響?(render in body)
- Selection和DropdownMenu分離在不同DOM層級,相對位置如何計算?頁面滾動時,兩者的位置能保證不變嗎?
(為了便於行文,下文將統一稱呼Select元件的觸發區域為Selection,下拉選單為DropdownMenu)
Render in body
“render in body”作為React專案一系列問題的最佳實踐,雖然我已經多次領教它的好處。但在具體實現上,Ant Design的拆分粒度還是非常值得學習的。Portal.js是Ant Design庫中專門實現這一功能的抽象。在Select元件中,DropdownMenu將會通過Portal.js渲染,以此解決上述問題1。具體邏輯可簡化為以下幾點:
- componentDidMount: create一個div至於root節點下,賦值給
this._container
。 - render:
return ReactDOM.createPortal(this.props.children, this._container)
(其中this.props.children
包含著DropdownMenu) - componentWillUnmount: 刪除
this._container
以下是一些關鍵的程式碼
// Portal.jsexport 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.getContainergetContainer = () =>
{
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.jsfunction 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很有可能處在獨立的滾動區域,並非天然隨著頁面的滾動而滾動。
上圖中,Selection位於一個獨立的滾動區域,而DropdownMenu位於body下。因此出現了圖中的狀況:
- 當頁面級別的滾動時,Selection與DropdownMenu的位置可以保證同步。
- 當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
。 - 通過使用者設定的Props(即擺放的方向,間距等)計算出DropdownMenu相對可視區的絕對位置
top2
。 - 將DropdownMenu的top值設定為-9999,並通過
element.getBoundingClientRect
獲取DropdownMenu當前top值top3
。
- 如果DropdownMenu位於body下,
top3 = 0 - 9999
。 - 如果DropdownMenu並非位於body下,
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…
總結
閱讀原始碼的收穫很多,鑑於篇幅有限,列出重點與大家分享,共同探討。水平有限,如果錯誤歡迎大家指出。
相關開源庫:
來源:https://juejin.im/post/5b02b960f265da0b9e655e61?utm_medium=fe&utm_source=weixinqun