likes
comments
collection
share

【react 定位组件】源码分析(第一部分)

作者站长头像
站长
· 阅读数 25

前言

react组件库系列耽搁了一些时间,继续了!定位组件在b端里面实在太常见了。

首先介绍一下什么是定位组件,如下图:

【react 定位组件】源码分析(第一部分) 也就是上面的“这是一个弹窗的div”其实是绝对定位到按钮上的,因为是绝对定位,所以可以定位到任意元素上。

整体源码来自于t-design的popup组件

但是他们也是二次封装了react-popper,react-popper又依赖@popperjs/core,所以这篇文章主要解决的是@popperjs/core的实现原理。

计划:

  • 本篇是@popperjs/core的实现核心原理
  • 第二篇讲 react-popper实现核心原理
  • 最后一篇是t-designpopup实现原理。

讲源码之前,跟大家亮点好玩的知识点:

里面有很多实用的工具函数,说实话,原以为自己原生dom api掌握还挺熟练的,看了这些兼容性的代码,我才知道自己有多无知,举个例子,你们一个元素的position是absolute,那么它是相当于谁定位?例如:

  <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的祖先元素。

正文开始!

如何创建基础的popper

使用createPopper API,我们先看看是怎么用的:

假设我们有这样一个html文件

<!DOCTYPE html>
<html>
  <head>
    <title>Popper Tutorial</title>
  </head>
    <style>
      #tooltip {
        background: #333;
        color: white;
        font-weight: bold;
        padding: 4px 8px;
        font-size: 13px;
        border-radius: 4px;
      }
    </style>
  <body>
    <button id="button" aria-describedby="tooltip">My button</button>
    <div id="tooltip" role="tooltip">My tooltip</div>

    <script src="https://unpkg.com/@popperjs/core@2"></script>
    <script>
      const button = document.querySelector('#button');
      const tooltip = document.querySelector('#tooltip');

      const popperInstance = Popper.createPopper(button, tooltip);
    </script>
  </body>
</html>

我们的目的是把tooltip组件定位到button组件下面,如下图

【react 定位组件】源码分析(第一部分)

我们仅仅使用了

使用createPopper函数,需要传递两个参数:参考元素(reference element)和弹出式元素(popper element),以及一个可选的配置对象。参考元素是要弹出的元素的参照点,而弹出式元素是要弹出的元素本身。

例如,以下是一个创建弹出式元素的示例代码:

import { createPopper } from '@popperjs/core';

const referenceElement = document.querySelector('#reference');
const popperElement = document.querySelector('#popper');

const popper = createPopper(referenceElement, popperElement, {
  placement: 'top',
});

上面的代码中,createPopper函数接收参考元素和弹出式元素,以及一个配置对象。这里的配置对象中指定了弹出式元素的位置,它将出现在参考元素的上方。

popper是一个对象,其中的方法用于进一步管理和控制弹出式元素的行为。例如,可以使用update函数在参考元素或弹出式元素发生变化时重新计算弹出式元素的位置和大小,或者可以使用destroy函数在不需要弹出式元素时将其删除。

createPopper函数的返回值

包含以下属性和方法的对象:

  • state:一个对象,包含有关弹出式元素的位置、大小和其他信息的状态信息。
  • update:一个函数,用于更新弹出式元素的位置和大小。
  • forceUpdate:一个函数,用于强制更新弹出式元素的位置和大小。
  • destroy:一个函数,用于删除弹出式元素并清除其事件侦听器。
  • setOptions: 这个函数用于更新弹出式元素的配置选项。

上面我们的案例是如何实现自动定位的呢,也就是createPopper函数调用后,

const popper = createPopper(referenceElement, popperElement, {
  placement: 'top',
});

popperElement自动定位到referenceElement元素的上面了,原因就是createPopper在执行过程中,调用了setOptions方法,所以我们只要看一下setOptions方法如何实现,就知道了它如何实现自动定位了。

注:这里我们不加入任何中间件,这样会提高复杂度,后面再讲几个典型的中间件实现原理。

setOptions方法本质上是调用了forceUpdate方法。

forceUpdate() {
        const { reference, popper } = state.elements;

        state.rects = {
          reference: getCompositeRect(
            reference,
            getOffsetParent(popper),
            state.options.strategy === 'fixed'
          ),
          popper: getLayoutRect(popper),
        };
}

我们先开第一部分,reference, popper是啥意思呢,如下,reference就是referenceElement,popper就是popperElement。

const popper = createPopper(referenceElement, popperElement, {
  placement: 'top',
});

