[Element Plus 源码解析] Popper 弹框

·  阅读 1708

一、组件介绍

element-plus的官方文档中没有ElPopper组件,ElPopper在其他组件内部使用,如:Select、Cascade、ColorPicker等组件中都用到了ElPopper

官方文档中提供的弹框组件是ElPopover,这两个组件之间的关系非常密切,这里先分析ElPopper组件。

二、源码分析

2.1 popper.ts

ElPopper组件及其子组件都使用render函数编写。

popper.ts对应弹出内容

// packages\components\popper\src\renderers\popper.ts
// 参数:
// props: 传入的属性
// children: 子元素数组 
export default function renderPopper(
  props: IRenderPopperProps,
  children: VNode[],
) {
  // 从props中解构出对应属性
  const {
    effect,
    name,
    stopPopperMouseEvent,
    popperClass,
    popperStyle,
    popperRef,
    pure,
    popperId,
    visibility,
    onMouseenter,
    onMouseleave,
    onAfterEnter,
    onAfterLeave,
    onBeforeEnter,
    onBeforeLeave,
  } = props

  // 动态class,缩写挺逗的
  const kls = [
    popperClass,
    'el-popper',
    'is-' + effect,
    pure ? 'is-pure' : '',
  ]
  
  // 鼠标按下/松开事件 stop = (e: Event) => e.stopPropagation()
  const mouseUpAndDown = stopPopperMouseEvent ? stop : NOOP
  // render函数
  return h(
    // 外层组件,使用vue官方过渡组件
    Transition,
    // 属性,主要是过渡事件
    {
      name,
      'onAfterEnter': onAfterEnter,
      'onAfterLeave': onAfterLeave,
      'onBeforeEnter': onBeforeEnter,
      'onBeforeLeave': onBeforeLeave,
    },
    // slots
    {
      // 默认插槽
      // withCtx withDirectives 是vue官方内部方法,
      default: withCtx(() => [withDirectives(
        // 内部元素,部分传入的属性绑定在这一层元素上
        h(
          'div',
          {
            'aria-hidden': String(!visibility),
            class: kls,
            style: popperStyle ?? {},
            id: popperId,
            ref: popperRef ?? 'popperRef',
            role: 'tooltip',
            onMouseenter,
            onMouseleave,
            onClick: stop,
            onMousedown: mouseUpAndDown,
            onMouseup: mouseUpAndDown,
          },
          children,
        ),
        // 指令,等同于 v-show="visibility"
        [[vShow, visibility]],
      )]),
    },
  )
  /**
   * 以上render函数,等同于:
   * <transition :name="name">
   *  <div v-show="visibility" :aria-hidden="!visibility" :class="kls" ref="popperRef" role="tooltip" @mouseenter="" @mouseleave="" @click="">
   *    <slot />
   *  </div>
   * </transition>
   */
}
复制代码

总结:

  • popper.ts提供了renderPopper方法,该方法接收2个参数:传入的属性及子元素数组;
  • renderPopper方法中,外层使用Transition组件展示过渡效果,内层使用div标签,将相应属性绑定在这一层div标签上,传入的children子元素作为这一层div的子元素。

2.2 trigger.ts

trigger.ts对应触发部分

// packages\components\popper\src\renderers\trigger.ts

interface IRenderTriggerProps extends Record<string, unknown> {
  ref: string | Ref<ComponentPublicInstance | HTMLElement>
  onClick?: EventHandler
  onMouseover?: EventHandler
  onMouseleave?: EventHandler
  onFocus?: EventHandler
}

// 参数:
// 1、元素数组 2、传入的属性
export default function renderTrigger(trigger: VNode[], extraProps: IRenderTriggerProps) {
  // 获取第一个有效的VNode节点
  const firstElement = getFirstValidNode(trigger, 1)
  // 没有有效VNode节点则报错提示
  if (!firstElement) throwError('renderTrigger', 'trigger expects single rooted node')
  // 使用vue的cloneVNode api,将extraProps和firstElement的props融合
  return cloneVNode(firstElement, extraProps, true)
}
复制代码

总结:

  • trigger.ts的逻辑比较简单,找到第一个有效的VNode节点,并且将一些额外的属性融入到VNode节点中。

2.3 arrow.ts

arrow.ts是弹框的箭头部分

// packages\components\popper\src\renderers\arrow.ts

