【目前最好的react组件库教程】手写增强版 @popper-js (主体逻辑分析)

前言

首先声明,后续所有组件都会有类似详细的教程 + 代码演示(以前的组件库教程不用看了,这是一个新的开始,是生产环境可用的,并且拿国内知名组件库的功能和代码质量作为对比,市面上百分之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到按钮的时候,其上方会出现一个弹出层:

image.png

弹出效果如下:

image.png

然后我们抽象一下目标,也就是鼠标在任意的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属性,下面的方式都可以成定位上下文元素(当时看源码这里我是怎么也不明白为啥要判断下面这些)

  1. 有transform、perspective、filter属性中任意一个不为none。
  2. 是will-change属性值为以上任意一个属性的祖先元素。
  3. 是contain属性值包含paint、layout、strict或content的祖先元素。

(注:更详细的内容请查看mnd,包含块的概念)

转化思路

既然弹框定位是根据定位上下文来设置top,left这些属性的,那么其实我们只要计算定位上下文到按钮(还是上面的案例,如何计算按钮的位置这个问题)的相对位置即可。

如何计算

如下图:

image.png

所以 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离,就是按钮相对于定位上下的距离。

好了,这时定位上下文没有滚动条的情况,如果定位上下文可以滚动,我们还需要加上滚动距离。至此,我们推导出了定位公式:

x =  按钮到视口左边的距离 - 定位上下文到视口左边的距离 + 定位上下文的横向滚动距离
y = 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离 + 定位上下文的纵向滚动距离

到视口的距离,都可以用getBoundingClientRect这个API来实现,滚动距离需要区分是定位上下文是文档元素还是其他普通html元素,比如div元素

  • 普通html元素,比如div元素,使用scrollTop这个api来获取滚动距离
  • html元素,也就是文档,使用Window.pageYOffset 来获取滚动距离

offsetParent的坑

我们现在要找定位上下是谁,一般都使用offsetParent这个方法,但是它有坑,以下是mnd对其的介绍:

HTMLElement.offsetParent 是一个只读属性,返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的 tabletdthbody 元素。当元素的 style.display 设置为 “none” 时,offsetParent 返回 null

也就是说,tabletdthbody元素,我们要做特殊处理,因为我们是想获取最近的定位的父元素,但是这几个,比如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宽度比较大的时候,按钮和定位元素没对齐。如下图:

image.png

整体逻辑如下:

image.png

上面有一个一部分一部分代码是判断getNodeName(offsetParent as Element) !== ‘body’,是body元素会有什么问题呢,其实也没啥问题,body元素的scrollLeft和scrollTop总是0,不判断的话,结果也是0,其实没啥区别。

其实代码都写好了,等定位组件梳理代码结束,会把代码放到github上,组件库的架子也会放上去慢慢迭代。

不用怀疑,你要想写react组件库,全网只此一家是最系统的,能上生产环境的,不是骗小白的系列文章。关注没错

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值