论如何实现一个完美的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…

总结

阅读源码的收获不少,鉴于篇幅有限,列出重点与你们分享,共同探讨。水平有限,若是错误欢迎你们指出。

相关开源库:

相关文章
相关标签/搜索