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