欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

揭开Popper.js的神秘面纱:入门指南第一篇

最编程 2024-07-27 07:39:37
...

前言

作为前端,popper.js是绕不过去的一道坎,它作为底层组件,帮助我们解决了 TooltipdropdownMenu等等一系列组件。几乎所有的这样的组件都是基于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初始化了一些 modifiersoptions,生成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();
},

runModifierEffectscleanupModifierEffects 其功能类似于reactuseEffect,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,当触发scrollresize时,可能会频繁触发 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
  1. 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,
  };

}
  1. getBoundingClientRect方法返回元素大小及其相对视窗(viewport) 的位置。

如果是标准盒子模型,元素的尺寸等于width/height + padding + border-width的总和。如果box-sizing: border-box,元素的的尺寸等于 width/height

返回的结果是包含完整元素的最小矩形,并且拥有left, top, right, bottom, x, y, width, 和 height这几个以像素为单位的只读属性用于描述整个边框。除了widthheight 以外的属性是相对于视图窗口的左上角来计算的。

  1. offsetWidth / offsetHeight = width/height + padding + border-width

由于getBoundingClientRect是基于 getClientRects的,mdn中说明到,小数级别的像素偏移是有可能的,所以在代码中做了一些容错做了一些容错。关于ClientRect是什么可以看这里 DomRect

  1. offsetLeft/offsetTop

返回当前元素左上角相对于offsetParent左边/上边偏移的像素值

offsetParent元素只可能是下面这几种情况:

  • <body>
  • position不是static的元素(position 默认值是 static)
  • <table>, <th><td>,但必须要position: static

实际这个 getLayoutRect返回了 popperwidth/height和 距离 reference 的x,y(一般情况下都是采用position:absolute | fixed)

有关更多各种offsetXXX的属性可以看张鑫旭大佬的这篇文章 CSSOM视图模式(CSSOM View Module)相关整理 « 张鑫旭-鑫空间-鑫生活 里面还贴心的配备了各种demo。给大佬点赞!!!。

  1. 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吧。。。。看了半天实际上就是返回了referencewidth,height以及left+scrollLefttop+scrollTop


可以看出来的是,每次update时,会重新计算rects、modifiersData,重新执行modifier[fn]。

通过上面的代码,可以看出 不同的Modifier主要是影响了两个部分

  1. effect的执行,仅在setOptions时执行,只在generator时 执行了一次
  2. modifier[fn]的执行,仅在Update时执行。update的执行可由effect来控制。每次触发eventListener都会执行。

Modifiers

Effect

const defaultModifiers = [
  eventListeners,
  popperOffsets,
  computeStyles,
  applyStyles,
  offset,
  flip,
  preventOverflow,
  arrow,
  hide,
];

默认的Modifiers中,拥有effect函数的有,eventListenersapplyStylesarrow。先来看一下这三个Modifier的effect函数分别做了什么。

  1. eventListeners

在前面已经看过了,这里就不做分析。该方法主要是注册监听器和取消监听器。

  1. 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);
      });
    });
  };
}

该方法针对 popperarrow做了一些style样式的合并和删除操作

  1. 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 refenercepopperarrow的style。Arrow主要是设置小箭头的elements。

Modifier[fn]

eventListeners这个Modifier 之外,其他的基本上都有fn方法,逐个攻破!

这里要注意的是,Modifier执行顺序是不可打乱的,在setOptions时,有这样一段代码,将默认的modifiers放置到了最前面。

// defaultModifiers 是默认的Modifiers
const orderedModifiers = orderModifiers(
          mergeByName([...defaultModifiers, ...state.options.modifiers])
        );
  1. 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。 是不是就更容易理解了

然后根据 mainAxisvariation主轴(x | y)计算 start | end 的偏移量 。

最终生成 offsets:{x:number,y:number},基点坐标,将它存储进入 modifiersData中。字段名为 popperOffsets

End

第一弹到此结束了,有关popper.js仅分为2篇,这是第1篇。该文章仅是我个人的一些理解,难免有一些不对的地方,欢迎大家批评指正!!! 如果可以的话,占用你一些时间,帮我点个赞吧????????????????????????????????????????????????~