解谜Popper.js 第二弹

1,107 阅读7分钟

前言

作为前端,popper.js是绕不过去的一道坎,它作为底层组件,帮助我们解决了 TooltipdropdownMenu等等一系列组件。几乎所有的这样的组件都是基于Popper.js组件来构成的。它的核心只有6000 byte左右,并且支持tree-shaking,支持自定义中间件、支持任意框架(基于 JavaScript)。

当前是基于v2.x分支作为分析,仅针对2.x分支分析。最新版本的popper.js已经更名为floating-ui。

本文为接上篇没有说完的续篇,要看完整篇幅请移步这里解谜Popper.js 第一弹

基本逻辑

Modifiers

Modifier[fn]

  1. computeStyles

主要计算 popperarrow的 style 样式,开启GPU加速(使用transform),一些误差的磨平。

function computeStyles({ state, options }) {
  const {
    // gpu加速 默认开启
    gpuAcceleration = true,
    // 自适应 默认开启
    adaptive = true,
    // defaults to use builtin `roundOffsetsByDPR`
    roundOffsets = true,
  } = options;
  
  const commonStyles = {
    // top、bottom、start、end
    placement: getBasePlacement(state.placement),
    // start、end
    variation: getVariation(state.placement),
    // element
    popper: state.elements.popper,
    // update中生成的 rect
    popperRect: state.rects.popper,
    // gpu加速
    gpuAcceleration,
    isFixed: state.options.strategy === 'fixed',
  };
  // 在popperOffsets中生成的
  if (state.modifiersData.popperOffsets != null) {
    state.styles.popper = {
      ...state.styles.popper,
      ...mapToStyles({
        ...commonStyles,
        offsets: state.modifiersData.popperOffsets,
        position: state.options.strategy,
        adaptive,
        roundOffsets,
      }),
    };
  }
  if (state.modifiersData.arrow != null) {
    state.styles.arrow = {
      ...state.styles.arrow,
      ...mapToStyles({
        ...commonStyles,
        offsets: state.modifiersData.arrow,
        position: 'absolute',
        adaptive: false,
        roundOffsets,
      }),
    };
  }
  state.attributes.popper = {
    ...state.attributes.popper,
    'data-popper-placement': state.placement,
  };
}

function mapToStyles({popper,popperRect,placement,variation,offsets,position,gpuAcceleration,adaptive,roundOffsets,isFixed,}) 
{
  // offsets 来自于 popperOffsets 中生成
  let { x = 0, y = 0 } = offsets;
  const hasX = offsets.hasOwnProperty('x');
  const hasY = offsets.hasOwnProperty('y');
  let sideX: string = left;
  let sideY: string = top;
  const win: Window = window;
  // 自适应处理逻辑 存在一些 style的变换
  if (adaptive) {
    // ... some code 
  }
  // 经过自适应处理过后的style
  const commonStyles = {
    position,
    ...(adaptive && unsetSides),
  };
    // 默认为 true 自定义取消误差函数
  ({ x, y } =
    typeof roundOffsets === 'function'
      ? roundOffsets({ x, y })
      : { x, y });
  // 取消误差 因为不同分辨率下可能会存在一些误差而导致渲染错位,通常会有<1px的误差
  ({ x, y } =
    roundOffsets === true
      ? roundOffsetsByDPR({ x, y })
      : { x, y });
  // 如果开启gpu加速的话 使用transform
  if (gpuAcceleration) {
    return {
      ...commonStyles,
      [sideY]: hasY ? '0' : '',
      [sideX]: hasX ? '0' : '',
      // Layer acceleration can disable subpixel rendering which causes slightly
      // blurry text on low PPI displays, so we want to use 2D transforms
      // instead
      transform:
        (win.devicePixelRatio || 1) <= 1
          ? `translate(${x}px, ${y}px)`
          : `translate3d(${x}px, ${y}px, 0)`,
    };
  }
  // 不开启gpu加速返回得结果
  return {
    ...commonStyles,
    [sideY]: hasY ? `${y}px` : '',
    [sideX]: hasX ? `${x}px` : '',
    transform: '',
  };
}
  1. applyStyles

popperarrowreference等原有的 style 和 attribute 与 computeSytles 中计算出的 style 样式进行合并。

function applyStyles({ state }: ModifierArguments<{||}>) {
  // 取出 reference、 popper、arrow 设置 style、attributes等
  Object.keys(state.elements).forEach((name) => {
    const style = state.styles[name] || {};
    const attributes = state.attributes[name] || {};
    const element = state.elements[name];
    // arrow is optional + virtual elements
    if (!isHTMLElement(element) || !getNodeName(element)) {
      return;
    }
    // Flow doesn't support to extend this property, but it's the most
    // effective way to apply styles to an HTMLElement
    // $FlowFixMe[cannot-write]
    Object.assign(element.style, style);
    // 针对 attribute 特殊处理
    Object.keys(attributes).forEach((name) => {
      const value = attributes[name];
      if (value === false) {
        element.removeAttribute(name);
      } else {
        element.setAttribute(name, value === true ? '' : value);
     }
    });
  });
}
  1. Offsets

注:这里的offsets指的是用户自定义的一些偏移量,而不是计算得出的 popperOffsets

function offset({ state, options, name }: ModifierArguments<Options>) {
  // options传入的参数 [0,0] 可以理解为用户自定义额外的偏移量
  const { offset = [0, 0] } = options;
  // 由于接下来的溢出检测,自动切换位置可能会需要其他方向的偏移量,所以这边一次性所有方位全部生成,而避免了每次计算,每次生成。
  const data = placements.reduce((acc, placement) => {
    // 根据placements生成 每个方位的偏移量
    acc[placement] = distanceAndSkiddingToXY(placement, state.rects, offset);
    return acc;
  }, {});
  const { x, y } = data[state.placement];
  if (state.modifiersData.popperOffsets != null) {
    state.modifiersData.popperOffsets.x += x;
    state.modifiersData.popperOffsets.y += y;
  }
  // 更新 modifiersData popperOffsets
  state.modifiersData[name] = data;
}
  1. flip
function flip({ state, options, name }) {
    // flip skip    
    if (state.modifiersData[name]._skip) {
        return;
    }
    // 
    const {
        mainAxis: checkMainAxis = true,
        altAxis: checkAltAxis = true,
        // 默认可供尝试的placements
        fallbackPlacements: specifiedFallbackPlacements,
        // 会对boundary边界进行虚拟填充
        padding,
        // popper
        boundary,
        // reference
        rootBoundary,
        // arrow
        altBoundary,
        // top-start => top-end 尝试翻转变化
        flipVariations = true,
        allowedAutoPlacements,
    } = options;
    // custom placement??
    const preferredPlacement = state.options.placement;
    // 'top-start' => 'top'
    const basePlacement = getBasePlacement(preferredPlacement);
    const isBasePlacement = basePlacement === preferredPlacement;
    // 获取 当发生溢出时,可供尝试的Placements,默认情况下应该为 specifiedFallbackPlacements,
    //若没有 specifiedFallbackPlacements 则根据placement来生成fallbackPlacements
    const fallbackPlacements =
        specifiedFallbackPlacements ||
        (isBasePlacement || !flipVariations ?
            [getOppositePlacement(preferredPlacement)] :
            getExpandedFallbackPlacements(preferredPlacement));
    // 生成的placements
    const placements = [preferredPlacement, ...fallbackPlacements].reduce(
        (acc, placement) => {
            return acc.concat(
                // 当placement为 'auto'时,自动生成一个可能能用的placement
                getBasePlacement(placement) === auto ?
                computeAutoPlacement(state, {
                    placement,
                    boundary,
                    rootBoundary,
                    padding,
                    flipVariations,
                    allowedAutoPlacements,
                }) :
                placement
            );
        }, []
    );
}

