1.场景描述
有一个我们自定义的组件,里面有用transition
组件包裹着的弹出框,例如:
以上组件主要由el-input
和el-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,我初步做了源码分析总结成文章(持续更新中),有兴趣可以前往查看