API 设计:
// 使用方式1
<Dialog
:visible="true"
:closeOnClickOverlay="true" // 点击空白区域是否关闭 Dialog
title="标题"
@confirm="fn1"
@cancel="fn2"
>
// 使用方式2
openDialog({
title: "示例2-测试",
content: "你好",
closeOnClickOverlay: false,
ok: () => { console.log('okkkk-')},
cancel: () =>{ console.log('cancel-----')},
})
注意几个要点:
- Dialog 组成部分:背景遮罩层 + Dialog 组件(右上角x,ok 和 cancel 按钮,标题和 content部分)
- z-index 的设置,遮罩层要在 Dialog 组件下面
- 右上角 x 的css 画法,使用 伪元素的方式,做成 2 个扁的 div,旋转角度即可。
<template>
<div class="my-dialog-overlay"></div>
<div class="my-dialog-wrapper">
<div class="my-dialog">
<header>标题<span class="my-dialog-close"></span></header>
<main>
<p>第一行</p>
<p>第二行</p>
</main>
<footer>
<Button>OK</Button>
<Button>Cancel</Button>
</footer>
</div>
</div>
</template>
<script>
import Button from "./Button.vue";
export default {
components: {
Button,
},
};
</script>
<style lang="scss">
$radius: 4px;
$border-color: #d9d9d9;
.my-dialog {
background: white;
border-radius: $radius;
box-shadow: 0 0 3px fade_out(black, 0.5);
min-width: 15em;
max-width: 90%;
&-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: fade_out(black, 0.5);
z-index: 10;
}
&-wrapper {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 11;
}
> header {
padding: 12px 16px;
border-bottom: 1px solid $border-color;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 20px;
}
> main {
padding: 12px 16px;
}
> footer {
border-top: 1px solid $border-color;
padding: 12px 16px;
text-align: right;
}
&-close {
position: relative;
display: inline-block;
width: 16px;
height: 16px;
cursor: pointer;
&::before,
&::after {
content: "";
position: absolute;
height: 1px;
background: black;
width: 100%;
top: 50%;
left: 50%;
}
&::before {
transform: translate(-50%, -50%) rotate(-45deg);
}
&::after {
transform: translate(-50%, -50%) rotate(45deg);
}
}
}
</style>
注意几个要点:
- ref 的学习和使用:可以将其看做一个容器,包裹一个值,取值的方式要取容器的 value,即
x.value
<!-- 父组件 -->
<template>
<div>
<h1>Dialog 示例</h1>
<Button @click="toggleDialog">切换Dialog</Button>
<Dialog :visible="x" />
</div>
</template>
<script>
import Dialog from '../lib/Dialog.vue';
import Button from '../lib/Button.vue';
import { ref } from 'vue';
export default {
components: {
Dialog,
Button,
},
setup() {
const x = ref(false);
const toggleDialog = () => {
x.value = !x.value;
}
return { x, toggleDialog };
}
}
</script>
<!-- 子组件 -->
<template v-if="visible">
<!-- ... -->
</template>
<script>
export default {
props: {
visible: {
type: Boolean,
default: false,
}
},
components: {
Button,
},
}
</script>
v-model:event="xxx"
的使用,下面 2 种写法是等价的context.emit("update:visible", false);
的使用,父元素使用v-model:event
直接可以获取值双向绑定进行相应
<!-- 父组件 -->
<!-- <Dialog :visible="x" @update:visible="x = $event" /> -->
<Dialog
v-model:visible="x"
:closeOnClickOverlay="true"
:confirm="onOk"
:cancel="onCancel"
/>
<!-- 子组件 -->
<script>
setup(props, context) {
const close = ()=> {
// context.emit("update", false)
context.emit("update:visible", false);
}
const onOk = () => {
console.log('ok')
return false;
}
const onCancel = () => {
console.log('cancel');
}
return { close, onOk, onCancel}
}
</script>
- 外层控制 点击 ok 是否立即关闭 Dialog。这里考虑一种业务场景,即 Dialog 的 content 是一个表单,当表单内容没有填写完成,点击确定不允许关闭。
- 可能的做法是,子元素会派发事件,让父元素响应为 true 或者 false,然后进行关闭。但是事实上,事件 是没有返回值的。
- 正确的做法是,将控制权(控制关闭操作)交给 父元素,onOk 返回为 true 方可关闭。
- 下面,使用了 typescript 的
?.
语法
<footer>
<Button @click="confirm">OK</Button>
<Button @click="cancel">Cancel</Button>
</footer>
<script>
// 子元素
const confirm = () => {
// 这里不应该用事件 的方式,否则关闭不好处理,因为事件没有返回值。场景:dialog 内有表单,没有填完不允许关闭
// const result = context.emit("confirm")
// if (result) close()
// props.confirm && props.confirm() !== false
if (props.confirm?.() !== false) {
close()
}
}
</script>
这里比较简单,只需要在 遮罩层添加一个事件 关闭 Dialog 即可。
<div class="my-dialog-overlay" @click="onClickOverlay"></div>
<script>
// ...
props: {
closeOnClickOverlay: {
type: Boolean,
default: true,
},
}
setup(props, context) {
const close = ()=> {
context.emit("update:visible", false);
}
// 通过外面判断是否点击 背景色关闭 dialog
const onClickOverlay = () => {
if (props.closeOnClickOverlay) {
close()
}
}
return { onClickOverlay }
}
</script>
这里有 2 种方式实现支持传入的 title
- 父元素使用
<Dialog :title="测试标题" />
传给子组件,缺点是无法传入 html 元素或者组件 - 使用插槽传入,可以传入组件
- 当使用多个 插槽 的时候,需要使用到 具名插槽,注意 具名插槽的使用方法:
v-slot:name
<!-- 方式1 -->
<!-- 父组件 -->
<Dialog :title="测试标题" />
<!-- 子组件 -->
<header>{{title}}<span @click="close" class="my-dialog-close"></span></header>
<script>
export default {
props: {
title: {
type: String,
defalut: "提示"
},
}
}
</script>
<!-- 方式2 slot 和 具名 slot -->
<!-- 父组件 -->
<Dialog :visible="xxx">
<template v-slot:content>
<p>你好测试,你好测试</p>
<p>你好测试,你好测试</p>
</template>
<template v-slot:title>
<strong>我是标题</strong>
</template>
</Dialog>
<!-- 子组件 -->
<header><slot name="title" /><span @click="close" class="my-dialog-close"></span></header>
<main>
<slot name="content" />
</main>
如下的代码 .test2
,可能会出现这样的 bug:即 Dialog 的显示会再 新的 div 下面,导致无法点击到 Dialog,问题在于 z-index
。
前面的 代码我们知道,Dialog的 z-index
是 10,但是这个 z-index
的 10 是在 test1
里面的 10,其权重是要小于 后者 test2
的 z-index: 2
子元素的 z-index: 1
的。
做个不恰当的比喻:清华大学的最后一名,对比 某三本 院校的第一名,是要更强的。前面的 权重也是如此,外层元素的 z-index
决定了其地位,而 Dialog 所在的外层元素的 z-index
权重要低于 其他元素的值,因此里面的元素的权重问题可能导致元素被覆盖
<div class="test1" style="position: relative; z-index: 1;">
<Dialog />
</div>
<div class="test2" style="position: relative; width: 500px; height: 500px; background: red; z-index: 2;">测试层叠关系</div>
解决办法: 使用 Teleport ,意思是将 其包裹的元素,转移到 其他的位置,如下代码中,是将其包裹的元素转移到 body
目录下
<template>
<template v-if="visible">
<Teleport to="body">
<div class="my-dialog-overlay" @click="onClickOverlay"></div>
<div class="my-dialog-wrapper">
<div class="my-dialog">
<header><slot name="title" /><span @click="close" class="my-dialog-close"></span></header>
<main>
<slot name="content" />
</main>
<footer>
<Button @click="confirm">OK</Button>
<Button @click="cancel">Cancel</Button>
</footer>
</div>
</div>
</Teleport>
</template>
</template>
我们试想,是否可以通过如下 js 代码,打开 Dialog 呢?
openDialog({
title: "示例2-测试",
content: "你好",
closeOnClickOverlay: false,
ok: () => { console.log('okkkk-')},
cancel: () =>{ console.log('cancel-----')},
})
答案是可以的,我们写一个 ts, 代码如下:
// 使用
const showDialog = () => {
openDialog({
title: "示例2-测试",
content: "你好",
closeOnClickOverlay: false,
ok: () => { console.log('okkkk-')},
cancel: () =>{ console.log('cancel-----')},
})
}
// openDialog.ts
import Dialog from './Dialog.vue'
import { createApp, h } from 'vue';
export const openDialog = (options) => {
const { title, content, ok, cancel, closeOnClickOverlay } = options;
const div = document.createElement("div");
document.body.appendChild(div)
const close = () => {
// @ts-ignore
app.unmount(div)
div.remove()
}
const app = createApp({
render() {
return h(Dialog, {
visible: true,
'onUpdate:visible': (newVisible) => {
if (newVisible === false) {
close()
}
},
confirm: ok,
cancel,
closeOnClickOverlay,
}, {
title,
content
})
}
});
app.mount(div);
}
解释下:
- 原理就是: 创建一个 dom (包裹了 Dialog 组价的 dom),挂在到 页面中
- createApp: 返回一个提供 context 的 Vue 实例
- render: 返回一个 virtual node,
- h: 接受的参数分别为 - 组件实例,props,slot
关于 visible 问题:
上面,我们没有使用 visible 的方式来控制 Dialog 的关闭和打开,而是使用组件的 挂载和卸载的方式来表示 visible 的 true 和 false