[element-ui源码]element-ui中的神器Popper(使用方法)

4,115 阅读2分钟

1.场景描述

有一个我们自定义的组件,里面有用transition组件包裹着的弹出框,例如:

以上组件主要由el-inputel-cascader-panel组成的组件。el-input内部右侧的“浏览”按钮控制el-cascader-panel的弹出和消失。源码如下:

<template lang="pug">
  .my-component
    el-input.my-component__input(v-model="inputValue")
      template(slot="append")
        .my-component__buttons
          el-button(type="text" @click="visible=!visible") 浏览
    transition(name="el-zoom-in-top")
      .my-component__panel(v-show="visible")
        el-cascader-panel(:options="options")
</template>
<script>
export default {
  name:'my-component',
  data () {
    return {
      inputValue: '',
      visible: false,
      options: [
        // https://element.eleme.cn/#/zh-CN/component/cascader中的示例数据
      ]
    }
  }
}
</script>
<style lang="scss" scoped>
  .my-component {
    &__input {
      width: 250px;
    }

    &__buttons {
      display: flex;
      justify-content: center;
      width: 40px;
    }

    &__panel {
      position: absolute;
      margin-top: 5px;
      font-size: 14px;
      background: #fff;
      border-radius: 4px;
      box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
    }
  }
</style>

如果以上组件因为布局原因放到靠近屏幕的右部或者下部,就会出现溢出情况。此时如果整个document.body中的style['overflow']hidden。则无法选择溢出的内容。若style['overflow']hidden为auto。则滚动条会突然出现,而且也对这个页面的UI造成影响。如下所示:

对于这个问题,我们可以在element-ui中找到很好的解决方案。

2.element-ui中弹出框

以element-ui中的el-select为例子作分析,如下图所示:

总结一下上面的特征:

  • 弹出框初次弹出时,会把弹出DOM插入到body的子元素中
  • 上下滚动时,弹出DOM的style.top会随之变化,保证出现的位置紧贴选择框下面。同样的,左右滚动时,弹出DOM的style.left也会随之变化。
  • 弹出框消失时,则把弹出框的style.display设置为none。当弹出框再次出现时,则把style.display设置为''。

这样子有什么好处呢?当放在body时,相比于放在父元素内部,可以更灵活地调节style.top和style.left的大小,以让弹出DOM不溢出屏幕。

我们以官网的el-cascader来示例会更直观:

总结一下上面的特征:

  • 同样地弹出框插入到body作为子元素,出现时把style.display从none设置为''
  • 当弹出DOM元素的宽度因展开而发生变化时,其style.left也会相应变化,防止其溢出屏幕右侧
  • 当发生resize事件时,弹出DOM的style.left会随之变化,以防止右侧溢出
  • 当上下滚动时,弹出DOM的位置会随之切换,以防止下部溢出到屏幕底部。

3.element-ui是如何实现的?

我们以el-cascader的源码作为分析,以下是源码的简略部分,我只挑涉及到Popper的代码作展示和注释。

再次提醒:以下源码中我会过滤掉template中大量的没涉及到Popper的DOM元素中的属性以及DOM元素以及script中大量的没涉及到Popper的内容。

<template>
  <!-- 在根节点中设置属性ref="reference"以确定Popper的基准DOM,
    PopperMixin内部方法会通过$refs.reference获取该节点-->
  <div
    ref="reference"
    v-clickoutside="() => toggleDropDownVisible(false)"
    @click="() => toggleDropDownVisible(readonly ? undefined : true)"
    @keydown="handleKeyDown"
    >
    <el-input
      ref="input"
      v-model="multiple ? presentText : inputValue"
      @input="handleInput"
      >
      <template slot="suffix">
        <!-- 当点击选择框的下拉图标以显示弹出框时,调用toggleDropDownVisible函数 -->
        <i
          key="arrow-down"
          :class="[
            'el-input__icon',
            'el-icon-arrow-down',
            dropDownVisible && 'is-reverse'
          ]"
          @click.stop="toggleDropDownVisible()"></i>
      </template>
    </el-input>

    <transition name="el-zoom-in-top">
      <!-- 通过dropDownVisible控制弹出DOM的出现
        通过设置ref="popper"以确定Popper的弹出DOM,
        PopperMixin内部方法会通过$refs.popper获取该节点 -->
      <div
        v-show="dropDownVisible"
        ref="popper"
        :class="['el-popper', 'el-cascader__dropdown', popperClass]">
        <el-cascader-panel
          :options="options"
          @expand-change="handleExpandChange"
          @close="toggleDropDownVisible(false)"></el-cascader-panel>
      </div>
    </transition>
  </div>
</template>

<script>
import Popper from 'element-ui/src/utils/vue-popper';
import ElInput from 'element-ui/packages/input';
import ElCascaderPanel from 'element-ui/packages/cascader-panel';
import { isDef } from 'element-ui/src/utils/shared';

// 引入Popper后,取里面需要的属性和方法作mixin
const PopperMixin = {
  props: {
    placement: {
      type: String,
      default: 'bottom-start'
    },
    appendToBody: Popper.props.appendToBody,
    visibleArrow: {
      type: Boolean,
      default: true
    },
    arrowOffset: Popper.props.arrowOffset,
    offset: Popper.props.offset,
    boundariesPadding: Popper.props.boundariesPadding,
    popperOptions: Popper.props.popperOptions
  },
  methods: Popper.methods,
  data: Popper.data,
  beforeDestroy: Popper.beforeDestroy
};

