不吹牛,完爆ant design的定位组件!floating-ui来也

20,555 阅读6分钟

前言

因为要写react定位组件(这不是标题党,就是完爆ant design的定位组件,你应该看到一半就会同意我的观点),如下图:

image.png

红框部分是用绝对定位放在按钮上面的,你们B端用的主流组件库都是这样实现的,它是很多组件的基础组件,比如下图:

下拉框组件

image.png

select组件

image.png

还有什么DataPicker,TreeSelect,Dropdown组件等等的下拉框都是以定位组件为基础的。

这个组件实现的复杂度在哪

上面提到,这不过就是一个绝对定位嘛(我们假设红框部分的的dom绝对定位是相较于body元素),我们拿最简单的情况来看,如下图,如何把红框部分渲染到按钮”更多“的下方呢?

image.png

我们可以计算更多按钮的getBoundingRect(),返回

const reference = {
  top: xx, // 按钮距离浏览器顶部的距离
  left: xx, // 按钮距离浏览器左边的距离
  width: xx, // 按钮的宽:没有padding
  height:xx,// 按钮的高:没有padding
  ...等等其他属性
}

所以红框部分左上角的坐标就轻易的计算出来了,上面的数据在reference对象上,所以借助reference的定位,我们计算红框部分的下拉框的定位是在哪

{
    position: 'absolute',
    top: reference.top + window.pageYOffset // 竖直方向滚动距离 + reference.height
    left: reference.left + window.pageXOffset // 横向滚动距离 
}

为啥是上面这么计算呢,假如没有滚动条滚动,那么红框部分的绝对定位的top,是不是等于按钮的距离浏览器顶部的高度 + 本身的高度,这个没问题吧?

然后,如果滚动条滚动了的话,是不是要在上面top的基础上加上这段距离,就是红框部分在文档流绝对定位的top。

好了,到此为止,就是最基本的定位组件的逻辑了,我们接下来看复杂点!

复杂度1

还是拿上面的图的红色部分下拉框来说,下拉框一般是在下面,但是我可以定位到上边吧?左边,右边也没啥吧,再过分点,右上,左下?定位组件要处理对吧

复杂度2

假设,我们定位在下面,我想向左偏移8px,向下偏移3px咋办,你是不是应该有暴露一个口子

复杂度3

假设我定位在下面,那么我一直滚,马上就要滚动到看不见下拉框了,如下图

image.png

此时我想让定位在上面,能不能自动帮我处理?如下图:

image.png

复杂度4

是不是还有可能超出浏览器视口了,如下图:

image.png

我们想自动处理,遇到超出就自动变为下方样子:

image.png

复杂度5

此时我定位了一次,但是有可能滚动容器不是window,是另一个div,这个计算咋办?还有,是不是我滚动的时候,我要监听滚动事件,还要监听浏览器resize事件,因为我定位的值可能会变?为啥呢,我们上面复杂度3是不是自动帮我们在滚动的时候调整位置

所以你不监听滚动事件你咋知道要调整位置了?

还有很多细枝末节,比如浏览器兼容性等等。。。。

国内组件库怎么实现这个功能

目前阿里的ant design和字节的arco design都是自己实现的,我们拿arco来看(ant内部叫rc-trriger组件,arco叫trriger组件),面向过程的代码,看的我头皮发麻。。。我截个图:

image.png

上面的代码属于把我们提到的复杂度全部揉在了一起。

floating-ui为啥代码质量比ant高

它是以中间件的形式去处理的,思路是什么呢?它假设最开始有一个 computePosition函数,我们假设上面提到的复杂度都没有,也就是不考虑的前提下,我们怎么计算定位组件的坐标,也就是我们最前面的图里说的,红色框部分绝对定位的的的top值和left值:

API如下:

computePosition(要挂载的dom节点,下拉框组件,参数...)

然后我们刚才提到的复杂度,它分别用中间件的形式去处理,比如复杂度2,是想定位之后还有点偏移量,floating-ui咋做的呢

import {computePosition, offset} from '@floating-ui/dom';

// referenceEl: 要挂载的dom节点
// floatingEl:下拉框组件(或者说想要挂载到上面referenceEl的dom元素)
computePosition(referenceEl, floatingEl, {
  middleware: [offset(10)],
});

