下拉列表组件Select可以是前端使用频率最高的UI组件之一。正因此,原生HTML也存在这一标签。但由于对UI的较高追求及统一规范,我们往往不会去使用即不好看又不统一的原生Select标签,而是自己实现。能够写出一个“多数场景下能用”的Select组件,并没有什么难度。直到遇到一些特殊的场景,才意识到要想完成一个组件库级别的作品,并非易事。本文将会阐述在实际生产环境中因为遇到的问题,并分享Antd的 rc-select 源码中解决问题的方式。
近期在工作的项目开发中,需要实现一个Select组件。本着“重复造轮子使我开心”的原则,打开VSCode就是一顿自我感觉良好的操作。 直到感觉不太好的用户给我发来一张gif图:
“BUG”版Select组件实现比较简单,一个相对定位的Selection + 一个绝对定位的DropdownMenu即可。 针对以上实现,我大致总结了在以下三种场景下会有问题:
overflow: auto overflow: hidden
针对以上场景,分别做了一个简单的demo。
在线预览鉴于以上场景都不属于小众场景,所以这个“BUG版”的Select组件显然是不合格。
其实如果经验相对丰富的小伙伴,面对这样的问题应该会条件反射到“render in body”这一概念。(啥是“render in body”呢?React项目中针对需要最高层级展示的组件,即可避开其他组件的影响,同时保留组件化写法的实现方式。最典型的为Modal组件,具体细节可参考我之前写的相关总结) 但是Select组件的问题会比一般的“render in body”复杂许多,我们姑且以这种方式实现,把需要解决的问题总结为以下两点,并以此为目标探究 Ant Design 中相关组件源码。
(为了便于行文,下文将统一称呼Select组件的触发区域为Selection,下拉菜单为DropdownMenu)
“render in body”作为React项目一系列问题的最佳实践,虽然我已经多次领教它的好处。但在具体实现上,Ant Design的拆分粒度还是非常值得学习的。 Portal.js 是Ant Design库中专门实现这一功能的抽象。在Select组件中,DropdownMenu将会通过Portal.js渲染,以此解决上述问题1。 具体逻辑可简化为以下几点:
this._container
。 return ReactDOM.createPortal(this.props.children, this._container)
(其中 this.props.children
包含着DropdownMenu) 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位于一个独立的滚动区域,而DropdownMenu位于body下。因此出现了图中的状况:
如何解决呢? 在 Ant Design Select组件 的文档中,有一个特殊的props:
上文在渲染DropdownMenu的代码中,有一处注释让大家留意的:
getContainer = () => { // ... const mountNode = props.getPopupContainer ? props.getPopupContainer(findDOMNode(this)) : props.getDocument().body; mountNode.appendChild(popupContainer); return popupContainer; }
如果用户设置了props getPopupContainer
,此处的 mountNode
将会是Selection所处的滚动父级,即DropdownMenu将会被渲染在Selection的滚动父级下,而不再是“render in body”。 放一张设置了正确的 getPopupContainer
Chrome Element截图大家感受一下:
在计算DropdownMenu的位置上,dom-align的算法策略十分巧妙,避免了区分滚动父级是否是body的问题,但略显得过于复杂。 (以下过程均以 top
值为例, left
值同理)
element.getBoundingClientRect
计算出Selection的相对可视区的绝对位置 top1
。 top2
。 element.getBoundingClientRect
获取DropdownMenu当前top值 top3
。 top3 = 0 - 9999 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…