下拉列表组件Select能够是前端使用频率最高的UI组件之一。正所以,原生HTML也存在这一标签。但因为对UI的较高追求及统一规范,咱们每每不会去使用即很差看又不统一的原生Select标签,而是本身实现。可以写出一个“多数场景下能用”的Select组件,并无什么难度。直到遇到一些特殊的场景,才意识到要想完成一个组件库级别的做品,并不是易事。本文将会阐述在实际生产环境中由于遇到的问题,并分享Antd的rc-select源码中解决问题的方式。html
近期在工做的项目开发中,须要实现一个Select组件。本着“重复造轮子使我开心”的原则,打开VSCode就是一顿自我感受良好的操做。 直到感受不太好的用户给我发来一张gif图: 前端
“BUG”版Select组件实现比较简单,一个相对定位的Selection + 一个绝对定位的DropdownMenu便可。 针对以上实现,我大体总结了在如下三种场景下会有问题:react
overflow: auto
,Select组件位于较下方。overflow: hidden
,Select组件位于较下方。针对以上场景,分别作了一个简单的demo。 git
鉴于以上场景都不属于小众场景,因此这个“BUG版”的Select组件显然是不合格。github
其实若是经验相对丰富的小伙伴,面对这样的问题应该会条件反射到“render in body”这一律念。(啥是“render in body”呢?React项目中针对须要最高层级展现的组件,便可避开其余组件的影响,同时保留组件化写法的实现方式。最典型的为Modal组件,具体细节可参考我以前写的相关总结) 可是Select组件的问题会比通常的“render in body”复杂许多,咱们姑且以这种方式实现,把须要解决的问题总结为如下两点,并以此为目标探究Ant Design中相关组件源码。算法
(为了便于行文,下文将统一称呼Select组件的触发区域为Selection,下拉菜单为DropdownMenu)app
“render in body”做为React项目一系列问题的最佳实践,虽然我已经屡次领教它的好处。但在具体实现上,Ant Design的拆分粒度仍是很是值得学习的。Portal.js是Ant Design库中专门实现这一功能的抽象。在Select组件中,DropdownMenu将会经过Portal.js渲染,以此解决上述问题1。 具体逻辑可简化为如下几点:dom
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颇有可能处在独立的滚动区域,并不是自然随着页面的滚动而滚动。
如何解决呢? 在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
。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…
阅读源码的收获不少,鉴于篇幅有限,列出重点与你们分享,共同探讨。水平有限,若是错误欢迎你们指出。
相关开源库: