返回首页

用 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>

面试要点

  1. 能够说出 Modal 组件的核心要素

    • 遮罩层、标题、内容、操作按钮、可配置性
  2. 理解 Vue3 Teleport 的作用

    • 将内容渲染到 body,避免样式层级问题
  3. 掌握 API 调用方式的实现

    • 使用 createVNoderender 动态创建组件
  4. 理解如何处理 Promise 确认

    • 支持异步操作,自动显示 loading
  5. 了解多层 Modal 的管理

    • z-index 递增、ESC 键关闭最上层
  6. 能够说出组件设计原则

    • 组件式 + API 式双重支持
    • 插槽提供扩展性
    • TypeScript 类型支持

核心结论

  • 一个好的 Modal 组件应该同时支持组件式和 API 式调用
  • 使用 Teleport 避免样式层级问题
  • Composition API 让逻辑复用更加清晰
  • 考虑 Promise 支持、多层管理、无障碍等高级功能