系列文章目录
第一章 Vue3封装全局函数式组件
第二章 Vue3封装组件(带回调事件)
文章目录
前言
前面的文章介绍了封装 Dialog
弹出框的组件,同时实现了弹窗的链式调用。不过需求总是在变化的,这次需求要在显示了弹窗的基础上不断往上面再显示弹窗,所以来分享一下自己改善后的封装方法,总结一下最新的心得体会。本以为上一篇文章已经结束这个课题了,没想到加上这篇可以成为一个系列了😂,以后还会继续更新,如有纰漏,敬请指正。
一、使用方式
全局封装的组件,在vue文件还有js文件当然都是可以使用的,这一点在第一章已经介绍了。下面展示在vue文件内使用的方式,在需要弹窗的地方调用proxy.$popup()
方法即可,随便写了写,可以对照下面效果展示来看。
import { getCurrentInstance } from 'vue';
// 获取当前实例,相当于 vue2 中的this
const { proxy } = getCurrentInstance();
const popupFn = () => {
proxy.$popup({
content:
'银河广袤寂静,也孕育了无数美好。此刻,如果您仰望星空,看见那深邃黑暗中若隐若现的点点微芒,便会知晓,那高远无穷的璀璨正是我们共同的期冀。',
successText: '愿此行,终抵群星',
// 点击遮罩层是否关闭弹窗,默认false
closeByOverlay: true
});
};
const openPopup = () => {
proxy.$popup({
content: '生命远不止连轴转和忙到极限,人类的体验远比这辽阔、丰富得多。',
holdOnFn: popupFn
});
};
二、效果展示
点击“弹窗”调用的是 openPopup 方法。
三、思路及实现方式
上面这个动图展示了其运行的原理,做出来之后我回看一下觉得其实细节还真不少,我将一步步介绍自己的思路及实现方法,完整代码在最后贴上,可供大家参考。
1、保留原有弹窗基础上加入新弹窗
vue文件组件里面主要区别在于点击确认按钮时如果参数holdOnFn存在值的话就不执行hidden,而是执行传进来的方法,这样旧弹窗就不会被关闭了,然后在弹窗上加个关闭按钮让其可以调用hidden方法去关闭。js文件主要区别就在于不能使用单例模式了,因为要创建多个弹窗。
// 省略无关代码
const props = defineProps({
holdOnFn: {
type: Function
}
});
// 隐藏弹窗方法
const hidden = () => {
isShow.value = false;
props.hide();
};
// 成功按钮
const successHandle = () => {
props.successBtn();
if (!props.holdOnFn) {
nextTick(() => {
hidden();
});
} else {
props.holdOnFn();
}
};
2、弹窗之间的层级问题
这里给遮罩层及弹窗加入样式,用fixed定位并且在外层记录当前z-index值,保证新弹窗的值永远大于旧弹窗,关键代码节选:
// 这里数值大一些保证弹窗在所有组件最上方
let overlayNodeZIndex = 3000;
// 创建遮罩层
const createOverlay = () => {
const overlayNode = document.createElement('div');
overlayNode.className = 'my-overlay';
document.body.appendChild(overlayNode);
overlayNode.style.zIndex = overlayNodeZIndex;
return overlayNode;
};
const popup = (options = {}) => {
// 创建遮罩层
let overlayNode = null;
overlayNode = createOverlay();
// 创建弹窗元素节点
const rootNode = document.createElement('div');
// 在body标签内部插入此元素
document.body.appendChild(rootNode);
rootNode.className = `my-dialog`;
rootNode.style.position = 'fixed';
rootNode.style.zIndex = overlayNodeZIndex;
overlayNodeZIndex += 1;
// 创建应用实例(第一个参数是根组件。第二个参数可选,它是要传递给根组件的 props)
const app = createApp(Popup, {
...options
});
return app.mount(rootNode);
};
3、弹窗关闭所有
有这么一个场景,当用户点了第二个或以上的弹窗之后需要一次性把之前弹窗都关闭掉,所以有了这么一个hideAll方法。那么调用proxy.$popupHide()
则可以关闭所有弹窗了。
// 定义临时数组存放关闭弹窗的方法
let hideArr = [];
const popup = (options = {}) => {
// 省略无关代码
const hide = function () {
// 关闭一个弹窗则从hideArr中取出一个
hideArr.pop();
};
// 每创建一个弹窗将其卸载方法存入hideArr数组中
hideArr.push(hide);
const app = createApp(Popup, {
...options,
hide
});
return app.mount(rootNode);
};
const okFun = (options = {}) => {
popup(options).show();
};
// 隐藏所有弹窗,遍历然后调用
const hideAll = () => {
hideArr.forEach(e => {
e();
});
};
popup.install = app => {
app.config.globalProperties.$popup = options => okFun(options);
app.config.globalProperties.$popupHide = hideAll;
};
popup.show = options => okFun(options);
popup.hideAll = hideAll;
export default popup;
4、弹窗滚动相关问题
这个问题其实在第二章也有探讨过,采取了文档固定定位加记录高度的方法实现固定背景层,避免滚动穿透问题,不过由于现在弹窗不止一个了,文档上的fixed
定位加上的时机以及去掉的时机就得考虑好,所以写法要相应改进一下。正好上面hideAll
方法存下来的数组里面方法的个数正好代表着目前弹窗的个数,所以可以根据数组长度做出判断。
let hideArr = [];
let appScrollTop = 0;
let bodyTop = 0;
// 创建弹窗调用的方法
const popup = (options = {}) => {
// 禁止app滚动逻辑
const appDOM = document.querySelector('#app');
// 此处hideArr用作弹窗计数
if (hideArr.length === 0) {
// 记录滚动高度
appScrollTop = appDOM.scrollTop;
// 记录文档高度,兼容移动端浏览器,防止偏移
bodyTop = document.scrollingElement.scrollTop;
appDOM.style.position = 'fixed';
appDOM.style.bottom = appScrollTop + 'px';
// 兼容ios手机fixed定位不生效,得加overflow
appDOM.style.overflow = 'visible';
}
// 关闭弹窗的方法
const hide = function () {
// 解除app滚动逻辑
// 关闭一个弹窗则从hideArr中取出一个
hideArr.pop();
// 此处hideArr用作弹窗计数
if (hideArr.length === 0) {
// 解除app元素滚动,移除样式
const appDOM = document.querySelector('#app');
appDOM.style.removeProperty('position');
appDOM.style.removeProperty('bottom');
appDOM.style.removeProperty('overflow');
// 恢复文档滚动位置
appDOM.scrollTop = appScrollTop;
document.scrollingElement.scrollTop = bodyTop;
// 恢复默认值
overlayNodeZIndex = 3000;
appScrollTop = 0;
bodyTop = 0;
}
};
// 每创建一个弹窗将其卸载方法存入hideArr数组中
hideArr.push(hide);
const app = createApp(Popup, {
...options,
hide
});
return app.mount(rootNode);
};
5、遮罩层加上动画效果
①:创建遮罩层的时候
const createOverlay = () => {
const overlayNode = document.createElement('div');
overlayNode.className = 'my-overlay';
// 添加初始显示的过渡效果
overlayNode.classList.add('my-overlay-enter-from');
// 在body标签内部插入此元素
document.body.appendChild(overlayNode);
const remove = () => {
overlayNode.classList.remove('my-overlay-enter-from');
};
// requestAnimationFrame在浏览器下一次重绘之前执行
requestAnimationFrame(remove);
overlayNode.style.zIndex = overlayNodeZIndex;
return overlayNode;
};
②:关闭弹窗的时候
const hide = function () {
// 显示移除动画
overlayNode.classList.add('my-overlay-leave-to');
setTimeout(() => {
overlayNode.classList.remove('my-overlay-leave-to');
// 移除遮罩层
deleteOverlay(overlayNode);
}, 300);
}
③:弹窗遮罩层样式
.my-overlay {
position: fixed;
top: 0;
left: 0;
z-index: 100;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
transition: opacity 0.3s ease-out;
}
.my-overlay-enter-from,
.my-overlay-leave-to {
opacity: 0;
}
6、配置点击遮罩层关闭弹窗
根据传入参数对象的closeByOverlay属性,监听遮罩层点击事件即可。
const props = defineProps({
// 默认点击遮罩层不关闭
closeByOverlay: {
type: Boolean,
default: false,
},
overlayNode: {
type: Object,
},
})
// 显示弹窗方法
const show = () => {
isShow.value = true;
if (props.closeByOverlay) {
props.overlayNode.addEventListener('click', hidden);
}
};
四、完整代码环节
1、vue文件
<template>
<transition name="toast" @after-leave="onAfterLeave">
<div class="toast" v-if="isShow" :style="{ width: toastWidth }" :class="{ bgc: needBgc }">
<div v-if="showCancel || props.holdOnFn" class="cancel" @click="hidden"></div>
<div v-if="content" class="content" :style="{ textAlign }">
{{ content }}
</div>
<div class="operation" v-if="type === 'confirm'">
<div class="confirm" @click="successHandle">{{ successText }}</div>
</div>
<div class="operation" v-if="type === 'confirmAndcancel'">
<div class="close" @click="cancelHandle">{{ cancelText }}</div>
<div class="confirm" @click="successHandle">{{ successText }}</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue';
const props = defineProps({
content: {
type: String,
default: ''
},
width: {
default: 640
},
textAlign: {
type: String,
default: 'center'
},
type: {
type: String,
default: 'confirm'
},
hide: {
type: Function
},
successText: {
type: String,
default: '确认'
},
cancelText: {
type: String,
default: '取消'
},
successBtn: {
type: Function
},
cancelBtn: {
type: Function
},
// 是否需要右上角关闭按钮
showCancel: {
type: Boolean,
default: false
},
// 是否需要背景
needBgc: {
type: Boolean,
default: true
},
// 传入调用方法
holdOnFn: {
type: Function
},
// 默认点击遮罩层不关闭
closeByOverlay: {
type: Boolean,
default: false
},
overlayNode: {
type: Object
}
});
// 弹窗控制
const isShow = ref(false);
// 宽度控制
const toastWidth = computed(
() =>
((parseInt(props.width.toString()) / 750) * document.documentElement.clientWidth).toFixed(3) +
'px'
);
// 显示弹窗方法
const show = () => {
isShow.value = true;
if (props.closeByOverlay) {
props.overlayNode.addEventListener('click', hidden);
}
};
// 将方法暴露出去
defineExpose({
show
});
// 隐藏弹窗方法
const hidden = () => {
isShow.value = false;
props.hide();
if (props.closeByOverlay) {
props.overlayNode.removeEventListener('click', hidden);
}
};
// 由于遮罩层需要消失动画,两个动画效果必须同时触发,所以关闭时直接调用hide方法
const onAfterLeave = () => {
// props.hide();
};
const successHandle = () => {
props.successBtn();
if (!props.holdOnFn) {
nextTick(() => {
hidden();
});
} else {
props.holdOnFn();
}
};
const cancelHandle = () => {
props.cancelBtn();
nextTick(() => {
hidden();
});
};
</script>
<style lang="scss" scoped>
@mixin expand-btn {
&::before {
content: '';
position: absolute;
top: -10px;
right: -10px;
bottom: -10px;
left: -10px;
}
}
.toast {
position: fixed;
top: 45%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 99;
text-align: center;
.cancel {
background: url('../../assets/images/quxiao@2x.png') no-repeat center / contain;
position: absolute;
top: 10px;
right: 10px;
width: 15px;
height: 15px;
@include expand-btn;
}
.content {
color: #ffcc99;
max-height: 50vh;
overflow-y: scroll;
}
.operation {
position: relative;
display: flex;
justify-content: space-around;
align-items: center;
margin-top: 15px;
&::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
bottom: -50%;
left: -50%;
border-top: 1px solid #666;
transform: scale(0.5);
}
.close {
position: relative;
width: 100%;
padding: 10px 0;
color: rgba($color: #ffcc99, $alpha: 0.9);
&::after {
content: '';
position: absolute;
top: -50%;
right: -50%;
bottom: -50%;
left: -50%;
border-right: 1px solid #666;
transform: scale(0.5);
}
}
.confirm {
width: 100%;
padding: 10px 0;
color: rgba($color: #ffcc99, $alpha: 0.9);
}
}
}
.bgc {
background: #333333;
border-radius: 20px;
padding: 25px 20px 0;
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease-out;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
</style>
2、js文件
import { createApp } from 'vue';
import Popup from './Popup.vue';
let overlayNodeZIndex = 3000;
let hideArr = [];
let appScrollTop = 0;
let bodyTop = 0;
// 创建遮罩层
const createOverlay = () => {
const overlayNode = document.createElement('div');
overlayNode.className = 'my-overlay';
// 添加初始显示的过渡效果
overlayNode.classList.add('my-overlay-enter-from');
// 在body标签内部插入此元素
document.body.appendChild(overlayNode);
const remove = () => {
overlayNode.classList.remove('my-overlay-enter-from');
};
// requestAnimationFrame在浏览器下一次重绘之前执行
requestAnimationFrame(remove);
overlayNode.style.zIndex = overlayNodeZIndex;
return overlayNode;
};
// 移除遮罩层
const deleteOverlay = overlayNode => {
if (overlayNode) {
document.body.removeChild(overlayNode);
}
};
const popup = (options = {}) => {
// 禁止app滚动
const appDOM = document.querySelector('#app');
// 此处hideArr用作弹窗计数
if (hideArr.length === 0) {
// 记录滚动高度
appScrollTop = appDOM.scrollTop;
// 记录文档高度,兼容移动端浏览器,防止偏移
bodyTop = document.scrollingElement.scrollTop;
appDOM.style.position = 'fixed';
appDOM.style.bottom = appScrollTop + 'px';
// 兼容ios手机fixed定位不生效,得加overflow
appDOM.style.overflow = 'visible';
}
// 创建遮罩层
let overlayNode = createOverlay();
// 创建弹窗元素节点
const rootNode = document.createElement('div');
// 在body标签内部插入此元素
document.body.appendChild(rootNode);
rootNode.className = `my-dialog`;
rootNode.style.position = 'fixed';
rootNode.style.zIndex = overlayNodeZIndex;
overlayNodeZIndex += 1;
const hide = function () {
// 显示移除动画
overlayNode.classList.add('my-overlay-leave-to');
setTimeout(() => {
overlayNode.classList.remove('my-overlay-leave-to');
deleteOverlay(overlayNode);
}, 300);
// 解除body滚动
// 关闭一个弹窗则从hideArr中取出一个
hideArr.pop();
// 此处hideArr用作弹窗计数
if (hideArr.length === 0) {
// 解除app元素滚动,移除样式
const app = document.querySelector('#app');
app.style.removeProperty('position');
app.style.removeProperty('bottom');
app.style.removeProperty('overflow');
// 恢复文档滚动位置
app.scrollTop = appScrollTop;
document.scrollingElement.scrollTop = bodyTop;
// 恢复默认值
overlayNodeZIndex = 3000;
appScrollTop = 0;
bodyTop = 0;
}
// 卸载已挂载的应用实例
setTimeout(() => {
app.unmount();
document.body.removeChild(rootNode);
}, 300);
};
// 每创建一个弹窗将其卸载方法存入hideArr数组中
hideArr.push(hide);
// 创建应用实例(第一个参数是根组件。第二个参数可选,它是要传递给根组件的 props)
const app = createApp(Popup, {
...options,
hide,
overlayNode
});
// 将应用实例挂载到创建的 DOM 元素上
return app.mount(rootNode);
};
// 显示弹窗
const okFun = (options = {}) => {
return new Promise((resolve, reject) => {
options.successBtn = () => {
resolve();
};
options.cancelBtn = () => {
reject();
};
popup(options).show();
});
};
// 隐藏所有弹窗
const hideAll = () => {
hideArr.forEach(e => {
e();
});
};
popup.install = app => {
// 注册全局属性,相当于 Vue2 的this
app.config.globalProperties.$popup = options => okFun(options);
app.config.globalProperties.$popupHide = hideAll;
};
// 定义两个方法用于js文件直接调用
popup.show = options => okFun(options);
popup.hideAll = hideAll;
export default popup;
总结
以上就是全部内容,本文通过封装可多次弹出的 Dialog 组件进一步探索了 Vue3 函数式组件的封装方法。暂时我把自己遇到的问题都列举出来了,但是可能还有些地方考虑不够周全,代码不够整洁优雅,我相信还有更好的实现方式,这也是我一直追求的,与君共勉!
如果此篇文章对您有帮助,欢迎您【点赞】、【收藏】!也欢迎您【评论】留下宝贵意见,共同探讨一起学习~