junjianlin
前端开发工程师
junjianlin 2023-01-10
floating-ui是一个用于创建浮动元素的轻量库,如常见UI组件库的弹窗组件tooltips、popover、下拉列表等,均可基于floating-ui去实现。
floating-ui是由popper.js v2升级而来的,相比popper.js,具有以下优点:
从仓库源码可以看出,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(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/