export default {
  name: 'ElCascader',

  directives: { Clickoutside },

  components: {
    ElInput,
    ElCascaderPanel
  },

  props:{
    popperClass: String
  },

  data() {
    return {
      dropDownVisible: false,
      inputValue: null,
    };
  },

  computed:{
    isDisabled() {
      return this.disabled || (this.elForm || {}).disabled;
    }
  },

  methods: {
    // 当改变visible的状态时,调用该方法
    toggleDropDownVisible(visible) {
      if (this.isDisabled) return;

      const { dropDownVisible } = this;
      const { input } = this.$refs;
      // 如果visible是null和undefined其中之一,则取this.dropDownVisible的反值
      visible = isDef(visible) ? visible : !dropDownVisible;
      if (visible !== dropDownVisible) {
        this.dropDownVisible = visible;
        if (visible) {
          // 如果visible为真值,则在下一更新帧中调用PopperMixin中的方法updatePopper更新位置
          this.$nextTick(() => {
            this.updatePopper();
            // ...
          });
        }
        input.$refs.input.setAttribute('aria-expanded', visible);
        this.$emit('visible-change', visible);
      }
    },
    // 处理键盘事件
    handleKeyDown(event) {
      switch (event.keyCode) {
        case KeyCode.enter:
          this.toggleDropDownVisible();
          break;
        case KeyCode.down:
          this.toggleDropDownVisible(true);
          // ...
          event.preventDefault();
          break;
        case KeyCode.esc:
        case KeyCode.tab:
          this.toggleDropDownVisible(false);
          break;
      }
    },
    handleInput(val, event) {
      !this.dropDownVisible && this.toggleDropDownVisible(true);
      // ...
    },
    handleExpandChange(value) {
      this.$nextTick(this.updatePopper.bind(this));
      this.$emit('expand-change', value);
      this.$emit('active-item-change', value); // Deprecated
    },
  }
};
</script>

从源码可知,一切关于dropDownVisible的更改都要通过toggleDropDownVisible方法。而toggleDropDownVisible方法内部调用的是PopperMixin中的updatePopper方法。在引入PopperMixin且mixin的同时,要通过给对应的DOM元素设置ref="reference"ref="popper"去确定Popper的基准DOM和弹出DOM。

4.把Popper也用到我们的例子中

以开头的my-component组件作为例子,我们可以通过参照el-cascader的源码对我们的组件进行,改造如下:

<template lang="pug">
  //- 通过添加属性ref="reference"设置Popper基准DOM
  .my-component(ref="reference")
    el-input.my-component__input(v-model="inputValue" )
      template(slot="append")
        .my-component__buttons
          //- visible状态的改变统一用toggleDropDownVisible方法来取替
          el-button(type="text" @click="toggleDropDownVisible(!visible)") 浏览
    transition(name="el-zoom-in-top")
      //- 通过添加属性ref="popper"设置Popper弹出DOM,且添加el-popper的class
      .my-component__panel(v-show="visible" :class="['el-popper']" ref="popper")
        //- 注意要监听展开节点事件,因为展开会影响到弹出DOM的大小,所以在展开时也要调用toggleDropDownVisible更新
        el-cascader-panel(:options="options" @expand-change="handleExpandChange")

</template>
<script>
// 引入Popper
import Popper from 'element-ui/src/utils/vue-popper'
// 和el-cascader一样设置
const PopperMixin = {
  props: {
    placement: {
      type: String,
      default: 'bottom-start'
    },
    appendToBody: Popper.props.appendToBody,
    visibleArrow: {
      type: Boolean,
      default: true
    },
    arrowOffset: Popper.props.arrowOffset,
    offset: Popper.props.offset,
    boundariesPadding: Popper.props.boundariesPadding,
    popperOptions: Popper.props.popperOptions
  },
  methods: Popper.methods,
  data: Popper.data,
  beforeDestroy: Popper.beforeDestroy
}

export default {
  // mixin注册
  mixins: [PopperMixin],
  data () {
    return {
      inputValue: '',
      visible: false,
      options: [
        // ..
      ]
    }
  },
  methods: {
    toggleDropDownVisible (visible) {
      if (visible !== this.visible) {
        this.visible = visible
        if (visible) {
          this.$nextTick(() => {
            this.updatePopper()
          })
        }
      }
    },
    handleExpandChange () {
      this.$nextTick(this.updatePopper.bind(this))
    }
  }
}
</script>
<style lang="scss" scoped>
  .my-component {
    position: relative;

    &__input {
      width: 250px;
    }

    &__buttons {
      display: flex;
      justify-content: center;
      width: 40px;
    }

    &__panel {
      // 不需要设置position:absolute,因为Popper内置方法会自动帮我们处理弹窗效果
      // position: absolute;

      margin-top: 5px;
      font-size: 14px;
      background: #fff;
      border-radius: 4px;
      box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
    }
  }

</style>

现在再次看效果:

如图所示,加入PopperMixin后,按照配置后也展现出el-cascader中Popper的冒泡防溢出处理。

后记

针对element-ui的Popper,我初步做了源码分析总结成文章(持续更新中),有兴趣可以前往查看

[element-ui源码]element-ui中的神器Popper(源码浅析)