整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

vue3.0系列:Vue3自定义PC端弹窗组件V3L

vue3.0系列:Vue3自定义PC端弹窗组件V3Layer

天给大家分享的是Vue3系列之自定义桌面端对话框组件v3layer

V3Layer 基于vue3.0构建的多功能PC网页端弹窗组件。拥有超过10+种弹窗类型、30+种参数配置,支持拖拽(自定义拖拽区域)、缩放、最大化、全屏及自定义置顶层叠等功能。

快速引入

在main.js中引入v3layer组件。

import { createApp } from 'vue'
import App from './App.vue'

// 引入Element-Plus组件库
import ElementPlus from 'element-plus'
import 'element-plus/lib/theme-chalk/index.css'

// 引入弹窗组件v3layer
import V3Layer from './components/v3layer'

createApp(App).use(ElementPlus).use(V3Layer).mount('#app')

v3layer支持组件式+函数式两种调用方式。

  • 组件式
<v3-layer 
    v-model="showDialog"
    title="标题内容"
    content="<div style='color:#f57b16;padding:30px;'>这里是内容信息!</div>"
    z-index="2021"
    lockScroll="false"
    xclose
    resize
    dragOut
    :btns="[
        {text: '取消', click: ()=> showDialog=false},
        {text: '确认', style: 'color:#f90;', click: handleSure},
    ]"
/>
    <template v-slot:content>这里是自定义插槽内容信息!</template>
</v3-layer>
  • 函数式
let $el=v3layer({
    title: '标题内容',
    content: '<div style='color:#f57b16;padding:30px;'>这里是内容信息!</div>', 
    shadeClose: false,
    zIndex: 2021,
    lockScroll: false,
    xclose: true,
    resize: true,
    dragOut: true,
    btns: [
        {text: '取消', click: ()=> { $el.close() }},
        {text: '确认', click: ()=> handleSure},
    ]
});

当弹窗类型为 message | notify | popover,调用方法如下:

v3layer.message({...})
v3layer.notify({...})
v3layer.popover({...})

vue3.0中提供了app.config.globalPropertiesapp.provide 两种方式挂载全局函数。

  • 如果是 app.config.globalProperties 方式创建:
// vue2中调用
methods: {
    showDialog() {
        this.$v3layer({...})
    }
}

// vue3中调用
setup() {
    // 获取上下文
    const { ctx }=getCurrentInstance()
    ctx.$v3layer({...})
}
  • 如果是 app.provide 方式创建:
// vue2中调用
methods: {
    showDialog() {
        this.v3layer({...})
    }
}

// vue3中调用
setup() {
    const v3layer=inject('v3layer')
    
    const showDialog=()=> {
        v3layer({...})
    }

    return {
        v3layer,
        showDialog
    }
}

参数配置

v3layer支持如下30+参数灵活配置,实现各种弹窗场景。

|props参数|
v-model         是否显示弹框
id              弹窗唯一标识
title           标题
content         内容(支持String、带标签内容、自定义插槽内容)***如果content内容比较复杂,推荐使用标签式写法
type            弹框类型(toast|footer|actionsheet|actionsheetPicker|android|ios|contextmenu|drawer|iframe)
layerStyle      自定义弹窗样式
icon            toast图标(loading | success | fail)
shade           是否显示遮罩层
shadeClose      是否点击遮罩时关闭弹窗
lockScroll      是否弹窗出现时将body滚动锁定
opacity         遮罩层透明度
xclose          是否显示关闭图标
xposition       关闭图标位置(left | right | top | bottom)
xcolor          关闭图标颜色
anim            弹窗动画(scaleIn | fadeIn | footer | fadeInUp | fadeInDown | fadeInLeft | fadeInRight)
position        弹出位置(auto | ['100px','50px'] | t | r | b | l | lt | rt | lb | rb)
drawer          抽屉弹窗(top | right | bottom | left)
follow          跟随元素定位弹窗(支持元素.kk #kk 或 [e.clientX, e.clientY])
time            弹窗自动关闭秒数(1、2、3)
zIndex          弹窗层叠(默认8080)
teleport        指定挂载节点(默认是挂载组件标签位置,可通过teleport自定义挂载位置) teleport="body | #xxx | .xxx"
topmost         置顶当前窗口(默认false)
area            弹窗宽高(默认auto)设置宽度area: '300px' 设置高度area:['', '200px'] 设置宽高area:['350px', '150px']
maxWidth        弹窗最大宽度(只有当area:'auto'时,maxWidth的设定才有效)
maximize        是否显示最大化按钮(默认false)
fullscreen      全屏弹窗(默认false)
fixed           弹窗是否固定
drag            拖拽元素(可定义选择器drag:'.xxx' | 禁止拖拽drag:false)
dragOut         是否允许拖拽到窗口外(默认false)
lockAxis        限制拖拽方向可选: v 垂直、h 水平,默认不限制
resize          是否允许拉伸尺寸(默认false)
btns            弹窗按钮(参数:text|style|disabled|click)
++++++++++++++++++++++++++++++++++++++++++++++
|emit事件触发|
success         层弹出后回调(@success="xxx")
end             层销毁后回调(@end="xxx")
++++++++++++++++++++++++++++++++++++++++++++++
|event事件|
onSuccess       层打开回调事件
onEnd           层关闭回调事件

v3layer弹窗模板

<template>
    <div ref="elRef" v-show="opened" class="vui__layer" :class="{'vui__layer-closed': closeCls}" :id="id">
        <!-- //蒙版 -->
        <div v-if="JSON.parse(shade)" class="vlayer__overlay" @click="shadeClicked" :style="{opacity}"></div>
        <div class="vlayer__wrap" :class="['anim-'+anim, type&&'popui__'+type, tipArrow]" :style="[layerStyle]">
            <div v-if="title" class="vlayer__wrap-tit" v-html="title"></div>
            <div v-if="type=='toast'&&icon" class="vlayer__toast-icon" :class="['vlayer__toast-'+icon]" v-html="toastIcon[icon]"></div>
            <div class="vlayer__wrap-cntbox">
                <!-- 判断插槽是否存在 -->
                <template v-if="$slots.content">
                    <div class="vlayer__wrap-cnt"><slot name="content" /></div>
                </template>
                <template v-else>
                    <template v-if="content">
                        <iframe v-if="type=='iframe'" scrolling="auto" allowtransparency="true" frameborder="0" :src="content"></iframe>
                        <!-- message|notify|popover -->
                        <div v-else-if="type=='message' || type=='notify' || type=='popover'" class="vlayer__wrap-cnt">
                            <i v-if="icon" class="vlayer-msg__icon" :class="icon" v-html="messageIcon[icon]"></i>
                            <div class="vlayer-msg__group"><div v-if="title" class="vlayer-msg__title" v-html="title"></div><div v-html="content"></div></div>
                        </div>
                        <div v-else class="vlayer__wrap-cnt" v-html="content"></div>
                    </template>
                </template>
                <slot />
            </div>
            <div v-if="btns" class="vlayer__wrap-btns">
                <span v-for="(btn,index) in btns" :key="index" class="btn" :style="btn.style" @click="btnClicked($event,index)" v-html="btn.text"></span>
            </div>
            <span v-if="xclose" class="vlayer__xclose" :class="!maximize&&xposition" :style="{'color': xcolor}" @click="close"></span>
            <span v-if="maximize" class="vlayer__maximize" @click="maximizeClicked($event)"></span>
            <span v-if="resize" class="vlayer__resize"></span>
        </div>
        <!-- 优化拖拽卡顿 -->
        <div class="vlayer__dragfix"></div>
    </div>
</template>
<script>
    import { onMounted, onUnmounted, ref, reactive, watch, toRefs, nextTick } from 'vue'
    import domUtils from './utils/dom.js'
    // 索引,蒙层控制,定时器
    let $index=0, $locknum=0, $timer={}, $closeTimer=null
    export default {
        props: {
            // ...
        },
        emits: [
            'update:modelValue'
        ],
        setup(props, context) {
            const elRef=ref(null);

            const data=reactive({
                opened: false,
                closeCls: '',
                toastIcon: {
                    // ...
                },
                messageIcon: {
                    // ...
                },
                vlayerOpts: {},
                tipArrow: null,
            })

            onMounted(()=> {
                if(props.modelValue) {
                    open();
                }
                window.addEventListener('resize', autopos, false);
            })

            onUnmounted(()=> {
                window.removeEventListener('resize', autopos, false);
                clearTimeout($closeTimer);
            })

            // 监听弹层v-model
            watch(()=> props.modelValue, (val)=> {
                // console.log('V3Layer is now [%s]', val ? 'show' : 'hide')
                if(val) {
                    open();
                }else {
                    close();
                }
            })

            // 打开弹窗
            const open=()=> {
                if(data.opened) return;
                data.opened=true;
                typeof props.onSuccess==='function' && props.onSuccess();

                const dom=elRef.value;
                // 弹层挂载位置
                if(props.teleport) {
                    nextTick(()=> {
                        let teleportNode=document.querySelector(props.teleport);
                        teleportNode.appendChild(dom);

                        auto();
                    })
                }

                callback();
            }

            // 关闭弹窗
            const close=()=> {
                if(!data.opened) return;

                let dom=elRef.value;
                let vlayero=dom.querySelector('.vlayer__wrap');
                let ocnt=dom.querySelector('.vlayer__wrap-cntbox');
                let omax=dom.querySelector('.vlayer__maximize');

                data.closeCls=true;
                clearTimeout($closeTimer);
                $closeTimer=setTimeout(()=> {
                    data.opened=false;
                    data.closeCls=false;
                    if(data.vlayerOpts.lockScroll) {
                        $locknum--;
                        if(!$locknum) {
                            document.body.style.paddingRight='';
                            document.body.classList.remove('vui__body-hidden');
                        }
                    }
                    if(props.time) {
                        $index--;
                    }
                    // 清除弹窗样式
                    vlayero.style.width=vlayero.style.height=vlayero.style.top=vlayero.style.left='';
                    ocnt.style.height='';
                    omax && omax.classList.contains('maximized') && omax.classList.remove('maximized');
                    
                    data.vlayerOpts.isBodyOverflow && (document.body.style.overflow='');

                    context.emit('update:modelValue', false);
                    typeof props.onEnd==='function' && props.onEnd();
                }, 200)
            }

            // 弹窗位置
            const auto=()=> {
                // ...

                autopos();

                // 全屏弹窗
                if(props.fullscreen) {
                    full();
                }

                // 弹窗拖动|缩放
                move();
            }

            const autopos=()=> {
                if(!data.opened) return;
                let oL, oT
                let pos=props.position;
                let isFixed=JSON.parse(props.fixed);
                let dom=elRef.value;
                let vlayero=dom.querySelector('.vlayer__wrap');

                if(!isFixed || props.follow) {
                    vlayero.style.position='absolute';
                }
                
                let area=[domUtils.client('width'), domUtils.client('height'), vlayero.offsetWidth, vlayero.offsetHeight]
                
                oL=(area[0] - area[2]) / 2;
                oT=(area[1] - area[3]) / 2;

                if(props.follow) {
                    offset();
                }else {
                    typeof pos==='object' ? (
                        oL=parseFloat(pos[0]) || 0, oT=parseFloat(pos[1]) || 0
                    ) : (
                        pos=='t' ? oT=0 : 
                        pos=='r' ? oL=area[0] - area[2] : 
                        pos=='b' ? oT=area[1] - area[3] : 
                        pos=='l' ? oL=0 : 
                        pos=='lt' ? (oL=0, oT=0) : 
                        pos=='rt' ? (oL=area[0] - area[2], oT=0) : 
                        pos=='lb' ? (oL=0, oT=area[1] - area[3]) :
                        pos=='rb' ? (oL=area[0] - area[2], oT=area[1] - area[3]) : 
                        null
                    )

                    vlayero.style.left=parseFloat(isFixed ? oL : domUtils.scroll('left') + oL) + 'px';
                    vlayero.style.top=parseFloat(isFixed ? oT : domUtils.scroll('top') + oT) + 'px';
                }
            }

            // 元素跟随定位
            const offset=()=> {
                let oW, oH, pS
                let dom=elRef.value
                let vlayero=dom.querySelector('.vlayer__wrap');

                oW=vlayero.offsetWidth;
                oH=vlayero.offsetHeight;
                pS=domUtils.getFollowRect(props.follow, oW, oH);
                data.tipArrow=pS[2];
                
                vlayero.style.left=pS[0] + 'px';
                vlayero.style.top=pS[1] + 'px';
            }

            // 最大化弹窗
            const full=()=> {
                // ...
            }

            // 恢复弹窗
            const restore=()=> {
                let dom=elRef.value;
                let vlayero=dom.querySelector('.vlayer__wrap');
                let otit=dom.querySelector('.vlayer__wrap-tit');
                let ocnt=dom.querySelector('.vlayer__wrap-cntbox');
                let obtn=dom.querySelector('.vlayer__wrap-btns');
                let omax=dom.querySelector('.vlayer__maximize');

                let t=otit ? otit.offsetHeight : 0
                let b=obtn ? obtn.offsetHeight : 0

                if(!data.vlayerOpts.lockScroll) {
                    data.vlayerOpts.isBodyOverflow=false;
                    document.body.style.overflow='';
                }
                
                props.maximize && omax.classList.remove('maximized')
                
                vlayero.style.left=parseFloat(data.vlayerOpts.rect[0]) + 'px';
                vlayero.style.top=parseFloat(data.vlayerOpts.rect[1]) + 'px';
                vlayero.style.width=parseFloat(data.vlayerOpts.rect[2]) + 'px';
                vlayero.style.height=parseFloat(data.vlayerOpts.rect[3]) + 'px';
            }

            // 拖动|缩放弹窗
            const move=()=> {
                // ...
            }

            // 事件处理
            const callback=()=> {
                // 倒计时关闭
                if(props.time) {
                    $index++
                    // 防止重复点击
                    if($timer[$index] !==null) clearTimeout($timer[$index])
                    $timer[$index]=setTimeout(()=> {
                        close();
                    }, parseInt(props.time) * 1000)
                }
            }

            // 点击最大化按钮
            const maximizeClicked=(e)=> {
                let o=e.target
                if(o.classList.contains('maximized')) {
                    // 恢复
                    restore();
                } else {
                    // 最大化
                    full();
                }
            }
            // 点击遮罩层
            const shadeClicked=()=> {
                if(JSON.parse(props.shadeClose)) {
                    close();
                }
            }
            // 按钮事件
            const btnClicked=(e, index)=> {
                let btn=props.btns[index]
                if(!btn.disabled) {
                    typeof btn.click==='function' && btn.click(e)
                }
            }
            
            return {
                ...toRefs(data),
                elRef,
                close,
                maximizeClicked,
                shadeClicked,
                btnClicked,
            }
        }
    }
</script>

v3layer支持自定义拖拽区域 (drag:'#aaa'),拖动到窗口外 (dragOut:true)。支持iframe弹窗类型 (type:'iframe')。

配置 topmost:true 当前窗口会保持置顶。

ok,基于vue3.0开发pc端对话框组件就分享到这里。希望对大家有所帮助哈!

时浏览网站的时候经常会遇到点击某些按钮会弹出登录提示或者注意事项提示的弹窗。那么这种弹窗是怎么实现的呢。实现方法有很多,不外乎就是点击事件触发相应的弹窗事件。
在这里介绍一个简易实现的方法。
首先,这里的弹窗长这样:


而原本页面长这样:


这里假定图中深绿色的按钮作为触发弹窗事件的按钮,在这里命名为btn1,然后就是弹窗的制作:
由图可看出,弹窗是基于整个屏幕的,有个灰色背景遮罩,中间有一块白色底带有图标文字说明的内容提示区,下面还有两个按钮,close是关闭弹窗的作用。了解了这些,就开始制作弹窗了:
1、html结构如下:

  1. css样式如下:

.tc{
width: 100%;
height: 100%;
position: fixed;
left: 0;
top: 0;
z-index: 9;
background: rgba(0,0,0,.5);
display: none;
}
.tc .box{
width: 670px;
height: 404px;
background: #fff;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
box-sizing: border-box;
padding-top: 54px;
}
.tc .box .icon{
width: 110px;
height: 110px;
margin: auto;
}
.tc .box .t1{
font-size: 18px;
line-height: 28px;
color: #333;
text-align: center;
box-sizing: border-box;
padding: 0 70px;
margin-top: 38px;
}
.tc .box .t2{
display: flex;
justify-content: center;
margin-top: 48px;
}
.tc .box .t2 .btn1{
width: 195px;
height: 40px;
border: none;
background: #1296db;
color: #fff;
font-size: 18px;
display: block;
cursor: pointer;
}
.tc .box .t2 .btn2{
width: 128px;
height: 40px;
border: none;
background: #295965;
color: #fff;
font-size: 18px;
display: block;
margin-left: 16px;
cursor: pointer;
}
由于在整个弹窗的父级div里加了隐藏属性:display:none; 所以在页面上是看不到弹窗的,这个时候就要开始写触发弹窗的点击事件了,上面假定的按钮是btn1,弹窗这块的父级div是 tc 。
<script>
$('.btn1').on('click',function(){
$('.tc').show();
})
</script>
这样就写好之后点击 btn1 就能显示弹窗了,现在弹窗出现的效果有了,那么点击close关闭弹窗的效果也就不远了
<script>
$('.tc .btn2').on('click',function(){
$('.tc').hide();
})
</script>
在这里把close 的类名命名为 btn2, 如上代码就实现了点击close按钮关闭弹窗的功能。
将这两个事件放在一起,节省一个script,也显得美观些就是这样
<script>
$('.btn1').on('click',function(){
$('.tc').show();
})
$('.tc .btn2').on('click',function(){
$('.tc').hide();
})
</script>
到这里一个建议的点击弹窗关闭的效果就实现了。

最近接到一个需求,需要在一些敏感操作进行前要求输入账号和密码,然后将输入的账号和密码加到接口请求的header里面。如果每个页面都去手动导入弹窗组件,在点击按钮后弹出弹窗。再拿到弹窗返回的账号密码后去请求接口也太累了,那么有没有更简单的实现方式呢?

函数式弹窗的使用场景

首先我们来看看什么是函数式弹窗?

函数式弹窗是一种使用函数来创建弹窗的技术。它可以简化弹窗的使用,只需要在需要弹窗的地方调用函数就可以了。那么这里使用函数式弹窗就能完美的解决我们的问题。

我们只需要封装一个showPasswordDialog函数,调用该函数后会弹出一个弹窗。该函数会返回一个resolve后的值就是账号密码的Promise。然后在http请求拦截器中加一个needValidatePassword字段,拦截请求时如果该字段为true,就await调用showPasswordDialog函数。拿到账号和密码后塞到请求的header里面。这样就我们就只需要在发起请求的地方加一个needValidatePassword: true配置就行了。

先来实现一个弹窗组件

这个是简化后template中的代码,和Element Plus官网中的demo代码差不多,没有什么说的。

<template>
  <el-dialog :model-value="visible" title="账号和密码" @close="handleClose">
    <!-- 省略账号、密码表单部分... -->
    <el-button type="primary" @click="submitForm()">提交</el-button>
  </el-dialog>
</template>


这个是简化后的script代码,大部分和Element Plus官网的demo代码差不多。需要注意的是我们这里将close关闭事件和confirm确认事件定义在了props中,而不是在emits中,因为后面函数式组件会通过props将这两个回调传入进来。具体的我们下面会讲。

<script setup lang="ts">
interface Props {
  visible: boolean;
  close?: ()=> void;
  confirm?: (data)=> void;
}

const props=defineProps<Props>();

const emit=defineEmits(["update:visible"]);

const submitForm=async ()=> {
  // 省略validate表单校验的代码
  // 这里的data为表单中输入的账号密码
  props.confirm?.(data);
  handleClose();
};

const handleClose=()=> {
  emit("update:visible", false);
  props.close?.();
};
</script>


再基于弹窗组件实现函数式弹窗

createApp函数和app.mount方法

createApp函数会创建和返回一个vue的应用实例,也就是我们平时常说的app,该函数接受两个参数。第一个参数为接收一个组件,也就是我们平时写的vue文件。第二个参数为可选的对象,这个对象会传递给第一个参数组件的props。

举个例子:

import MyComponent from "./MyComponent"

const app=createApp(MyComponent, {
  visible: true
})


在这个例子中我们基于MyComponent组件生成了一个app应用实例,如果MyComponent组件的props中有定义visible,那么visible就会被赋值为true。

调用createApp函数创建的这个应用实例app实际就是在内存中创建的一个对象,并没有渲染到浏览器的dom上面。这个时候我们就要调用应用实例app暴露出来的mount方法将这个组件挂载到真实的dom上面去。mount方法接收一个“容器”参数,用于将组件挂载上去,可以是一个实际的 DOM 元素或是一个 CSS 选择器字符串。比如下面这个例子是将组件挂载到body上面:

app.mount(document.body)


app提供了很多方法和属性,详见 vue官网。

封装一个showPasswordDialog函数

首先我们来看看期望如何使用showPasswordDialog函数?

我们希望showPasswordDialog函数返回一个Promise,resolve的值就是弹窗中输入的表单。例如,我们可以使用以下代码使用showPasswordDialog函数:

try {
  // 调用这个就会弹出弹窗
    const res: RuleForm=await showPasswordDialog();
    // 这个res就是输入的账号密码
    console.log("res", res);
  } catch (error) {
    console.log(error);
  }


具体如何实现showPasswordDialog函数?

经过上面的介绍我们知道了可以调用createApp函数传入指定组件生成app,然后使用app.mount方法将这个组件挂载到指定的dom上面去。那么现在思路就清晰了,我们只需要将我们前面实现的弹窗组件作为第一个参数传递给createApp函数。第二个参数传入一个对象给弹窗组件的props,用以控制打开弹窗和注册弹窗关闭和确认的事件回调。下面是实现的showPasswordDialog函数

import { App, createApp } from "vue";
import PasswordDialog from "./index.vue";
// 这个index.vue就是我们前面实现的弹窗组件

export async function showPasswordDialog(): Promise<RuleForm> {
  return new Promise((resolve, reject)=> {
    let mountNode=document.createElement("div");
    let dialogApp: App<Element> | undefined=createApp(PasswordDialog, {
      visible: true,
      close: ()=> {
        if (dialogApp) {
          dialogApp.unmount();
          document.body.removeChild(mountNode);
          dialogApp=undefined;
          reject("close");
        }
      },
      confirm: (res: RuleForm)=> {
        resolve(res);
        dialogApp?.unmount();
        document.body.removeChild(mountNode);
        dialogApp=undefined;
      },
    });
    document.body.appendChild(mountNode);
    dialogApp.mount(mountNode);
  });
}


在这个showPasswordDialog函数中我们先创建了一个div元素,再将弹窗组件传递给了createApp函数生成一个dialogApp的实例。然后将创建的div元素挂载到body上面,再调用mount方法将我们的弹窗组件挂载到创建的div元素上,至此我们实现了通过函数式调用将弹窗组件渲染到body中。

现在我们再来看看传入到createApp函数的第二个对象参数,我们给这个对象分别传入了visible属性、close和confirm回调方法,分别会赋值给弹窗组件props中的visible、close、confirm。

弹窗组件中触发关闭事件时会调用props.close?.(),实际这里就是在调用我们传入的close回调方法。在这个方法中我们调用了实例的unmount方法卸载组件,然后将创建的弹窗组件dom从body中移除,并且返回一个reject的Promise。

当我们将账号和密码输入完成后,会调用props.confirm?.(ruleForm),这里的ruleForm就是我们表单中的账号和密码。实际这里就是在调用我们传入的confirm回调方法,接下来同样也是卸载组件和移除弹窗组件生成的dom,并且返回一个resolve值为账号密码表单的Promise。

总结

这篇文章主要介绍了如何创建函数式弹窗:

  1. 创建一个常规的弹窗组件,有点不同的是close和confirm事件不是定义在emits中,而是作为回调定义在props中。
  2. 创建一个showPasswordDialog函数,该函数返回一个Promise,resolve的值就是我们弹窗中输入的表单。
  3. 调用createApp函数将步骤一的弹窗组件作为第一个参数传入,并且第二个对象参数中传入属性visible为true打开弹窗和注入弹窗close关闭和confirm确认的回调。
  4. 使用者只需await调用showPasswordDialog就可以打开弹窗和拿到表单中填入的账号和密码。


作者:欧阳码农
链接:https://juejin.cn/post/7322229620391673908