state.rects是啥意思呢,简单来说,就是包含了reference的getBoundingClientRect的结果,popper也是包含了reference的getBoundingClientRect的结果。

这里再简单讲一下 getBoundingClientRect是什么。

含义:

方法返回元素的大小及其相对于视口的位置。

值:

返回值是一个 DOMRect 对象,这个对象是由该元素的 getClientRects() 方法返回的一组矩形的集合, 即:是与该元素相关的CSS 边框集合。

【react 定位组件】源码分析(第一部分)

属性值:

  • top: 元素上边距离页面上边的距离
  • left: 元素右边距离页面左边的距离
  • right: 元素右边距离页面左边的距离
  • bottom: 元素下边距离页面上边的距离
  • width: 元素宽度
  • height: 元素高度

为什么需要这些属性呢?你想想,我绝对定位某个元素,我知道了另一个元素的坐标,是不是绝对定位上去就很简单了?

这也是定位组件最最最基本的思想,所有的定位组件都差不多。

接着讲:

forceUpdate() {
        const { reference, popper } = state.elements;

        state.rects = {
          reference: getCompositeRect(
            reference,
            getOffsetParent(popper),
            state.options.strategy === 'fixed'
          ),
          popper: getLayoutRect(popper),
        };
}

为什么这里要用getCompositeRect来代替getBoundingClientRect的功能,其实里面主要也是用了getBoundingClientRect的功能。

最主要的区别就是一些非常细节的处理了:

绝对定位的坐标受到transfrom: scale的影响

我们举个例子:

  <style>
    #a {
      margin: 0 auto;
      width: 500px;
      position: relative;
      transform: scale(2.5);
    }
    #tooltip {
      background: #333;
      color: white;
      font-weight: bold;
      padding: 4px 8px;
      font-size: 13px;
      border-radius: 4px;
    }
  </style>
  <body>
    <div id="a">
      <button id="button" aria-describedby="tooltip">My button</button>
      <div id="tooltip" role="tooltip">My tooltip</div>
    </div>

注意,id是a的div元素,有可能transform的scale出现变化,那么你定位的时候,是不是要找出scale的值是2.5,然后在正常scale(1)的情况下,决定定位的x,y,width,height都要乘以2.5。

那么问题来了,怎么计算scale的值呢?有人说了,我可以用getComputedStyle获取到,问题来了,我还可以用直接在css上设置scale属性,缩小和放大,我还可以一起上两个属性,你咋办?

所以我们要用以下的方法

const dom= xxx; //假设获取到了某个dom元素
dom.getBoundingClientRect().width / dom.offsetWith

学到了吧,我真的强烈大家多看看开源的好的代码,你们自己项目很多前端不可能知道这些细节的。

好了,继续!请问相对定位的元素如何查找?

有人说了,废话,文章开头不是说了吗,相当于offsetParent或者有一些例如css属性是transform等等属性的dom元素。

这里面又充满了坑!

例如,如果是offsetParent是table元素的情况,table元素,并且定位是static的话,我们需要继续网上找offsetParent,为啥呢?

如果一个元素的父元素是一个table元素,而该元素又没有显式地设置position属性,那么该元素的offsetParent会被设置为table元素的父元素。

所以通常offsetParent属性得到的是position是非static的元素,这个就出现问题了!(还有一些小细节,继续说下去就太多内容了)

计算相当于最近的offsetParent元素,如何计算绝对定位的值

源码核心如下:

 const rect = element.getBoundingClientRect();
 const scroll = getNodeScroll(offsetParent);
  offsets = getBoundingClientRect(offsetParent, true);
  offsets.x += offsetParent.clientLeft;
  offsets.y += offsetParent.clientTop;
 return {
    x: rect.left + scroll.scrollLeft - offsets.x,
    y: rect.top + scroll.scrollTop - offsets.y,
    width: rect.width,
    height: rect.height,
  }

rect.left + scroll.scrollLeft - offsets.x这个公式的复杂度有点高,scroll的元素,不一定就是你的offsetParent对吧?我可以有滚动条,但是我可以定位是static,很合理对吧。

所以我们就要区分两种情况,一种是scroll的元素和offsetParent是同一个元素的情况。

此时rect.left是指react元素左边到屏幕左边的距离,scroll.scrollLeft是指offsetParent的左侧滚动距离,offsets.x(也就是offsets.left)是指offsetParent到屏幕左侧的距离。

好了,rect.left - offsets.x就是rect组件到offsetParent可视区域左侧的距离。

然后 + scroll.scrollLeft,也就是offsetParent的滚动距离。

所以rect.left + scroll.scrollLeft - offsets.x就是指react元素左侧到滚动元素左侧的距离。(也就是包裹react元素的滚动元素)

