論如何實現一個完美的Select元件

站酷前端小組發表於2018-05-21

前言

下拉選單元件Select可以是前端使用頻率最高的UI元件之一。正因此,原生HTML也存在這一標籤。但由於對UI的較高追求及統一規範,我們往往不會去使用即不好看又不統一的原生Select標籤,而是自己實現。能夠寫出一個“多數場景下能用”的Select元件,並沒有什麼難度。直到遇到一些特殊的場景,才意識到要想完成一個元件庫級別的作品,並非易事。本文將會闡述在實際生產環境中因為遇到的問題,並分享Antd的rc-select原始碼中解決問題的方式。

錯誤的例子

近期在工作的專案開發中,需要實現一個Select元件。本著“重複造輪子使我開心”的原則,開啟VSCode就是一頓自我感覺良好的操作。直到感覺不太好的使用者給我發來一張gif圖:

bug動圖

“BUG”版Select元件實現比較簡單,一個相對定位的Selection + 一個絕對定位的DropdownMenu即可。針對以上實現,我大致總結了在以下三種場景下會有問題:

  1. 父級容器overflow: auto,Select元件位於較下方。
  2. 父級容器overflow: hidden,Select元件位於較下方。
  3. 父級容器的層級較低時,高層級元素與DropdownMenu位置重合。

針對以上場景,分別做了一個簡單的demo。

導致錯誤的場景

線上預覽

鑑於以上場景都不屬於小眾場景,所以這個“BUG版”的Select元件顯然是不合格。

第一直覺

其實如果經驗相對豐富的小夥伴,面對這樣的問題應該會條件反射到“render in body”這一概念。(啥是“render in body”呢?React專案中針對需要最高層級展示的元件,即可避開其他元件的影響,同時保留元件化寫法的實現方式。最典型的為Modal元件,具體細節可參考我之前寫的相關總結)但是Select元件的問題會比一般的“render in body”複雜許多,我們姑且以這種方式實現,把需要解決的問題總結為以下兩點,並以此為目標探究Ant Design中相關元件原始碼。

  1. 如何避免其他元素對DropdownMenu的影響?及對DropdownMenu其他元素的影響?(render in body)
  2. Selection和DropdownMenu分離在不同DOM層級,相對位置如何計算?頁面滾動時,兩者的位置能保證不變嗎?

(為了便於行文,下文將統一稱呼Select元件的觸發區域為Selection,下拉選單為DropdownMenu)

Render in body

“render in body”作為React專案一系列問題的最佳實踐,雖然我已經多次領教它的好處。但在具體實現上,Ant Design的拆分粒度還是非常值得學習的。Portal.js是Ant Design庫中專門實現這一功能的抽象。在Select元件中,DropdownMenu將會通過Portal.js渲染,以此解決上述問題1。具體邏輯可簡化為以下幾點:

  1. componentDidMount: create一個div至於root節點下,賦值給this._container
  2. render: return ReactDOM.createPortal(this.props.children, this._container) (其中this.props.children包含著DropdownMenu)
  3. 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處於獨立滾動區域而引發的bug

上圖中,Selection位於一個獨立的滾動區域,而DropdownMenu位於body下。因此出現了圖中的狀況:

  • 當頁面級別的滾動時,Selection與DropdownMenu的位置可以保證同步。
  • 當Selection所處的獨立區域滾動時,位置就會發生錯亂。

如何解決呢?在Ant Design Select元件的文件中,有一個特殊的props:

getPopupContainer

上文在渲染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”。放一張設定了正確的getPopupContainerChrome Element截圖大家感受一下:

Selection處於獨立滾動區域而引發的bug

在計算DropdownMenu的位置上,dom-align的演算法策略十分巧妙,避免了區分滾動父級是否是body的問題,但略顯得過於複雜。(以下過程均以top值為例,left值同理)

  1. 通過element.getBoundingClientRect計算出Selection的相對可視區的絕對位置top1
  2. 通過使用者設定的Props(即擺放的方向,間距等)計算出DropdownMenu相對可視區的絕對位置top2
  3. 將DropdownMenu的top值設定為-9999,並通過element.getBoundingClientRect獲取DropdownMenu當前top值top3
  • 如果DropdownMenu位於body下,top3 = 0 - 9999
  • 如果DropdownMenu並非位於body下,top3 = 滾動父級至body的距離 - 9999
  1. top4 = top2 - top3 = top2 - (滾動父級至body的距離 - 9999) = top2 - 滾動父級至body的距離 + 9999
  2. 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

相關文章