论如何实现一个完美的Select组件

13,171 阅读6分钟

前言

下拉列表组件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.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…

总结

阅读源码的收获很多,鉴于篇幅有限,列出重点与大家分享,共同探讨。水平有限,如果错误欢迎大家指出。

相关开源库: