~/static/img/default-avatar-0.jpeg

junjianlin

前端开发工程师

博客

floating-ui(升级版popper.js)解读

junjianlin 2023-01-10

简介

floating-ui是一个用于创建浮动元素的轻量库,如常见UI组件库的弹窗组件tooltips、popover、下拉列表等,均可基于floating-ui去实现。

floating-ui是由popper.js v2升级而来的,相比popper.js,具有以下优点:

  1. 跨平台,不仅限于dom;
  2. 体积更小,支持tree-shaking;
  3. 中间件机制,支持按需引用中间件和自定义中间件;
  4. 采用TypeScript,用法更简单,更容易被使用;

基本用法

从仓库源码可以看出,floating-ui在实现了核心功能(packages/core)的基础上,提供了基础的dom实现和vue、react、react-native的封装,这里我们以基础的dom实现为例:

<script lang="ts" setup>
import { computePosition, offset } from "@floating-ui/dom";
import { onMounted } from "@vue/runtime-core";

onMounted(() => {
  const referenceEl = document.querySelector(".target")!;
  const floatingEl = document.querySelector(".floating") as HTMLElement;
  computePosition(referenceEl, floatingEl, {
    middleware: [offset(20)],  // 按需引用的中间件
    placement: "top",  // 指定浮动位置
  }).then(({ x, y }) => {
  // computePosition根据传入参数计算目标元素和浮动元素位置,
  // 异步返回浮动元素坐标后可手动设置浮层位置
    Object.assign(floatingEl.style, {
      left: `${x}px`,
      top: `${y}px`,
    });
  });
});
</script>
<template>
  <div class="target">referenceEl</div>
  <div class="floating">floatingEl</div>
</template>
<style>
.target {
  width: auto;
  height: 60px;
  padding: 10px;
  background: #aaa;
}
.floating {
  position: absolute;
  width: 200px;
  height: 200px;
  background: rgb(125, 200, 125);
  box-shadow: -3px -3px 3px #aaa, 3px 3px 3px #aaa;
}
</style>

computePosition为floating-ui核心方法和入口函数,其本身不会操作我们传入的元素,而是通过Promise的形式返回计算后的坐标让我们手动设置。

提供一个目标元素和浮动元素,然后调用computePosition即可实现基本的浮动定位效果:

autoUpdate

autoUpdate(referenceEl, floatingEl, update, options)@floating-ui/dom实现的最主要的一个方法,其作用是监听页面滚动和窗口大小变化、目标元素大小变化,提供回调更新浮动元素位置。

比如我需要在目标元素高度变化时自动调整浮动位置:

<template>
  <div class="target">referenceEl</div>
  <div class="floating">floatingEl</div>
  <button @click="expand">expand</button>
</template>
<script lang="ts" setup>
import { computePosition, offset, autoUpdate } from "@floating-ui/dom";
import { onMounted, onUnmounted } from "@vue/runtime-core";

// 点击浮动高度随机变化
const expand = () => {
  (document.querySelector(".target")! as HTMLElement).style.height =
    200 * Math.random() + "px";
};

let cleanup: () => void;

onMounted(() => {
  const referenceEl = document.querySelector(".target")! as HTMLElement;
  const floatingEl = document.querySelector(".floating") as HTMLElement;
  
  // 提供一个回调函数
  const updatePosition = () => {
    computePosition(referenceEl, floatingEl, {
      middleware: [offset(20)],
      strategy: "absolute",
    }).then(({ x, y }) => {
      Object.assign(floatingEl.style, {
        left: `${x}px`,
        top: `${y}px`,
      });
    });
  };

  cleanup = autoUpdate(referenceEl, floatingEl, updatePosition);
});
onUnmounted(() => {
  cleanup();  // 组件卸载需移除监听
});
</script>

中间件机制

不同于popper.js默认处理了浮动的边界场景,floating-ui默认是不会实现任何浮动定位的效果的,而是通过提供处理基本边界场景和常见需求的中间件,让我们可以根据场景按需引用: 例如:

autoPlacement,可实现自动选择可用空间最多的位置摆放浮动元素

<template>
  <div style="height: 350px; width: 1px"></div>
  <div class="target">referenceEl</div>
  <div class="floating">floatingEl</div>
  <div style="height: 350px; width: 1px"></div>
</template>
<script lang="ts" setup>
import {
  computePosition,
  offset,
  autoPlacement,
  autoUpdate,
} from "@floating-ui/dom";
import { onMounted } from "@vue/runtime-core";

onMounted(() => {
  const referenceEl = document.querySelector(".target")! as HTMLElement;
  const floatingEl = document.querySelector(".floating") as HTMLElement;
  const updatePosition = () => {
    computePosition(referenceEl, floatingEl, {
      middleware: [offset(20), autoPlacement({
        alignment: 'start'
      })],
      strategy: "absolute",
    }).then(({ x, y }) => {
      Object.assign(floatingEl.style, {
        left: `${x}px`,
        top: `${y}px`,
      });
    });
  };

  autoUpdate(referenceEl, floatingEl, updatePosition);
});
</script>

中间件的主要实现逻辑(此处省略次要逻辑):

export const computePosition: ComputePosition = async (
  reference,
  floating,
  config
): Promise<ComputePositionReturn> => {
  const {
    placement = 'bottom',
    strategy = 'absolute',
    middleware = [],
    platform,
  } = config;

  const validMiddleware = middleware.filter(Boolean) as Middleware[];
  const rtl = await platform.isRTL?.(floating);


  let rects = await platform.getElementRects({reference, floating, strategy});
  // 根据placement计算出浮动元素应该要放置的坐标
  let {x, y} = computeCoordsFromPlacement(rects, placement, rtl);
  let statefulPlacement = placement;  // 记录原始值
  let middlewareData: MiddlewareData = {};  // 存放中间件处理后的数据

  // 以队列形式执行中间件函数
  for (let i = 0; i < validMiddleware.length; i++) {
    const {name, fn} = validMiddleware[i];
    
    // 中间件函数返回处理后的新坐标,data是可以让后面的中间件也访问到的数据
    // reset表示是否重复执行该中间件
    const {
      x: nextX,
      y: nextY,
      data,
      reset,
    } = await fn({
      /**
       * 这里是每个中间件的传参
       * x,y当前坐标
       * middlewareData 中间件返回的额外数据
       */
      x,
      y,
      initialPlacement: placement,
      placement: statefulPlacement,
      strategy,
      middlewareData,
      rects,
      platform,
      elements: {reference, floating},
    });

    x = nextX ?? x; // 每个中间件执行后更新坐标
    y = nextY ?? y;
    
    // 执行后可以让后续中间件访问的数据,这里使用name做了隔离,避免了各个中间件数据之间的污染
    middlewareData = {
      ...middlewareData,
      [name]: {
        ...middlewareData[name],
        ...data,
      },
    };

  }

  return {
    x,
    y,
    placement: statefulPlacement,
    strategy,
    middlewareData,
  };
};

自定义中间件

根据提供的ts类型和上面代码,我们可以很容易根据需要去实现自己的中间件,例如:

const offSetByReference = (value: number = 0): Middleware => ({
    name: 'offSetByReference',
   async fn(middlewareArguments) {
    const { x, y, elements } = middlewareArguments
    const rect = elements.reference.getBoundingClientRect()
    return  {
        x: x + rect.width * value,
        y: y + rect.height * value
    }
   }
}) 

 computePosition(referenceEl, floatingEl, {
      middleware: [offSetByReference(0.5)]
    })

中间件机制为插件提供了可扩展性,也降低了二次封装的复杂度

需要注意的是中间件应该是一个不包含副作用的纯函数,即不应该在中间件中直接修改传入的参数和直接操作元素,否则会发生不可预知的错误,比如死循环等。

跨平台实现

@floating-ui/core computePosition本身并不会包含任何平台的api,但是提供了一个platform参数可以让我们自己根据不同平台去实现:必传参数仅有三个

export interface Platform {
  // Required
  getElementRects: (args: {
    reference: ReferenceElement;
    floating: FloatingElement;
    strategy: Strategy;
  }) => Promisable<ElementRects>;
  getClippingRect: (args: {
    element: any;
    boundary: Boundary;
    rootBoundary: RootBoundary;
    strategy: Strategy;
  }) => Promisable<Rect>;
  getDimensions: (element: any) => Promisable<Dimensions>;

  // Optional
  convertOffsetParentRelativeRectToViewportRelativeRect?: (args: {
    rect: Rect;
    offsetParent: any;
    strategy: Strategy;
  }) => Promisable<Rect>;
  getOffsetParent?: (element: any) => Promisable<any>;
  isElement?: (value: any) => Promisable<boolean>;
  getDocumentElement?: (element: any) => Promisable<any>;
  getClientRects?: (element: any) => Promisable<Array<ClientRectObject>>;
  isRTL?: (element: any) => Promisable<boolean>;
  getScale?: (element: any) => Promisable<{x: number; y: number}>;
}

最终的返回基本也都是基于以下四个属性:

interface Rect {  
  width: number;  
  height: number; 
  x: number;  
  y: number;
 }

案例

Shepherd是基于@floating-ui/dom实现的新手引导UI库

其他使用floating-ui的框架/库: element-plus react-tooltip,ant-design-mobile ,naive-ui 等。

floating-ui相比popper.js,更简单易用,扩展性更强,更容易与主流UI框架相结合,目前正逐步被使用。

参考文档

https://floating-ui.com/docs/getting-started

https://blog.logrocket.com/popper-vs-floating-ui/

作者相关知识精选
  • localhost与127.0.0.1的区别:

    localhost:也叫local ,正确的解释是:本地服务器 127.0.0.1:在windows等系统的正确解释是:本机地址(本机服务器)

    localhost:是不经网卡传输的,它不受网络防火墙和网卡相关的的限制。 127.0.0.1:是通过网卡传输的,它依赖网卡,并受到网络防火墙和网卡相关的限制。

    查看更多
  • 浏览器打开新标签安全问题

    如果在项目中需要 打开新标签 进行跳转一般会有两种方式:

    window.open

    <a href="..." target="_blank">new tab</a>

    这两种方式看起来没什么问题,但是存在漏洞

    通过这两种方式打开的页面可以使用 window.opener 来访问源页面的 window 对象,场景:

    在自己的系统内通过 <a target="_blank" 标签或 window.open打开第三方网站,第三方网站可以通过 window.opener.location.replace("https://test.evil.com")(跨域仍然有效)将原来的页面替换成钓鱼网站。

    如何防止?

    劫持window.open:

    const openPage = (url) => {
        var newTab = window.open()
        newTab.opener = null
        newTab.location = url
    }
    
    
    <!-- 
      noopener:将 window.opener置空
      noreferrer:兼容老浏览器/火狐。禁用HTTP头部Referer属性(后端方式)
     -->
    <a target="_blank" href="" rel="noopener noreferrer nofollow">a标签跳转url</a>
    
    查看更多