fallbackPlacements有关的一些说明,假设我们原有的placement设置为button,当没有足够的空间来容纳它时,会使用fallbackPlacements中的参数来进行尝试。pacements 有下面三种情况:

  • specifiedFallbackPlacements参数,则直接使用参数,
  • basePlacement(top | left | right | bottom),则会取反,例如 原来是top,取反会是 bottom
  • 是 top-start,会先尝试 top-endbottom-startbottom-end这三种情况。
  • auto,则执行 computeAutoPlacement进行计算。
    const referenceRect = state.rects.reference;
    const popperRect = state.rects.popper;
    const checksMap = new Map();
    let makeFallbackChecks = true;
    let firstFittingPlacement = placements[0];
    // 开始生成checksMap
    for (let i = 0; i < placements.length; i++) {
        const placement = placements[i];
        const basePlacement = getBasePlacement(placement);
        const isStartVariation = getVariation(placement) === start;
        // 方向 x | y
        const isVertical = [top, bottom].indexOf(basePlacement) >= 0;
        
        // 检测溢出 
        const overflow = detectOverflow(state, {
            placement,
            boundary,
            rootBoundary,
            altBoundary,
            padding,
        });

这里的是 经过 detectOverflow函数,生成了 overflow,利用 overflow 来判断可用的 placement。看一下 detectOverflow是如何检测溢出的。

function detectOverflow(state,option){
  const {
    placement = state.placement,
    boundary = clippingParents,
    rootBoundary = viewport,
    // flip调用时,没有传入elementContext,为默认值
    elementContext = popper,
    // true => reference || false => popper
    altBoundary = false,
    // padding 检测距离,用户传入参数
    padding = 0,
  } = options;
  // 根据padding和 basePlacements生成 
  // paddingObject,默认是{top:0,left:0,right:0,bottom:0}
  // 假设padding = 10,则为 {top:10,left:10,right:10,bottom:10}
  const paddingObject = mergePaddingObject(
    typeof padding !== 'number'
      ? padding
      : expandToHashMap(padding, basePlacements)
  );
  // altContext 在flip调用时 默认为 reference
  const altContext = elementContext === popper ? reference : popper;
  const popperRect = state.rects.popper;
  // element 默认为 popper
  const element = state.elements[altBoundary ? altContext : elementContext];
  // 生成一个clippingclient {width,height,x,y} // 这个clippingclientRect默认情况下指的是父节点的滚动区域大小或者是 viewport大小
  // 原有的注释:Gets the maximum area that the element is visible in due to any number of clipping parents
  const clippingClientRect = getClippingRect(
    isElement(element)
      ? element
      : element.contextElement || getDocumentElement(state.elements.popper),
    boundary, // clippingParents
    rootBoundary // viewport
  );
// 浏览器中的 真正大小, getBoundingClientRect函数重写了element. getBoundingClientRect,其中对页面的缩放及一些element.getBoundingClientRect错误情况做了容错处理。这里可以直接理解为element.getBoundingClientRect的返回值
  const referenceClientRect = getBoundingClientRect(state.elements.reference);
  // computeOffsets 在popperOffsets中用到过,这里就不做说明了,会生成一个基点坐标
  const popperOffsets = computeOffsets({
    reference: referenceClientRect,
    element: popperRect,
    strategy: 'absolute',
    placement,
  });
  根据得到的基点坐标,生成新的 popperClientRect
  const popperClientRect = rectToClientRect({
    ...popperRect,
    ...popperOffsets,
  });
  // elementClientRect默认值为 popperClientRect
  const elementClientRect =
    elementContext === popper ? popperClientRect : referenceClientRect;
  // 四个方向检测是否有溢出,如果有溢出的话,例如:top:10,
  const overflowOffsets = {
    top: clippingClientRect.top - elementClientRect.top + paddingObject.top,
    bottom:
      elementClientRect.bottom -
      clippingClientRect.bottom +
      paddingObject.bottom,
    left: clippingClientRect.left - elementClientRect.left + paddingObject.left,
    right:
      elementClientRect.right - clippingClientRect.right + paddingObject.right,
  };
   // 获取在offsets生成的offsetData
  const offsetData = state.modifiersData.offset;
  // Offsets can be applied only to the popper element
  if (elementContext === popper && offsetData) {
    const offset = offsetData[placement];
    Object.keys(overflowOffsets).forEach((key) => {
      const multiply = [right, bottom].indexOf(key) >= 0 ? 1 : -1;
      const axis = [top, bottom].indexOf(key) >= 0 ? 'y' : 'x';
      overflowOffsets[key] += offset[axis] * multiply;
    });
  }
  return overflowOffsets;
}

代码有点复杂,来分析一下detectOverflow函数做了什么

  1. 根据 padding 生成 paddingObject
  2. 判断是检测 popper还是 reference,生成altContext
  3. 根据altContext,生成 clippingClientRect,该变量默认情况下是父节点滚动区域的大小或者 viewport的可视区域大小,为altContext可见的最大区域
  4. elementClientRect此时此刻reference.getBoundingClientRect()或者 popperClientRect
  5. 生成 overflowOffsets,举例:topclippingClientRect.top - elementClientRect.top + paddingObject.top,正常情况下若可用为负数

最终,我们返回了overflowOffsets,它包含了4个方位是否有溢出的情况。目前,我们仅分析默认情况,传参情况暂不考虑。

        // 分区
        // right:top-start、bottom-start
        // left:top-end、bottom-end
        // bottom:left-start、right-start
        // top:left-end、right-end
        let mainVariationSide: any = isVertical ?
            (isStartVariation ?
              right :
              left ):
            (isStartVariation ?
              bottom :
              top);
        const len = isVertical ? 'width' : 'height';
        // 若 reference width | height > popper 取反向,没太想通为什么要取反向
        // 刚刚得到的right会变成 left
        if (referenceRect[len] > popperRect[len]) {
            mainVariationSide = getOppositePlacement(mainVariationSide);
        }
        
        const altVariationSide: any = getOppositePlacement(mainVariationSide);
        const checks = [];
        // 这里需要注意正数为不可用,负数|| 0 为可用,还有空间
        if (checkMainAxis) {
            checks.push(overflow[basePlacement] <= 0);
        }
        if (checkAltAxis) {
            checks.push(
                overflow[mainVariationSide] <= 0,
                overflow[altVariationSide] <= 0
            );
        }
        // 若有一个可用的位置
        if (checks.every((check) => check)) {
            firstFittingPlacement = placement;
            makeFallbackChecks = false;
            break;
        }
        checksMap.set(placement, checks);
    }
    // 如果没有可用的placement
    if (makeFallbackChecks) {
        // `2` may be desired in some cases – research later
        const numberOfChecks = flipVariations ? 3 : 1;
        for (let i = numberOfChecks; i > 0; i--) {
            const fittingPlacement = placements.find((placement) => {
                const checks = checksMap.get(placement);
                if (checks) {
                    return checks.slice(0, i).every((check) => check);
                }
            });
            if (fittingPlacement) {
                firstFittingPlacement = fittingPlacement;
                break;
            }
        }
    }
    // 若placement与预设的不同,则_skip为true。
    if (state.placement !== firstFittingPlacement) {
        state.modifiersData[name]._skip = true;
        state.placement = firstFittingPlacement;
        state.reset = true;
    }