// 参数:1、是否展示箭头
export default function renderArrow(showArrow: boolean) {
  // 根据showArrow决定是否展示箭头
  return showArrow
    ? h(
      'div',
      {
        ref: 'arrowRef',
        class: 'el-popper__arrow',
        'data-popper-arrow': '',
      },
      null,
    )
    : h(Comment, null, '')
}
复制代码

arrow.ts的逻辑更加简单,根据传入的showArrow参数决定是否展示箭头。

2.4 index.vue

前面介绍了ElPopper的3个子组件部分,现在来看一下组件本身的index.vue

// packages\components\popper\src\index.vue

export default defineComponent({
  name: compName,
  props: defaultProps,
  emits: [UPDATE_VISIBLE_EVENT, 'after-enter', 'after-leave', 'before-enter', 'before-leave'],
  setup(props, ctx) {
    // 如果用户没有写trigger插槽,则报错提示:必须提供trigger
    if (!ctx.slots.trigger) {
      throwError(compName, 'Trigger must be provided')
    }
    // 调用usePopper,返回值是popper的状态数据
    const popperStates = usePopper(props, ctx)
    // 封装强制销毁方法
    const forceDestroy = () => popperStates.doDestroy(true)
    // 挂载时调用initializePopper
    onMounted(popperStates.initializePopper)
    // 卸载时销毁
    onBeforeUnmount(forceDestroy)
    // keep-alive激活时调用initializePopper
    onActivated(popperStates.initializePopper)
    // 失效时销毁
    onDeactivated(forceDestroy)

    // 返回状态数据
    return popperStates
  },

  render() {
    // 从this上解构属性,其中一部分属性是setup函数返回的,一部分是vue组件初始化时生成的
    const {
      $slots,
      appendToBody,
      class: kls,
      style,
      effect,
      hide,
      onPopperMouseEnter,
      onPopperMouseLeave,
      onAfterEnter,
      onAfterLeave,
      onBeforeEnter,
      onBeforeLeave,
      popperClass,
      popperId,
      popperStyle,
      pure,
      showArrow,
      transition,
      visibility,
      stopPopperMouseEvent,
    } = this

    // 是否手动触发模式
    const isManual = this.isManualMode()
    // 箭头
    const arrow = renderArrow(showArrow)
    // 弹框
    const popper = renderPopper(
      // 属性
      {
        effect,
        name: transition,
        popperClass,
        popperId,
        popperStyle,
        pure,
        stopPopperMouseEvent,
        onMouseenter: onPopperMouseEnter,
        onMouseleave: onPopperMouseLeave,
        onAfterEnter,
        onAfterLeave,
        onBeforeEnter,
        onBeforeLeave,
        visibility,
      },
      // children
      [
        // 默认插槽内容
        renderSlot($slots, 'default', {}, () => {
          return [toDisplayString(this.content)]
        }),
        // 箭头
        arrow,
      ],
    )
    // trigger slot
    const _t = $slots.trigger?.()
    // trigger props
    const triggerProps = {
      'aria-describedby': popperId,
      class: kls,
      style,
      ref: 'triggerRef',
      ...this.events,
    }
    // 根据是否手动触发,决定是否给trigger添加clickOutside指令
    const trigger = isManual
      ? renderTrigger(_t, triggerProps)
      : withDirectives(renderTrigger(_t, triggerProps), [[ClickOutside, hide]])
    // Fragment是vue内置的片段,其不会渲染出任何节点
    return h(Fragment, null, 
    // children
    [
      // 触发元素
      trigger,
      // Teleport是vue内置的传送门组件,将子元素渲染到to指定的元素中
      // 这里的to赋值为body,即弹框部分挂载到body下
      h(
        Teleport as any, // Vue did not support createVNode for Teleport
        {
          to: 'body',
          // 若appendToBody值为false,则禁用传送门
          disabled: !appendToBody,
        },
        // 弹框内容
        [popper],
      ),
    ])
  },
})
复制代码

总结:

  • setup方法中调用usePopper方法生成popper的状态数据;并注册生命周期钩子函数,在挂载/激活时调用initializePopper方法,在卸载/失效时调用doDestroy(true)
  • render函数中,首先通过解构赋值的方式从this上获取相应的属性值;调用renderArrow/renderPopper/renderTrigger生成相应子模块,调用时传入相应模块所需的属性值;
  • 使用Teleport传送门组件,将popper部分挂载到body上

2.5 use-popper/index.ts

在上面的index.vue中,其setup函数内调用了userPopper方法,我们进一步分析一下这个方法:

// packages\components\popper\src\use-popper\index.ts
export default function(
  props: IPopperOptions,
  { emit }: SetupContext<EmitType[]>,
) {
  // arrow trigger popper三个部分的ref
  const arrowRef = ref<RefElement>(null)
  const triggerRef = ref(null) as Ref<ElementType>
  const popperRef = ref<RefElement>(null)

  // 生成唯一的随机id
  const popperId = `el-popper-${generateId()}`
  let popperInstance: Nullable<PopperInstance> = null
  // 延迟出现的计时器,即触发显示后,延迟一段时间再显示
  let showTimer: Nullable<TimeoutHandle> = null
  // 自动隐藏计时器,即显示弹框后,一段时间后自动隐藏
  let hideTimer: Nullable<TimeoutHandle> = null
  // trigger的focus状态
  let triggerFocused = false

  // 是否手动触发模式
  const isManualMode = () => props.manualMode || props.trigger === 'manual'

  // popper的动态样式,这里是通过PopupManager生成一个zIndex值
  const popperStyle = ref<CSSProperties>({ zIndex: PopupManager.nextZIndex() })

  // 调用usePopperOptions生成popperOptions
  const popperOptions = usePopperOptions(props, {
    arrow: arrowRef,
  })

  // 响应式数据,将props.visible的值转换成boolean类型,
  const state = reactive({
    visible: !!props.visible,
  })
  // visible has been taken by props.visible, avoiding name collision
  // Either marking type here or setter parameter
  // 计算属性visibility,有get和set方法
  const visibility = computed<boolean>({
    get() {
      if (props.disabled) {
        return false
      } else {
        return isBool(props.visible) ? props.visible : state.visible
      }
    },
    set(val) {
      if (isManualMode()) return
      isBool(props.visible)
        ? emit(UPDATE_VISIBLE_EVENT, val)
        : (state.visible = val)
    },
  })
  // 内部的show方法
  function _show() {
    // autoClose用户控制弹框出现后,自动隐藏的延迟,若为0则不会自动隐藏
    if (props.autoClose > 0) {
      // 定时器,时间到时调用_hide隐藏
      hideTimer = window.setTimeout(() => {
        _hide()
      }, props.autoClose)
    }
    visibility.value = true
  }
  // 内部的hide方法
  function _hide() {
    visibility.value = false
  }

  // 清除计时器,包括showTimer和hideTimer
  function clearTimers() {
    clearTimeout(showTimer)
    clearTimeout(hideTimer)
  }
  
  // 展示
  const show = () => {
    if (isManualMode() || props.disabled) return
    // 清除计时器
    clearTimers()
    if (props.showAfter === 0) {
      _show()
    } else {
      // 有延迟显示时,使用计时器
      showTimer = window.setTimeout(() => {
        _show()
      }, props.showAfter)
    }
  }

  // 隐藏
  const hide = () => {
    if (isManualMode()) return
    // 清除计时器
    clearTimers()
    if (props.hideAfter > 0) {
      // 若有延迟关闭,则使用计时器
      hideTimer = window.setTimeout(() => {
        close()
      }, props.hideAfter)
    } else {
      close()
    }
  }
  // 关闭
  const close = () => {
    _hide()
    if (props.disabled) {
      // 如果禁用,调用销毁
      doDestroy(true)
    }
  }
  // 鼠标进入popper事件处理函数
  function onPopperMouseEnter() {
    // trigger非click模式下,鼠标进入popper,清除自动隐藏计时器
    if (props.enterable && props.trigger !== 'click') {
      clearTimeout(hideTimer)
    }
  }
  // 鼠标离开popper事件处理函数
  function onPopperMouseLeave() {
    const { trigger } = props
    
    const shouldPrevent =
      (isString(trigger) && (trigger === 'click' || trigger === 'focus')) ||
      (trigger.length === 1 &&
        (trigger[0] === 'click' || trigger[0] === 'focus'))
    // trigger是click或focus时,不触发关闭弹框    
    if (shouldPrevent) return
    // trigger是hover时关闭弹框,因为hide方法中对manual模式进行了判断,因此只有hover模式下会关闭
    hide()
  }

  // 初始化 popper
  function initializePopper() {
    // $()方法是element-plus自定义的一个方法,作用是直接拿到ref类型数据的value值
    //  function $<T>(ref: Ref<T>) {
    //    return ref.value
    //  }
    if (!$(visibility)) {
      // 隐藏状态下,直接return
      return
    }

    const unwrappedTrigger = $(triggerRef)
    // 判断trigger元素是HTML原生元素还是vue组件
    // 如果是组件,取其$el
    const _trigger = isHTMLElement(unwrappedTrigger)
      ? unwrappedTrigger
      : (unwrappedTrigger as ComponentPublicInstance).$el
    // 关键点:createPopper是popperjs库提供的方法
    // createPopper接收3个参数:1、reference,即触发弹框的对象;2、popper: 即弹框元素;3、popperOptions:即弹框配置
    popperInstance = createPopper(_trigger, $(popperRef), $(popperOptions))
    // 调用update
    popperInstance.update()
  }

  // 销毁
  function doDestroy(forceDestroy?: boolean) {
    /* istanbul ignore if */
    if (!popperInstance || ($(visibility) && !forceDestroy)) return
    detachPopper()
  }
  
  function detachPopper() {
    // 调用popperjs的destroy方法
    popperInstance?.destroy?.()
    popperInstance = null
  }

  const events = {} as PopperEvents

  // 显示切换副作用函数
  function onVisibilityChange(toState: boolean) {
    if (toState) {
      // 变成可见状态时,更新zIndex
      popperStyle.value.zIndex = PopupManager.nextZIndex()
      // 重新调用initializePopper
      initializePopper()
    }
  }
  // 显示切换时,调用显示切换副作用函数
  watch(visibility, onVisibilityChange)

  // 非自动触发模式下
  if (!isManualMode()) {
    // 定义切换显示方法
    const toggleState = () => {
      if ($(visibility)) {
        hide()
      } else {
        show()
      }
    }

    // 定义事件处理函数
    const popperEventsHandler = (e: Event) => {
      // 阻止冒泡
      e.stopPropagation()
      switch (e.type) {
        // 点击事件
        case 'click': {
          if (triggerFocused) {
            // 当前focus,则调整为非focus
            triggerFocused = false
          } else {
            // 当前非focus,则切换显示状态
            toggleState()
          }
          break
        }
        // 鼠标进入事件
        case 'mouseenter': {
          // 显示
          show()
          break
        }
        // 鼠标移出事件
        case 'mouseleave': {
          // 隐藏
          hide()
          break
        }
        // focus事件
        case 'focus': {
          // 置为focus状态,并显示
          triggerFocused = true
          show()
          break
        }
        // 失焦事件
        case 'blur': {
          // 置为非focus状态,并隐藏
          triggerFocused = false
          hide()
          break
        }
      }
    }
    // 根据trigger类型,绑定对应事件
    const triggerEventsMap: Partial<Record<TriggerType, (keyof PopperEvents)[]>> = {
      // click类型,绑定onClick事件
      click: ['onClick'],
      // hover类型,绑定onMouseenter onMouseleave 事件
      hover: ['onMouseenter', 'onMouseleave'],
      // focus类型,绑定onFocus onBlur事件
      focus: ['onFocus', 'onBlur'],
    }
    
    const mapEvents = (t: TriggerType) => {
      triggerEventsMap[t].forEach(event => {
        // 各个事件的响应函数都一样,popperEventsHandler函数内部针对event type进行处理
        events[event] = popperEventsHandler
      })
    }

    if (isArray(props.trigger)) {
      Object.values(props.trigger).forEach(mapEvents)
    } else {
      mapEvents(props.trigger as TriggerType)
    }
  }

  // popperOptions变化时,使用popperjs的setOptions方法重新设置,并update
  watch(popperOptions, val => {
    if (!popperInstance) return
    popperInstance.setOptions(val)
    popperInstance.update()
  })

  return {
    doDestroy,
    show,
    hide,
    onPopperMouseEnter,
    onPopperMouseLeave,
    onAfterEnter: () => {
      emit('after-enter')
    },
    onAfterLeave: () => {
      detachPopper()
      emit('after-leave')
    },
    onBeforeEnter: () => {
      emit('before-enter')
    },
    onBeforeLeave: () => {
      emit('before-leave')
    },
    initializePopper,
    isManualMode,
    arrowRef,
    events,
    popperId,
    popperInstance,
    popperRef,
    popperStyle,
    triggerRef,
    visibility,
  }
}
复制代码

usePopper总结:

  • usePopper方法中,定义了属性和方法,维护弹框的显示状态;
  • usePopper方法中,调用第三方库popperjs控制弹框的实际显示位置;
  • usePopper方法中,为trigger绑定相应的事件处理函数,控制弹框的展示隐藏。
分类:
前端
收藏成功!
已添加到「」, 点击更改