如上,offset就是一个中间件,offset(10),就是向左偏移10px

好了,如果想处理复杂度3呢,我们用另一个中间件

import {computePosition, flip} from '@floating-ui/dom';
 
computePosition(referenceEl, floatingEl, {
  middleware: [flip()],
});

这样就自动处理了,是不是很简单啊

其实所有这些复杂度的解决方案,在floating-ui里都是以中间件的形式去处理的,还可以传多个中间件解决多个问题。

中间件的形式好在哪

那么我们就可以自定义很多中间件了,也就是你的组件不仅仅提供了很多功能,解决了很多常用的问题,你还允许用户写代码去拓展,试问,现在哪个组件库的代码是这么写的?没有吧?

代码中间件原理

我们先看看floating-ui的computePosition API是怎么实现的,它是floating-ui的核心方法,是串联所有中间件的基础。

下一篇写完整的源码(很晦涩,估计也没几个人看,所以这期就不写了),理解起来说实话,你不熟悉原生dom的话有点困难,比如说为啥这个库要用window.pageYoffset而不是document.body.scrollTop去获取浏览器html元素的滚动距离,因为document.body.scrollTop固定为0,取不到。。。

核心思路讲解:我们还是拿下图做类比

image.png

let {x, y} = 求出红色框里的下拉框绝对定位的x坐标和y坐标

// 记录原始placement
  let statefulPlacement = placement;
  // 所有中间件导出的值都挂载到下面的对象上
  let middlewareData: MiddlewareData = {};
  
  // 数据经过middleware的处理
  // middleware是一个数组,存放所有中间件,就是我们上面说的处理每一个复杂度的对象
  for (let i = 0; i < middleware.length; i++) {
   // name是中间件的名字,fn是处理复杂度的逻辑
    const {name, fn} = middleware[i];
   
   
   // 通过把最前面计算的x,y经过fn的处理,得到了新的x,y的值
   // data是指返回的数据,想让后面的中间件也能访问到的数据
    /**
     * 每个middleware需要返回
     * x 新的x坐标
     * y 新的y坐标
     * data 
     * reset
     */
    const {
      x: nextX,
      y: nextY,
      data,
      reset,
    } = await fn({
      /**
       * 每个middleware收到的参数
       * x 目前的x坐标
       * y 目前的y坐标
       * initialPlacement 最初传入的placement
       * placement 
       * middlewareData middleware返回的额外数据
       */
      x,
      y,
      initialPlacement: placement,
      placement: statefulPlacement,
      strategy,
      middlewareData,
      rects,
      platform,
      elements: {reference, floating},
    });

    x = nextX ?? x;
    y = nextY ?? y;

    // 每次处理后的数据想要让后面的中间件访问,就需要挂载到middlewareData对象
    // 这个对象非常好啊,用name隔离了作用域
    middlewareData = {
      ...middlewareData,
      [name]: {
        ...middlewareData[name],
        ...data,
      },
    };

   rest的处理逻辑。。。省略,不是很重要
   
   

最后return出被处理完的x,y坐标,或者自动帮我们监听滚动事件和resize事件,然后拿着x,y就可以赋在css的绝对定位的top和left上,实现了定位。

每次处理后的数据想要让后面的中间件访问,就需要挂载到middlewareData对象,这个对象非常好啊,用name隔离了作用域,这就是比koa这个框架处理的高明之处,koa里的ctx对象就像一个垃圾桶,什么属性都往上面挂载,挂载太多了,你也不知道是哪个中间件挂载的

所以floating-ui的处理思路给我打开了新的思路!nice!!!

中间件如何写

源码再开一篇文章写,这里看看就好,不用过多去关系代码

export const offset = (value: Options = 0): Middleware => ({
  name: 'offset', // 中间件名字
  options: value,  // 传给中间件的值
  async fn(middlewareArguments) { // 中间件处理函数
    const {x, y} = middlewareArguments;
    const diffCoords = await convertValueToCoords(middlewareArguments, value);

    return {
      x: x + diffCoords.x,
      y: y + diffCoords.y,
      data: diffCoords,
    };
  },
});

本文结束,所以如果市面上的组件库的每个组件都是这个形式暴露给用户,就是提供插件式的自定义的中间件,那么整个组件库的拓展性可以说碾压市面上国内所有的react的组件库了

之前写的react组件如下: