用 Vue3.0 写过组件吗?如果想实现一个 Modal 你会怎么设计?
问题解析
这是一道考察组件设计能力和 Vue3 实际应用经验的题目。面试官希望看到候选人对组件封装、API 设计、以及 Vue3 新特性(如 Teleport、Composition API)的掌握程度。
核心概念
Modal 组件的核心要素
┌─────────────────────────────────────────┐
│ Modal 组件设计要点 │
├─────────────┬─────────────┬─────────────┤
│ 功能设计 │ 技术实现 │ 使用方式 │
├─────────────┼─────────────┼─────────────┤
│ • 遮罩层 │ • Teleport │ • 组件式 │
│ • 标题栏 │ • Transition│ • API 调用 │
│ • 内容区 │ • v-model │ • 指令式 │
│ • 操作按钮 │ • 插槽 │ │
│ • 可配置 │ • 渲染函数 │ │
└─────────────┴─────────────┴─────────────┘
详细解答
一、基础组件设计
1. 组件结构
<!-- Modal.vue -->
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="modelValue" class="modal-overlay" @click="handleMaskClick">
<div class="modal-container" @click.stop>
<!-- 头部 -->
<div class="modal-header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<button v-if="showClose" class="modal-close" @click="handleClose">×</button>
</div>
<!-- 内容 -->
<div class="modal-body">
<slot>
<component :is="content" v-if="isVNode(content)" />
<div v-else v-html="content"></div>
</slot>
</div>
<!-- 底部 -->
<div class="modal-footer">
<slot name="footer">
<button @click="handleCancel">{{ cancelText }}</button>
<button @click="handleConfirm" :loading="loading">
{{ confirmText }}
</button>
</slot>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { Teleport, Transition, isVNode } from 'vue';
const props = defineProps({
modelValue: Boolean,
title: { type: String, default: '提示' },
content: { type: [String, Object], default: '' },
showClose: { type: Boolean, default: true },
confirmText: { type: String, default: '确定' },
cancelText: { type: String, default: '取消' },
loading: { type: Boolean, default: false },
maskClosable: { type: Boolean, default: true }
});
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
const handleClose = () => {
emit('update:modelValue', false);
};
const handleMaskClick = () => {
if (props.maskClosable) {
handleClose();
}
};
const handleConfirm = () => {
emit('confirm');
};
const handleCancel = () => {
emit('cancel');
handleClose();
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-container {
background: white;
border-radius: 8px;
min-width: 400px;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
}
/* 过渡动画 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .modal-container,
.modal-leave-active .modal-container {
transition: transform 0.3s ease;
}
.modal-enter-from .modal-container,
.modal-leave-to .modal-container {
transform: scale(0.9);
}
</style>
二、API 调用方式
1. 使用 Vue3 的 createApp 和 render 实现
// modal-api.js
import { createVNode, render } from 'vue';
import Modal from './Modal.vue';
// 存储当前打开的 modal 实例
let instance = null;
export function createModal(options = {}) {
// 创建容器
const container = document.createElement('div');
document.body.appendChild(container);
// 创建 vnode
const vnode = createVNode(Modal, {
...options,
modelValue: true,
'onUpdate:modelValue': (val) => {
if (!val) {
// 关闭时销毁
setTimeout(() => {
render(null, container);
document.body.removeChild(container);
instance = null;
}, 300);
}
},
onConfirm: () => {
if (options.onConfirm) {
const result = options.onConfirm();
// 支持 Promise,返回 Promise 时显示 loading
if (result && typeof result.then === 'function') {
vnode.component.props.loading = true;
result
.then(() => {
vnode.component.props.modelValue = false;
})
.finally(() => {
vnode.component.props.loading = false;
});
} else {
vnode.component.props.modelValue = false;
}
}
},
onCancel: () => {
options.onCancel?.();
}
});
// 渲染
render(vnode, container);
instance = vnode.component;
// 返回控制方法
return {
close: () => {
vnode.component.props.modelValue = false;
}
};
}
// 便捷方法
export function $confirm(options) {
return createModal({
title: '确认',
...options
});
}
export function $alert(options) {
return createModal({
title: '提示',
showClose: false,
...options
});
}
2. 作为插件安装
// index.js
import { createModal, $confirm, $alert } from './modal-api';
export default {
install(app) {
// 全局属性
app.config.globalProperties.$modal = {
show: createModal,
confirm: $confirm,
alert: $alert
};
// 组合式函数
app.provide('modal', {
show: createModal,
confirm: $confirm,
alert: $alert
});
}
};
// 在 setup 中使用
import { inject } from 'vue';
export function useModal() {
return inject('modal');
}
三、使用示例
1. 组件式使用
<template>
<button @click="showModal = true">打开 Modal</button>
<Modal
v-model="showModal"
title="编辑用户信息"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<form>
<input v-model="form.name" placeholder="姓名" />
<input v-model="form.email" placeholder="邮箱" />
</form>
<template #footer>
<button @click="showModal = false">暂不</button>
<button @click="save">保存</button>
</template>
</Modal>
</template>
<script setup>
import { ref, reactive } from 'vue';
import Modal from './Modal.vue';
const showModal = ref(false);
const form = reactive({ name: '', email: '' });
const handleConfirm = () => {
console.log('点击确定');
};
</script>
2. API 式使用
<script setup>
import { getCurrentInstance } from 'vue';
import { useModal } from './modal';
const { proxy } = getCurrentInstance();
const modal = useModal();
// 方式1: 使用全局属性
const handleDelete = (row) => {
proxy.$modal.confirm({
title: '确认删除',
content: `确定要删除 "${row.name}" 吗?`,
onConfirm: async () => {
await deleteUser(row.id);
// 返回 Promise,按钮会自动显示 loading
}
});
};
// 方式2: 使用组合式函数
const handleAlert = () => {
modal.alert({
content: '操作成功!',
onConfirm: () => {
router.push('/list');
}
});
};
// 方式3: 传入渲染函数/JSX
const handleComplex = () => {
proxy.$modal.show({
title: '复杂内容',
content: (h) => h('div', { class: 'custom-content' }, [
h('p', '这是段落'),
h('button', { onClick: () => console.log('点击') }, '按钮')
])
});
};
</script>
深入理解
1. Teleport 的作用
<!-- 不使用 Teleport - Modal 渲染在组件内部 -->
<div class="parent" style="position: relative; z-index: 1;">
<div class="modal" style="position: fixed; z-index: 100;">
<!-- 可能被父元素的 z-index、overflow 影响 -->
</div>
</div>
<!-- 使用 Teleport - Modal 直接渲染到 body -->
<Teleport to="body">
<div class="modal">
<!-- 脱离组件层级,避免样式污染 -->
</div>
</Teleport>
2. 处理 Promise 确认
// modal-api.js 改进版
export function createModal(options) {
return new Promise((resolve, reject) => {
const vnode = createVNode(Modal, {
modelValue: true,
onConfirm: async () => {
try {
if (options.onConfirm) {
const result = options.onConfirm();
// 等待异步操作完成
if (result && typeof result.then === 'function') {
await result;
}
}
close();
resolve('confirm');
} catch (error) {
// 发生错误不关闭,显示错误信息
console.error(error);
}
},
onCancel: () => {
options.onCancel?.();
close();
reject('cancel');
}
});
// ... 渲染逻辑
});
}
// 使用
const handleSubmit = async () => {
try {
await $confirm({ content: '确定提交吗?' });
await submitForm();
} catch {
console.log('用户取消');
}
};
3. 支持多层 Modal
// modal-manager.js
class ModalManager {
constructor() {
this.modals = [];
this.zIndex = 2000;
}
add(modal) {
this.modals.push(modal);
return ++this.zIndex;
}
remove(modal) {
const index = this.modals.indexOf(modal);
if (index > -1) {
this.modals.splice(index, 1);
}
}
// ESC 键关闭最上层
handleEsc() {
const topModal = this.modals[this.modals.length - 1];
if (topModal && topModal.closable) {
topModal.close();
}
}
}
export const modalManager = new ModalManager();
// 在 Modal 组件中使用
onMounted(() => {
modalManager.add(instance);
document.addEventListener('keydown', handleEsc);
});
onUnmounted(() => {
modalManager.remove(instance);
document.removeEventListener('keydown', handleEsc);
});
4. 国际化支持
// locale/index.js
const locales = {
'zh-CN': {
modal: {
title: '提示',
confirm: '确定',
cancel: '取消'
}
},
'en-US': {
modal: {
title: 'Tip',
confirm: 'OK',
cancel: 'Cancel'
}
}
};
// config.js
export const config = {
locale: 'zh-CN',
zIndex: 2000,
maskClosable: true,
showClose: true
};
// Modal.vue 中使用
const t = (key) => {
return locales[config.locale]?.modal[key] || key;
};
最佳实践
1. 目录结构
modal/
├── components/
│ └── Modal.vue # 基础组件
├── composables/
│ ├── useModal.js # 组合式函数
│ └── useDialog.js # 扩展 Dialog
├── api/
│ └── index.js # API 调用实现
├── locale/
│ ├── zh-CN.js
│ └── en-US.js
├── styles/
│ └── modal.css
├── index.js # 插件入口
└── types/
└── index.d.ts
2. 类型定义(TypeScript)
// types/index.ts
import { VNode, RenderFunction } from 'vue';
export interface ModalOptions {
title?: string;
content?: string | VNode | RenderFunction;
showClose?: boolean;
confirmText?: string;
cancelText?: string;
maskClosable?: boolean;
onConfirm?: () => void | Promise<void>;
onCancel?: () => void;
}
export interface ModalInstance {
close: () => void;
}
// 扩展全局属性
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$modal: {
show: (options: ModalOptions) => ModalInstance;
confirm: (options: Omit<ModalOptions, 'showClose'>) => Promise<void>;
alert: (options: Omit<ModalOptions, 'showClose' | 'cancelText'>) => Promise<void>;
};
}
}
3. 性能优化
<script setup>
import { defineAsyncComponent } from 'vue';
// 异步加载 Modal 组件
const AsyncModal = defineAsyncComponent(() => import('./Modal.vue'));
</script>
4. 无障碍支持
<template>
<Teleport to="body">
<div
v-if="visible"
class="modal-overlay"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
@keydown.esc="handleClose"
>
<div class="modal-container" tabindex="-1" ref="modalRef">
<!-- 聚焦陷阱 -->
<FocusTrap>
<!-- Modal 内容 -->
</FocusTrap>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue';
const modalRef = ref();
const visible = ref(false);
// 打开时聚焦到 modal
watch(visible, (val) => {
if (val) {
nextTick(() => {
modalRef.value?.focus();
// 禁止背景滚动
document.body.style.overflow = 'hidden';
});
} else {
document.body.style.overflow = '';
}
});
</script>
面试要点
-
能够说出 Modal 组件的核心要素
- 遮罩层、标题、内容、操作按钮、可配置性
-
理解 Vue3 Teleport 的作用
- 将内容渲染到 body,避免样式层级问题
-
掌握 API 调用方式的实现
- 使用
createVNode和render动态创建组件
- 使用
-
理解如何处理 Promise 确认
- 支持异步操作,自动显示 loading
-
了解多层 Modal 的管理
- z-index 递增、ESC 键关闭最上层
-
能够说出组件设计原则
- 组件式 + API 式双重支持
- 插槽提供扩展性
- TypeScript 类型支持
核心结论:
- 一个好的 Modal 组件应该同时支持组件式和 API 式调用
- 使用 Teleport 避免样式层级问题
- Composition API 让逻辑复用更加清晰
- 考虑 Promise 支持、多层管理、无障碍等高级功能