Flip 是这样,整体执行流程大致为,设置一些placements,检测popper四个方向,根据placementsoverflowOffsets来推算哪个placements可以使用,如果某一个可以使用,结束退出函数。

  1. preventOverflow

这部分实在是懒得写了,QAQ。简单介绍一下他的作用,计算出reference在什么情况下应该算溢出,然后记录下这个值,以便后面使用

  1. Arrow
function arrow({ state, name, options }: ModifierArguments<Options>) {
  // 
  const arrowElement = state.elements.arrow;
  // 
  const popperOffsets = state.modifiersData.popperOffsets;
  // top/bottom/left/right
  const basePlacement = getBasePlacement(state.placement);
  // top/bottom => x left/right => y
  const axis = getMainAxisFromPlacement(basePlacement);
  const isVertical = [left, right].indexOf(basePlacement) >= 0;
  const len = isVertical ? 'height' : 'width';
  // 若无arrow元素或者 无popper的offset
  if (!arrowElement || !popperOffsets) {
    return;
  }
  // 边距
  const paddingObject = toPaddingObject(options.padding, state);
  const arrowRect = getLayoutRect(arrowElement);
  const minProp = axis === 'y' ? top : left;
  const maxProp = axis === 'y' ? bottom : right;
  const endDiff =
    state.rects.reference[len] +
    state.rects.reference[axis] -
    popperOffsets[axis] -
    state.rects.popper[len];
  const startDiff = popperOffsets[axis] - state.rects.reference[axis];
  const arrowOffsetParent = getOffsetParent(arrowElement);
  const clientSize = arrowOffsetParent
    ? axis === 'y'
      ? arrowOffsetParent.clientHeight || 0
      : arrowOffsetParent.clientWidth || 0
    : 0;
  const centerToReference = endDiff / 2 - startDiff / 2;
  // Make sure the arrow doesn't overflow the popper if the center point is
  // outside of the popper bounds
  const min = paddingObject[minProp];
  const max = clientSize - arrowRect[len] - paddingObject[maxProp];
  const center = clientSize / 2 - arrowRect[len] / 2 + centerToReference;
  const offset = within(min, center, max);
  // Prevents breaking syntax highlighting...
  const axisProp: string = axis;
  state.modifiersData[name] = {
    [axisProp]: offset,
    centerOffset: offset - center,
  };

计算出arrow的位置。 8. ##### hide

function hide({ state, name }: ModifierArguments<{||}>) {
  const referenceRect = state.rects.reference;
  const popperRect = state.rects.popper;
  const preventedOffsets = state.modifiersData.preventOverflow;
  // referenceOverflow是否溢出
  const referenceOverflow = detectOverflow(state, {
    elementContext: 'reference',
  });
  // popper是否溢出
  const popperAltOverflow = detectOverflow(state, {
    altBoundary: true,
  });
  const referenceClippingOffsets = getSideOffsets(
    referenceOverflow,
    referenceRect
  );
  const popperEscapeOffsets = getSideOffsets(
    popperAltOverflow,
    popperRect,
    preventedOffsets
  );
  const isReferenceHidden = isAnySideFullyClipped(referenceClippingOffsets);
  const hasPopperEscaped = isAnySideFullyClipped(popperEscapeOffsets);
  state.modifiersData[name] = {
    referenceClippingOffsets,
    popperEscapeOffsets,
    isReferenceHidden,
    hasPopperEscaped,
  };
  state.attributes.popper = {
    ...state.attributes.popper,
    'data-popper-reference-hidden': isReferenceHidden,
    'data-popper-escaped': hasPopperEscaped,
  };
}

分别判断了,referencepopper是否溢出,如果溢出的话,则hide。

End

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