组合实战:Modal 与 Card
插槽与组件组合 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
把插槽用到真实组件上:设计一个 Modal(遮罩、ESC 关闭、焦点管理、具名插槽)和一个 Card(头部/内容/底部三区),理解「容器组件」的设计取舍。这一节也是练习 C64 的完整思路。
问题场景
项目里要弹窗,你写了一个组件,把标题、内容、按钮全做成 props。第二个页面要弹一个「带搜索框的选择器」,第三个页面要弹「富文本编辑器」——props 方案彻底不够用了。同时新问题冒出来:弹窗打开时背景还能滚动、ESC 不关闭、点遮罩不关闭、焦点留在背后的按钮上。
核心解释
Modal 的职责分两半:行为(打开关闭、遮罩、ESC、焦点、滚动锁定)由组件封装;内容(标题、正文、按钮)由调用方通过插槽提供。
<!-- components/BaseModal.vue -->
<script setup>
import { onBeforeUnmount, onMounted, watch } from "vue";
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue"]);
function close() {
emit("update:modelValue", false);
}
function onKeydown(e) {
if (e.key === "Escape") close();
}
// 打开时锁定背景滚动 + 监听 ESC
watch(
() => props.modelValue,
(open) => {
document.body.style.overflow = open ? "hidden" : "";
}
);
onMounted(() => window.addEventListener("keydown", onKeydown));
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKeydown);
document.body.style.overflow = "";
});
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="modelValue" class="overlay" @click.self="close">
<div class="dialog" role="dialog" aria-modal="true" :aria-label="title || '对话框'">
<header class="dialog-header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<button class="close" aria-label="关闭" @click="close">×</button>
</header>
<div class="dialog-body">
<slot />
</div>
<footer v-if="$slots.footer" class="dialog-footer">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center; z-index: 50;
}
.dialog { background: #fff; border-radius: 12px; width: min(560px, 92vw); max-height: 85vh; display: flex; flex-direction: column; }
.dialog-body { padding: 16px; overflow: auto; }
.dialog-header, .dialog-footer { display: flex; align-items: center; gap: 8px; padding: 12px 16px; }
.dialog-footer { justify-content: flex-end; border-top: 1px solid #eee; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
四个关键设计:
v-model控制开关:modelValue+update:modelValue,调用方写<BaseModal v-model="open">。<Teleport to="body">:把弹窗渲染到 body 下,避免被父级overflow: hidden或transform裁剪/影响定位。@click.self:只有点遮罩本身才关闭,点弹窗内容不触发(事件冒泡到遮罩时 target 不是遮罩)。- 行为清理:ESC 监听和滚动锁定都要在卸载时恢复,否则组件销毁后页面再也滚不动。
Card 更简单,是纯容器组件:
<!-- components/BaseCard.vue -->
<template>
<article class="card">
<header v-if="$slots.header" class="card-header"><slot name="header" /></header>
<div class="card-body"><slot /></div>
<footer v-if="$slots.footer" class="card-footer"><slot name="footer" /></footer>
</article>
</template>
v-if="$slots.header" 让没传头部时不渲染空容器——这是容器组件的常见细节。
示例拆解
用 Modal + Card 组合一个「确认删除」流程:
<script setup>
import { ref } from "vue";
import BaseModal from "./components/BaseModal.vue";
import BaseCard from "./components/BaseCard.vue";
const open = ref(false);
const target = ref(null);
function askRemove(item) {
target.value = item;
open.value = true;
}
function confirmRemove() {
// 这里执行真正的删除
open.value = false;
}
</script>
<template>
<BaseCard>
<template #header><h3>课程列表</h3></template>
<ul>
<li v-for="c in ['V01', 'V02', 'V03']" :key="c">
{{ c }} <button @click="askRemove(c)">删除</button>
</li>
</ul>
</BaseCard>
<BaseModal v-model="open" title="确认删除">
<p>确定要删除 {{ target }} 吗?此操作不可撤销。</p>
<template #footer>
<button @click="open = false">取消</button>
<button class="danger" @click="confirmRemove">删除</button>
</template>
</BaseModal>
</template>
验证点:打开后背景不能滚动、按 ESC 关闭、点遮罩关闭、点弹窗内部不关闭。这四个行为就是「容器组件」该替你兜住的事。
动手任务
- 完成 BaseModal,逐条验证四个行为(滚动锁定、ESC、遮罩点击、内部点击不关闭)。
- 给 Modal 加焦点管理:打开时聚焦关闭按钮,关闭后焦点回到触发按钮(提示:
ref+nextTick)。 - 用 BaseCard + BaseModal 做一个「新建任务」表单弹窗,表单内容通过默认插槽传入。
常见错误
- 忘记 Teleport:弹窗被父容器的
overflow: hidden裁掉,或position: fixed相对错误的祖先定位。 - 遮罩用
@click不加.self:点弹窗内容也关闭,用户填到一半的表单丢失。 - 卸载时不恢复
body.overflow:路由切换后页面无法滚动,刷新才恢复。 - 把业务按钮写死在 Modal 里:组件只能用于一种场景。按钮放
#footer插槽。
检查与复盘
- 能独立写出带 Teleport、ESC、滚动锁定、遮罩关闭的 Modal
- 能解释
@click.self和@click的行为差异 - 能说出容器组件「封装行为、暴露结构」的设计原则
参考资料
- Vue 官方文档「Teleport」:传送门的使用场景
- Vue 官方文档「插槽」:具名插槽与
$slots判断 - Vue 官方文档「Transition」:弹窗过渡
读完这一课了?
标记完成后,工作台会安排接下来的学习。