揭开Popper.js的神秘面纱:入门指南第一篇
前言
作为前端,popper.js
是绕不过去的一道坎,它作为底层组件,帮助我们解决了 Tooltip
、dropdown
、Menu
等等一系列组件。几乎所有的这样的组件都是基于Popper.js
组件来构成的。它的核心只有6000 byte左右,并且支持tree-shaking
,支持自定义中间件、支持任意框架(基于 JavaScript
)。
当前是基于v2.x分支作为分析,仅针对2.x分支分析。最新版本的popper.js已经更名为floating-ui。
基本逻辑
const defaultModifiers = [
eventListeners,
popperOffsets,
computeStyles,
applyStyles,
offset,
flip,
preventOverflow,
arrow,
hide,
];
const createPopper = popperGenerator({ defaultModifiers });
popper.js 利用 popperGenerator
来生成 createPopper
函数,其中初始化了一些中间件。
const DEFAULT_OPTIONS = {
placement: 'bottom',
modifiers: [],
strategy: 'absolute',
}
function popperGenerator(generatorOptions) {
const {
defaultModifiers = [],
defaultOptions = DEFAULT_OPTIONS,
} = generatorOptions;
return createPopper(referance,popper,options){
let state = {
// ...some code
};
let effectCleanupFns = [];
let isDestroyed = false;
const instance = {
state,
setOptions(){
// ...some code
cleanupModifierEffects();
// ...some code
runModifierEffects();
return instance.update();
},
forceUpdate(){
// ...some code
},
update(){
return new Promise(resolve) => {
instance.forceUpdate()
resolve(state);
}
},
destory(){
// ...some code
},
}
instance.setOptions(options).then(state) => {
if (!isDestroyed && options.onFirstUpdate) {
options.onFirstUpdate(state);
}
}
function runModifierEffects(){
// ...some code
}
function cleanupModifierEffects(){
// ...some code
}
return instance;
}
}
利用popperGenerator
初始化了一些 modifiers
、options
,生成createPopper
函数必备的一些参数,当执行createPopper
时,setOptions
-> cleanupModifierEffects()
-> runModifierEffects()
-> update
-> forceUpdate()
。不仅仅可以利用createPopper
来直接使用,我们还可以利用popperGenerator
来自定义 popper
更新策略。整体代码执行顺序及设计十分巧妙,利用effect
处理副作用,然后顺序执行 Modifier[fn]
来计算、生成、检测是否溢出等。
初始化
setOptions
初始化时执行一次 setOptions
setOptions(){
// ...some code
cleanupModifierEffects();
// ...some code
runModifierEffects();
return instance.update();
},
runModifierEffects
和 cleanupModifierEffects
其功能类似于react
的 useEffect
,Effect 副作用函数,会直接或者间接的影响其他函数的执行。
// react useEffect
useEffect(()=>{
// some code such as: addEventListener
return ()=>{
//some code such as: removeEventListener
}
})
// popper.js
runModifierEffects(){
// 每一个modifier执行 runModifierEffect
state.orderedModifiers.forEach((modifier) => runModifierEffect(modifier);
}
runModifierEffect({ name, options = {}, effect }){
if (typeof effect === 'function') {
// 执行 effect
const cleanupFn = effect({ state, name, instance, options });
const noopFn = () => {};
// push effect的返回结果
effectCleanupFns.push(cleanupFn || noopFn);
}
}
cleanupModifierEffects(){
effectCleanupFns.forEach((fn) => fn());
effectCleanupFns = [];
}
有没有很像,有没有~ 还没看出来吗,没关系,我们利用代入法,用一个eventListenersModifier来表达一下这里的关系
function effect({ state, instance, options }: ModifierArguments<Options>) {
const { scroll = true, resize = true } = options;
const window = getWindow(state.elements.popper);
const scrollParents = [
...state.scrollParents.reference,
...state.scrollParents.popper,
];
if (scroll) {
scrollParents.forEach(scrollParent => {
scrollParent.addEventListener('scroll', instance.update, passive);
});
}
if (resize) {
window.addEventListener('resize', instance.update, passive);
}
return () => {
if (scroll) {
scrollParents.forEach(scrollParent => {
scrollParent.removeEventListener('scroll', instance.update, passive);
});
}
if (resize) {
window.removeEventListener('resize', instance.update, passive);
}
};
}
export default ({
name: 'eventListeners',
enabled: true,
phase: 'write',
fn: () => {},
effect,
data: {},
}: EventListenersModifier);
当执行 runMidifierEffects
时,会对 scrollParent
添加addEventListener
、对window
添加addEventListener
,当执行clearnUpModifierEffects
时,会执行removeEventLIstener
来移除事件监听。
最后,setOptions
返回 instance.update()
的执行结果
update: debounce (() => new Promise < $Shape < State >> ((resolve) => {
instance.forceUpdate();
resolve(state);
})
),
这里加了一个防抖,防止多次触发 forceUpdate()
,比如说在上面看到的EventListenersModifier
,当触发scroll
、resize
时,可能会频繁触发 update
函数,导致多次执行,所以这里加了防抖。
forceUpdate
forceUpdate(){
// destroyed标志
if (isDestroyed) {
return;
}
const { reference, popper } = state.elements;
//校验 reference、popper元素是否可用
if (!areValidElements(reference, popper)) {
return;
}
// 存储reference和popper rect以供 modifiers读取
state.rects = {
reference: getCompositeRect(
reference,
getOffsetParent(popper),
state.options.strategy === 'fixed'
),
popper: getLayoutRect(popper),
};
state.reset = false;
state.placement = state.options.placements;
// 遍历orderedModifiers 每次更新重新填充 modifiersData
state.orderedModifiers.forEach((modifier) =>
(state.modifiersData[modifier.name] = {
...modifier.data,
})
);
// 遍历orderedModifiers 取出 modifier[fn] 执行
for (let index = 0; index < state.orderedModifiers.length; index++) {
if (state.reset === true) {
state.reset = false;
index = -1;
continue;
}
const { fn, options = {}, name } = state.orderedModifiers[index];
if (typeof fn === 'function') {
state = fn({ state, options, name, instance }) || state;
}
}
}
Rects
-
popper
// 存储reference和popper rect以供 modifiers读取
state.rects = {
// some code
popper: getLayoutRect(popper),
};
function getLayoutRect(element: HTMLElement): Rect {
const clientRect = getBoundingClientRect(element);
// Use the clientRect sizes if it's not been transformed.
// Fixes https://github.com/popperjs/popper-core/issues/1223
let width = element.offsetWidth;
let height = element.offsetHeight;
// 由于getBoundingClientRect是基于 getClientRects的,mdn中说明到,小数级别的像素偏移是有可能的。这里做了一些容错。
if (Math.abs(clientRect.width - width) <= 1) {
width = clientRect.width;
}
if (Math.abs(clientRect.height - height) <= 1) {
height = clientRect.height;
}
return {
// 返回element距离element.offsetParent的左边界偏移的像素值
x: element.offsetLeft,
// 同上
y: element.offsetTop,
width,
height,
};
}
- getBoundingClientRect方法返回元素大小及其相对视窗(viewport) 的位置。
如果是标准盒子模型,元素的尺寸等于
width/height
+padding
+border-width
的总和。如果box-sizing: border-box
,元素的的尺寸等于width/height
。
返回的结果是包含完整元素的最小矩形,并且拥有
left
,top
,right
,bottom
,x
,y
,width
, 和height
这几个以像素为单位的只读属性用于描述整个边框。除了width
和height
以外的属性是相对于视图窗口的左上角来计算的。
-
offsetWidth / offsetHeight
=width/height
+padding
+border-width
由于getBoundingClientRect
是基于 getClientRects的,mdn中说明到,小数级别的像素偏移是有可能的,所以在代码中做了一些容错做了一些容错。关于ClientRect是什么可以看这里 DomRect。
offsetLeft/offsetTop
返回当前元素左上角相对于offsetParent
左边/上边偏移的像素值
offsetParent
元素只可能是下面这几种情况:
<body>
-
position
不是static
的元素(position 默认值是static
)
-
<table>
,<th>
或<td>
,但必须要position: static
。
实际这个 getLayoutRect
返回了 popper
的 width/height
和 距离 reference
的x,y(一般情况下都是采用position:absolute | fixed)
有关更多各种offsetXXX的属性可以看张鑫旭大佬的这篇文章 CSSOM视图模式(CSSOM View Module)相关整理 « 张鑫旭-鑫空间-鑫生活 里面还贴心的配备了各种demo。给大佬点赞!!!。
-
reference
state.rects = {
reference: getCompositeRect(
reference,
getOffsetParent(popper),
state.options.strategy === 'fixed'
),
// some code
};
// 获取一个正确的offsetParent 针对 Element.offsetParent的特殊情况纠正
function getOffsetParent(element: Element) {
const window = getWindow(element);
let offsetParent = getTrueOffsetParent(element);
while (
offsetParent &&
isTableElement(offsetParent) &&
getComputedStyle(offsetParent).position === 'static'
) {
offsetParent = getTrueOffsetParent(offsetParent);
}
if (
offsetParent &&
(getNodeName(offsetParent) === 'html' ||
(getNodeName(offsetParent) === 'body' &&
getComputedStyle(offsetParent).position === 'static'))
) {
return window;
}
return offsetParent || getContainingBlock(element) || window;
}
getTrueOffsetParent(element: Element): ?Element {
if (
!isHTMLElement(element) ||
// https://github.com/popperjs/popper-core/issues/837
getComputedStyle(element).position === 'fixed'
) {
return null;
}
return element.offsetParent;
}
getOffsetParent
函数返回值为 offsetParent
| window
。
function getCompositeRect(
elementOrVirtualElement: Element | VirtualElement,
offsetParent: Element | Window,
isFixed: boolean = false
): Rect {
const isOffsetParentAnElement = isHTMLElement(offsetParent);
const offsetParentIsScaled =
isHTMLElement(offsetParent) && isElementScaled(offsetParent);
const documentElement = getDocumentElement(offsetParent);
// 取到 DomRect
const rect = getBoundingClientRect(
elementOrVirtualElement,
offsetParentIsScaled
);
// ------------------开始计算scroll和offsets
let scroll = { scrollLeft: 0, scrollTop: 0 };
let offsets = { x: 0, y: 0 };
if (isOffsetParentAnElement || (!isOffsetParentAnElement && !isFixed)) {
if (
getNodeName(offsetParent) !== 'body' ||
// https://github.com/popperjs/popper-core/issues/1078
isScrollParent(documentElement)
) {
scroll = getNodeScroll(offsetParent);
}
if (isHTMLElement(offsetParent)) {
offsets = getBoundingClientRect(offsetParent, true);
offsets.x += offsetParent.clientLeft;
offsets.y += offsetParent.clientTop;
} else if (documentElement) {
offsets.x = getWindowScrollBarX(documentElement);
}
}
// ------------------计算结束-------------------
return {
// left + scrollLeft - offsetParent.clientLeft
x: rect.left + scroll.scrollLeft - offsets.x,
// top + scrollTop - offsetParent.clientTop
y: rect.top + scroll.scrollTop - offsets.y,
width: rect.width,
height: rect.height,
};
}
emmmm,这里的x,y我也没看太懂为啥要减去 offset.x|y
,暂时就当他为0吧。。。。看了半天实际上就是返回了reference
的width,height
以及left+scrollLeft
和top+scrollTop
可以看出来的是,每次update时,会重新计算rects、modifiersData,重新执行modifier[fn]。
通过上面的代码,可以看出 不同的Modifier主要是影响了两个部分
- effect的执行,仅在setOptions时执行,只在generator时 执行了一次
- modifier[fn]的执行,仅在Update时执行。update的执行可由effect来控制。每次触发eventListener都会执行。
Modifiers
Effect
const defaultModifiers = [
eventListeners,
popperOffsets,
computeStyles,
applyStyles,
offset,
flip,
preventOverflow,
arrow,
hide,
];
默认的Modifiers中,拥有effect函数的有,eventListeners
、applyStyles
、arrow
。先来看一下这三个Modifier的effect函数分别做了什么。
-
eventListeners
在前面已经看过了,这里就不做分析。该方法主要是注册监听器和取消监听器。
-
applyStyles
function effect({ state }) {
const initialStyles = {
// 初始化initial popper styles
popper: {
position: state.options.strategy, // 默认为 absolute
left: '0',
top: '0',
margin: '0',
},
// 初始化 initial arrow
arrow: {
position: 'absolute',
},
// reference
reference: {},
};
// 合并 popper style
Object.assign(state.elements.popper.style, initialStyles.popper);
state.styles = initialStyles;
// 合并 arrow style 设置position
if (state.elements.arrow) {
Object.assign(state.elements.arrow.style, initialStyles.arrow);
}
// clearnUp Fn
return () => {
Object.keys(state.elements).forEach((name) => {
// 取出element
const element = state.elements[name];
// 取出 attribute
const attributes = state.attributes[name] || {};
// styleProperties
const styleProperties = Object.keys(
state.styles.hasOwnProperty(name)
? state.styles[name]
: initialStyles[name]
);
// Set all values to an empty string to unset them
const style = styleProperties.reduce((style, property) => {
style[property] = '';
return style;
}, {});
// arrow is optional + virtual elements
if (!isHTMLElement(element) || !getNodeName(element)) {
return;
}
Object.assign(element.style, style);
// 移除apply的initalStyle
Object.keys(attributes).forEach((attribute) => {
element.removeAttribute(attribute);
});
});
};
}
该方法针对 popper
和arrow
做了一些style样式的合并和删除操作
-
Arrow
// Specifies the element to position as the arrow. This element must be a child of the popper element.
// A string represents a CSS selector queried within the context of the popper element.
// Popper will automatically pick up the following element (using the data-popper-arrow attribute) and position it:
// arrow type = Element | String
function effect({ state, options }) {
let { element: arrowElement = '[data-popper-arrow]' } = options;
// 若没有arrowElement return
if (arrowElement == null) {
return;
}
// CSS selector
if (typeof arrowElement === 'string') {
arrowElement = state.elements.popper.querySelector(arrowElement);
if (!arrowElement) {
return;
}
}
if (!contains(state.elements.popper, arrowElement)) {
return;
}
// arrow 设置
state.elements.arrow = arrowElement;
}
通过这三个 effect
,可以发现,eventListeners
主要是设置update
更新机制,applyStyles主要是 merge refenerce
、popper
、arrow
的style。Arrow
主要是设置小箭头的elements。
Modifier[fn]
除eventListeners
这个Modifier 之外,其他的基本上都有fn方法,逐个攻破!
这里要注意的是,Modifier执行顺序是不可打乱的,在setOptions
时,有这样一段代码,将默认的modifiers
放置到了最前面。
// defaultModifiers 是默认的Modifiers
const orderedModifiers = orderModifiers(
mergeByName([...defaultModifiers, ...state.options.modifiers])
);
-
popperOffsets
function popperOffsets({ state, name }) {
// offsets 是popper需要被正确放置在参考元素附近的实际位置,
state.modifiersData[name] = computeOffsets({
reference: state.rects.reference,
element: state.rects.popper,
strategy: 'absolute',
placement: state.placement,
});
}
function computeOffsets({
reference,
element,
placement,
}){
// 获取 placement 'top' | 'bottom' | 'left' | 'right';
const basePlacement = placement ? getBasePlacement(placement) : null;
// 获取方位 'start' | 'end';
const variation = placement ? getVariation(placement) : null;
// 基准坐标 x 可以理解为 距离页面左边的宽度 + reference宽度 / 2 - (popper的宽度 / 2)
const commonX = reference.x + reference.width / 2 - element.width / 2;
// 基准坐标 y 可以理解为 距离页面上边的宽度 + reference高度 / 2 - (popper的高度 / 2)
const commonY = reference.y + reference.height / 2 - element.height / 2;
// 基本为 reference 中心点 向左上偏移 [element.width / 2,element.height / 2];
let offsets;
switch (basePlacement) {
case top:
offsets = {
x: commonX,
y: reference.y - element.height,
};
break;
case bottom:
offsets = {
x: commonX,
y: reference.y + reference.height,
};
break;
case right:
offsets = {
x: reference.x + reference.width,
y: commonY,
};
break;
case left:
offsets = {
x: reference.x - element.width,
y: commonY,
};
break;
default:
offsets = {
x: reference.x,
y: reference.y,
};
}
const mainAxis = basePlacement
? getMainAxisFromPlacement(basePlacement)
: null;
if (mainAxis != null) {
const len = mainAxis === 'y' ? 'height' : 'width';
switch (variation) {
case start:
offsets[mainAxis] =
offsets[mainAxis] - (reference[len] / 2 - element[len] / 2);
break;
case end:
offsets[mainAxis] =
offsets[mainAxis] + (reference[len] / 2 - element[len] / 2);
break;
default:
}
}
return offsets;
}
代码解释起来看上去不是很好理解,看图
offsets
先会根据basePlacement
计算出 4种情况的定位 基点。
基点就是 popper element 左上角的那个点,这也是为什么offset看上去奇奇怪怪的。
假设popper 的 width 和 height 均为 0。 是不是就更容易理解了
然后根据 mainAxis
和variation
主轴(x | y)计算 start | end
的偏移量 。
最终生成 offsets:{x:number,y:number}
,基点坐标,将它存储进入 modifiersData
中。字段名为 popperOffsets
。
End
第一弹到此结束了,有关popper.js仅分为2篇,这是第1篇。该文章仅是我个人的一些理解,难免有一些不对的地方,欢迎大家批评指正!!! 如果可以的话,占用你一些时间,帮我点个赞吧????????????????????????????????????????????????~