另一种情况,scroll元素和offsetPatent元素不是同一个元素。

比如offsetPatent元素是body,scroll元素是包裹元素。

好了,rect.left - offsets.x就是rect组件到offsetParent可视区域左侧的距离。这个跟上面的没有啥区别。

然后 + scroll.scrollLeft,就有区别了,这个scrollLeft就是offsetParent的,但是真正滚动的是并不是offsetParent,所以这个值会是0.

我觉得你肯定会懵逼,可能需要自己down源码调试,这个真的要自己下去搞一下,才会有深刻体会。

注意,源码里的绝对定位,虽然position:absolute,但是位移用的transform,而不是top,left这种,目的是提高性能

中间件处理

const orderedModifiers = orderModifiers(
  mergeByName([...defaultModifiers, ...state.options.modifiers])
);

// Strip out disabled modifiers
state.orderedModifiers = orderedModifiers.filter((m) => m.enabled);

state.orderedModifiers.forEach(
  (modifier) =>
    (state.modifiersData[modifier.name] = {
      ...modifier.data,
    })
);

首先mergeByName是什么意思,主要是把所有中间件合并了,一个中间件长啥样呢,如下:

{
  name: 'offset',
  enabled: true,
  phase: 'main',
  requires: ['popperOffsets'],
  fn: offset,
}

上面一个命名为offset的中间件,处理的生命周期在'main'这个生命周期中。处理这个offset的中间件函数是fn属性里的offset函数,我们跳过,这个函数的实现,因为我们只是为了简单介绍中间件是什么。

后面我们会讲生命周期钩子函数。

mergeByName简单来说,就是我们的中间件如下:

[
{
  name: 'offset',
  enabled: true,
  phase: 'main',
  requires: ['popperOffsets'],
  fn: offset,
},
{
  name: 'offset',
  enabled: true,
  phase: 'main',
  requires: ['popperOffsets'],
  fn: offset1,
}
]

也就是可能有重名的中间件,然后将他们合并,我们看到上面数组第一个元素的fn是offset,第二个是offset1,此时offset1就会覆盖offset,也就是所有中间件最终只能有一个名字唯一的去处理它。

最终合并为

[
{
  name: 'offset',
  enabled: true,
  phase: 'main',
  requires: ['popperOffsets'],
  fn: offset1,
}
]

orderModifiers

上面处理过后会把结果传给orderModifiers,它会做两件事:

  • 1、处理依赖
  • 2、按照生命周期分层

处理依赖

如何处理依赖呢,我们看到上面的 offset中间件有一个 requires: ['popperOffsets'],意思是offset中间件加载之前,首先要popperOffsets中间件处理。所以我们遇到这种情况就要先加载popperOffsets中间件。

注意,这里并没有处理循环依赖的情况,需要使用者自己注意(循环依赖最终会报错,因为肯定会栈溢出)。

我们简单看下这个order函数如何处理依赖关系。

function order(modifiers) {
  const map = new Map();
  const visited = new Set();
  const result = [];

  modifiers.forEach(modifier => {
    map.set(modifier.name, modifier);
  });

  // On visiting object, check for its dependencies and visit them recursively
  function sort(modifier: Modifier<any, any>) {
    visited.add(modifier.name);

    const requires = [
      ...(modifier.requires || []),
      ...(modifier.requiresIfExists || []),
    ];

    requires.forEach(dep => {
      if (!visited.has(dep)) {
        const depModifier = map.get(dep);

        if (depModifier) {
          sort(depModifier);
        }
      }
    });

    result.push(modifier);
  }

  modifiers.forEach(modifier => {
    if (!visited.has(modifier.name)) {
      // check for visited object
      sort(modifier);
    }
  });

  return result;
}

这里的关键就是sort函数,首先visited函数会判断在加载某个中间件时,你是否有依赖,有的话,我看看我之前加载过没有,没有的话我就先加载依赖。

按照生命周期分层

代码如下

modifierPhases.reduce((acc, phase) => {
    return acc.concat(
      orderedModifiers.filter(modifier => modifier.phase === phase)
    );
  }, []);

modifierPhases是一个字符串数组,这个数组的顺序就是生命周期钩子函数的顺序,或者说处理中间件的顺序。

// 如下的变量看做字符串即可
const modifierPhases = [
  beforeRead,
  read,
  afterRead,
  beforeMain,
  main,
  afterMain,
  beforeWrite,
  write,
  afterWrite,
];

通过reduce函数,会先处理数组靠前的名字的中间件。

我们接着看刚才的中间件处理流程:

