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

前言

下拉列表組件Select能夠是前端使用頻率最高的UI組件之一。正所以,原生HTML也存在這一標籤。但因爲對UI的較高追求及統一規範,咱們每每不會去使用即很差看又不統一的原生Select標籤,而是本身實現。可以寫出一個「多數場景下能用」的Select組件,並無什麼難度。直到遇到一些特殊的場景,才意識到要想完成一個組件庫級別的做品,並不是易事。本文將會闡述在實際生產環境中由於遇到的問題,並分享Antd的rc-select源碼中解決問題的方式。html

錯誤的例子

近期在工做的項目開發中,須要實現一個Select組件。本着「重複造輪子使我開心」的原則,打開VSCode就是一頓自我感受良好的操做。 直到感受不太好的用戶給我發來一張gif圖: 前端

bug動圖

「BUG」版Select組件實現比較簡單,一個相對定位的Selection + 一個絕對定位的DropdownMenu便可。 針對以上實現,我大體總結了在如下三種場景下會有問題:react

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

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

致使錯誤的場景
在線預覽

鑑於以上場景都不屬於小衆場景,因此這個「BUG版」的Select組件顯然是不合格。github

第一直覺

其實若是經驗相對豐富的小夥伴,面對這樣的問題應該會條件反射到「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)app

Render in body

「render in body」做爲React項目一系列問題的最佳實踐,雖然我已經屢次領教它的好處。但在具體實現上,Ant Design的拆分粒度仍是很是值得學習的。Portal.js是Ant Design庫中專門實現這一功能的抽象。在Select組件中,DropdownMenu將會經過Portal.js渲染,以此解決上述問題1。 具體邏輯可簡化爲如下幾點:dom

  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.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頗有可能處在獨立的滾動區域,並不是自然隨着頁面的滾動而滾動。

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…

總結

閱讀源碼的收穫不少,鑑於篇幅有限,列出重點與你們分享,共同探討。水平有限,若是錯誤歡迎你們指出。

相關開源庫:

相關文章
相關標籤/搜索