前言
首先声明,后续所有组件都会有类似详细的教程 + 代码演示(以前的组件库教程不用看了,这是一个新的开始,是生产环境可用的,并且拿国内知名组件库的功能和代码质量作为对比,市面上百分之95%的所谓react组件库教程你都不用看了,大多数骗小白的,欢迎加群交流,我的微信:a2298613245)。
注:其实看源码我发现不少疑惑的代码,觉得不应该那样写,比较明显的,我就给floating-ui提了pr,也合进去了(刚刚合了一个变量重复计算的问题),还有就是给这些知名的库提pr是非常简单的事情,只要你真的去梳理源码,总会有不少能提的,只是大家很少去钻研源码罢了。
腾讯的tdesign的弹出层组件,使用了@popper-js, 大家可以点击链接查看效果,我也看其源码实现了类似的组件,其依赖的@popper-js已经有点过时了,并且因为它是第三方库,如果你的弹出层组件有些定制化的需求,腾讯的tdsign是做不了任何优化的。
所以为了我自己能够做出超越腾讯的tdesign,并且超越ant deisgn类似功能的组件(在ant中叫rc-trigger组件,是ant底层依赖的组件,并没有出现在官网中),我从0到1改造了@popper-js代码。
注:@popper-js现在新版是floating-ui,我也看了其代码,其核心代码大同小异,主要是修复了一些边界的case,和增加了一些性能优化点,我改造的@popper-js也将这些新的代码融入其中。
所以以下的@popper-js应该是增强版,不是官方的@popper-js(看完本篇文章之后可以看第二篇实现代码逻辑)
为什么我的定位组件更好
这个疑问可以换做可为什么@popper-js比国内所有的弹出层组件更好?
受众多,功能非常稳定
因为这个组件的作者专做这一个功能已经有很多年了,在jquery时期就在开始做了,目前github上最受欢迎的弹出层组件,目前的版本来看,就算有bug,也只可能是极为特殊的case,现在是相当稳定的。
代码质量高,易于维护
整个组件以中间件的方式书写,非常易于拓展、理解和改造。其实它就是一个数据层, 我的组件库也是遵循数据和ui分离的原则,也就是每个组件有一个store,存储所有数据和事件(比如 onclick),然后导出的事件和属性用于react(react仅仅是一个视图层)消费。
主要是因为目前国内组件库的组件写法大多数都是react、数据、事件耦合在一起,维护起来很糟心。
功能对比
@popper-js本身跟ant的trigger组件功能差不多,但有一个对开发者非常重要的功能,ant是没有的,
- @popper-js具备自动跟踪的定位的功能,比如滚动条滚动的时候,会自动帮你更改定位坐标,ant要手动设置
- @popper-js启用了css的gpu加速,例如在绝对定位的基础上,使用transform来辅助定位
但是 @popper-js有两个致命的性能问题,
- 其一,滚动的时候,更新定位,会重新绑定事件(先把之前的事件移除,再绑定新的滚动更新定位的事件),所以我做了一个性能优化,把绑定事件交给react组件的useEffect去做,只在组件销毁时销毁事件
- 其二,有些复杂的相同逻辑的数据计算没有缓存数据,导致多次计算
其实字节的arco design的trigger比ant的功能要丰富的多。是唯一我看来能与@popper-js功能上平起平坐的组件(但是代码质量我觉得还是有待提高)。
废话不多说,开写!
梳理主体逻辑,实现一个最简单的定位函数
如下图,我们希望鼠标hover到按钮的时候,其上方会出现一个弹出层:
弹出效果如下:
然后我们抽象一下目标,也就是鼠标在任意的dom元素上出现(你先别管是hover,click还是什么方式触发),我们希望另一个dom元素能够出现在其的上方(可以自定义指定方位,比如下方,左方,左下方,右上方等等)。
所以主体逻辑就非常简单了:我们只要计算按钮的位置,然后得到一个定位的坐标,最后将坐标赋给弹出框即可(利用绝对定位,或者fixed定位,我们统一为绝对定位)。
核心知识点:弹框是绝对定位,那么就会有一个绝对定位的上下文,所以我们计算按钮的坐标的时候,实际上是相对于这个上下文去计算的
相对定位的上下文是什么?(之前的文章提到过)举个例子,你们一个元素的position是absolute,那么它是相当于谁定位?例如:
html
复制代码
<body>
<div>
王二
</div>
<div style="transform: translateX(2px);">
<span style="position: absolute; top: 0" >李四</span>
</div>
</body>
肯定有人说了,这个我熟啊,相当于上面包含它的元素只要不是static定位的。这个没错,但是只答对一部分,还有一种可能,本身元素是static元素也会成为定位上下文,比如给它加一个transform属性,你可以试试上面的代码,李四是相对于transform属性的div定位的。
不仅仅是transform属性,下面的方式都可以成定位上下文元素(当时看源码这里我是怎么也不明白为啥要判断下面这些)
- 有transform、perspective、filter属性中任意一个不为none。
- 是will-change属性值为以上任意一个属性的祖先元素。
- 是contain属性值包含paint、layout、strict或content的祖先元素。
(注:更详细的内容请查看mnd,包含块的概念)
转化思路
既然弹框定位是根据定位上下文来设置top,left这些属性的,那么其实我们只要计算定位上下文到按钮(还是上面的案例,如何计算按钮的位置这个问题)的相对位置即可。
如何计算
如下图:
所以 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离,就是按钮相对于定位上下的距离。
好了,这时定位上下文没有滚动条的情况,如果定位上下文可以滚动,我们还需要加上滚动距离。至此,我们推导出了定位公式:
x = 按钮到视口左边的距离 - 定位上下文到视口左边的距离 + 定位上下文的横向滚动距离
y = 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离 + 定位上下文的纵向滚动距离
到视口的距离,都可以用getBoundingClientRect这个API来实现,滚动距离需要区分是定位上下文是文档元素还是其他普通html元素,比如div元素
- 普通html元素,比如div元素,使用scrollTop这个api来获取滚动距离
- html元素,也就是文档,使用Window.pageYOffset 来获取滚动距离
offsetParent的坑
我们现在要找定位上下是谁,一般都使用offsetParent这个方法,但是它有坑,以下是mnd对其的介绍:
HTMLElement.offsetParent
是一个只读属性,返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的 table
, td
, th
, body
元素。当元素的 style.display
设置为 "none" 时,offsetParent
返回 null
。
也就是说,table
, td
, th
, body
元素,我们要做特殊处理,因为我们是想获取最近的定位的父元素,但是这几个,比如body元素就算是static定位,也会被获取到,我们就要排除这些可能。
代码
首先实现加强版offsetParent方法,具体代码会放到github上,这里主要是帮助大家梳理主要逻辑,后面会逐行解释代码
function getOffsetParent(element: HTMLElement): Element | Window {
let offsetParent = getTrueOffsetParent(element);
// https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement/offsetParent
while (offsetParent && isTableElement(offsetParent) && getComputedStyle(offsetParent).position === 'static') {
offsetParent = getTrueOffsetParent(offsetParent as HTMLElement);
}
// https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement/offsetParent
if (
offsetParent &&
(getNodeName(offsetParent) === 'html' ||
(getNodeName(offsetParent) === 'body' && getComputedStyle(offsetParent).position === 'static' && !isContainingBlock(offsetParent)))
) {
return window;
}
return offsetParent || getContainingBlock(element) || window;
}
首先解释
let offsetParent = getTrueOffsetParent(element);
getTrueOffsetParent要排除一些特殊情况,而不是直接使用element.offsetParent来获取offsetParent,因为例如element不是HTMLElement类型,它是没有offsetParent这个属性的,所以此时如果不是对应的类型要返回null
还有,如果一个dom元素是position是fixed,它的offsetParent属性也是null
接着
while (offsetParent && isTableElement(offsetParent) && getComputedStyle(offsetParent).position === 'static') {
offsetParent = getTrueOffsetParent(offsetParent as HTMLElement);
}
isTableElement的实现:
export function isTableElement(element: Element): boolean {
return ['table', 'td', 'th'].indexOf(getNodeName(element)) >= 0;
}
是排除了之前我们说的,isTableElement排除了'table', 'td', 'th'元素,他们可能会得到错误的offsetParent
但是这里的写法我觉得是有bug的,因为如果这些table元素有transfrom,就是他们是包含块的话,依然可以是定位上下文(现实中几乎遇不到这种情况),所以还需要判断是否是包含块,这样就可以返回这些table元素了。
接着:
if (
offsetParent &&
(getNodeName(offsetParent) === 'html' ||
(getNodeName(offsetParent) === 'body' && getComputedStyle(offsetParent).position === 'static' && !isContainingBlock(offsetParent)))
) {
return window;
}
你看,上面这里处理body这种特殊的offsetParent的情况,同时还判断了是否是包含块,因为即使一个dom元素的offsetParent是body,定位是static,得到错误的offsetParent,但是如果body元素是包含块,绝对定位依然是拿它当做定位上下文的。
当然,offsetParent我设置了一个封顶,基本上到html,就结束寻找了,统一返回window
接着:
return offsetParent || getContainingBlock(element) || window
如果 offsetParent 没有得到dom元素的值,就会寻找包含块,最后用window元素兜底(包含块也不存在)
我们附上判断包含块的函数:
export function getContainingBlock(element: Element): HTMLElement | null {
let currentNode: Node | null = getParentNode(element);
while (isHTMLElement(currentNode) && !['html', 'body', '#document'].includes(getNodeName(currentNode))) {
if (isContainingBlock(currentNode)) {
return currentNode;
} else {
currentNode = getParentNode(currentNode);
}
}
return null;
}
关键函数在于:isContainingBlock,这个是根据mdn的描述来判断的:
export function isContainingBlock(element: Element): boolean {
const safari = isSafari();
const css = getComputedStyle(element);
// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
return (
css.transform !== 'none' ||
css.perspective !== 'none' ||
(css.containerType ? css.containerType !== 'normal' : false) ||
(!safari && (css.backdropFilter ? css.backdropFilter !== 'none' : false)) ||
(!safari && (css.filter ? css.filter !== 'none' : false)) ||
['transform', 'perspective', 'filter'].some((value) => (css.willChange || '').includes(value)) ||
['paint', 'layout', 'strict', 'content'].some((value) => (css.contain || '').includes(value))
);
}
这里,我们把offsetParent的逻辑梳理完毕,我们接着之前的定位逻辑:
x = 按钮到视口左边的距离 - 定位上下文到视口左边的距离 + 定位上下文的横向滚动距离
y = 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离 + 定位上下文的纵向滚动距离
接下来,我们实现这个函数,把定位坐标,也就是x,y坐标求出来。
整体代码如下,我们逐个分析:
export function getCompositeRect(element: Element | VirtualElement, offsetParent: Element | Window): Rect {
const isOffsetParentAnElement = isHTMLElement(offsetParent);
const offsetParentIsScaled = isHTMLElement(offsetParent) && isElementScaled(offsetParent as HTMLElement);
const documentElement = getDocumentElement(offsetParent);
const rect = getBoundingClientRect(element, offsetParentIsScaled);
let scroll = { scrollLeft: 0, scrollTop: 0 };
let offsets = { x: 0, y: 0 };
if (isOffsetParentAnElement) {
if (
getNodeName(offsetParent as Element) !== 'body' ||
// https://github.com/popperjs/popper-core/issues/1078
isScrollParent(documentElement)
) {
scroll = getNodeScroll(offsetParent as HTMLElement | Window);
}
if (isOffsetParentAnElement) {
offsets = getBoundingClientRect(offsetParent as HTMLElement, true);
offsets.x += (offsetParent as HTMLElement).clientLeft;
offsets.y += (offsetParent as HTMLElement).clientTop;
} else if (documentElement as HTMLElement) {
offsets.x = getWindowScrollBarX(documentElement);
}
}
return {
x: rect.left + scroll.scrollLeft - offsets.x,
y: rect.top + scroll.scrollTop - offsets.y,
width: rect.width,
height: rect.height,
};
}
简单来说,第一步先获取到按钮的getBoundingClientRect,核心代码如下:
const rect = getBoundingClientRect(element, offsetParentIsScaled)
为什么单独封装了一个getBoundingClientRect方法呢?,是因为有可能offsetParent元素被缩小或者放大了,比如transform: scale(0.5),缩小到原来长宽的一半。原本dom元素的宽是1000px,加了transform: scale(0.5)之后,变为了宽500px
按道理来说,就按照缩小放大后的坐标去定位也没啥,但是官方认为,我们需要还原成正常尺寸去计算定位。
代码如下:
export function getBoundingClientRect(element: Element | VirtualElement, includeScale: boolean = false): ClientRectObject {
const clientRect = element.getBoundingClientRect();
let scaleX = 1;
let scaleY = 1;
if (includeScale && isHTMLElement(element)) {
scaleX = (element as HTMLElement)?.offsetWidth > 0 ? Math.round(clientRect.width) / (element as HTMLElement).offsetWidth || 1 : 1;
scaleY = (element as HTMLElement)?.offsetHeight > 0 ? Math.round(clientRect.height) / (element as HTMLElement).offsetHeight || 1 : 1;
}
const x = clientRect.left / scaleX;
const y = clientRect.top / scaleY;
const width = clientRect.width / scaleX;
const height = clientRect.height / scaleY;
return {
width,
height,
top: y,
right: x + width,
bottom: y + height,
left: x,
x,
y,
};
}
其中使用了以下代码去计算缩小和放大的倍数
(element as HTMLElement)?.offsetWidth > 0 ? Math.round(clientRect.width) / (element as HTMLElement).offsetWidth || 1 : 1;
最后getBoundingClientRect获得的值都按这个倍数去还原。
接着,我们继续回到上面的公式:
x = 按钮到视口左边的距离 - 定位上下文到视口左边的距离 + 定位上下文的横向滚动距离
y = 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离 + 定位上下文的纵向滚动距离
其中按钮到视口左边的距离
和 按钮到视口顶部的距离
我们上面求出来了,定位上下文到视口左边和顶部的距离,同理我们也可以用同样的方法求出,代码上面已经写了,我们回忆一下:
offsets = getBoundingClientRect(offsetParent as HTMLElement, true);
offsets.x += (offsetParent as HTMLElement).clientLeft;
offsets.y += (offsetParent as HTMLElement).clientTop;
上面的clientLeft是指左边框,也就是左border的宽度,仔细一想,是要把border也算上,要不可能出现border宽度比较大的时候,按钮和定位元素没对齐。如下图:
整体逻辑如下:
上面有一个一部分一部分代码是判断getNodeName(offsetParent as Element) !== 'body',是body元素会有什么问题呢,其实也没啥问题,body元素的scrollLeft和scrollTop总是0,不判断的话,结果也是0,其实没啥区别。
其实代码都写好了,等定位组件梳理代码结束,会把代码放到github上,组件库的架子也会放上去慢慢迭代。
不用怀疑,你要想写react组件库,全网只此一家是最系统的,能上生产环境的,不是骗小白的系列文章。关注没错