const orderedModifiers = orderModifiers(
  mergeByName([...defaultModifiers, ...state.options.modifiers])
);

// Strip out disabled modifiers
state.orderedModifiers = orderedModifiers.filter((m) => m.enabled);

state.orderedModifiers.forEach(
  (modifier) =>
    (state.modifiersData[modifier.name] = {
      ...modifier.data,
    })
);

orderedModifiers我们之前介绍了,接着state抽取了所有orderedModifiers中的data数据,一般情况是没有这个数据的。

最后orderedModifiers也挂载到了state.orderedModifiers上。

简而言之,中间件就是把我们定位坐标进行了变换,或者添加了监听事件

比如绝对定位好的坐标,如果滚动条滚动,是不是要更新坐标才能继续定位准确?

这里主逻辑就解释完了,然后有人就会说what????没有写什么时候把定位的坐标赋给定位元素啊!!

这个逻辑是写在中间件里的,所以自然而然我们开始讲中间件。

所有官方中间件都会讲

eventListeners中间件

这个中间件简单来说就是递归寻找所有的父元素,如果有滚动条的话,就加上scroll事件,然后触发更新定位坐标。

为啥要更新定位坐标上面已经说的很清楚了呗。

顺便再给window事件加上resize事件,这个也是为了害怕resize窗口导致定位元素定位偏离。

popperOffsets中间件

简单来说,就是根据placement计算坐标,placement比如是top,就是绝对定位到某个元素的上方,而且是居中对齐。

这个坐标咋算呢,我简单说下顶部居中对齐,大家自己算就行了

  const commonX = reference.x + reference.width / 2 - element.width / 2;


    case top:
      offsets = {
        x: commonX,
        y: reference.y - element.height,
      };
 

最后得到的定位坐标放到了state属性上,如下:

state.modifiersData[popperOffsets] = 定位坐标

computeStyle中间件

如果state上的popperOffsets属性不为null,也就是我们上面计算过的popperOffsets。然后给定义新的定位元素的坐标。

有人会问了,为啥要定义新的坐标,主要是为了被定位的元素可能变长或者变短,然后自动定位上去

  if (state.modifiersData.popperOffsets != null) {
    state.styles.popper = {
      ...state.styles.popper,
      ...mapToStyles({
        placement: getBasePlacement(state.placement),
        variation: getVariation(state.placement),
        popper: state.elements.popper,
        popperRect: state.rects.popper,
        gpuAcceleration,
        isFixed: state.options.strategy === 'fixed',
        offsets: state.modifiersData.popperOffsets,
        position: state.options.strategy,
        adaptive,
        roundOffsets,
      }),
    };
  }

可以看到,核心的是mapToStyles这个处理函数。我们看下它的实现,简单来说:

  • 如果gpuAcceleration参数为true,那么我们的定位使用transfrom,否则使用left,top这种方式定位

  • 如果adaptive为true,假设reference元素变宽或者变窄(比如一段文字),它会自动定位上去

applyStyles中间件

这个很简单,上面我们不是把定位的坐标求出来了吗,这个中间件就是把定位组件的styles属性合并上去的,源码如下,element就是定位元素,这种方式值得大家学习,而不是直接赋值给style。

    const attributes = state.attributes[name] || {};
    Object.assign(element.style, style);

offset中间件

这个太简单了,偏移距离用的,请看下图:

【react 定位组件】源码分析(第一部分)

flip中间件

原理是,比如我们现在placement:bottom,表示定位到reference元素的下方,当我们向下滚动的时候,是不是这个定位的元素因为在下方,迟早会到视口的下面,如下图:

【react 定位组件】源码分析(第一部分)

为了能看见tooltip,我们自动翻转到上方!

【react 定位组件】源码分析(第一部分)

这就是flip的功能,至于如何实现,我们马上分析:

假设我们传入的placement是bottom,会自动计算它相反的位置:最后生成['bottom','top'],意思是如果bottom超出视口边界,就转到top的位置去。

这个位置我们还可以外界自定义,默认的是placement是top,那么就生成['top','bottom'],如果是left就生成['left', 'right'],也就是自己的位置和相反的位置。

然后通过一个函数detectOverflow(建议大家可以单独copy一份这个函数的代码,表示是否传入的元素已经超过视口的)

但是原理也很简单,如果我去写的话,就是判断当前元素的最上边是否超过定位它的父元素的最上边,最左边和其他方向都是一样的。比较坐标嘛。

然后如果超出也很简单,你超出了top,你就返回top: true,没有就返回top:false,我知道如果超出不就马上计算另一个方向的坐标,然后更新绝对定位的值了呗。