Vue3 组合式 API 核心宏详解:defineProps、defineEmits、defineExpose

Vue3 组合式 API 核心宏详解:defineProps、defineEmits、defineExpose

在 Vue3 的 Composition API 中,definePropsdefineEmitsdefineExpose 是三个非常重要的编译器宏,它们为组件开发提供了简洁而强大的功能。下面我将详细解释它们的含义、用法并提供完整示例。

核心概念解析

1. defineProps

  • 作用:声明组件接收的 props(属性)
  • 功能:定义组件可以从父组件接收哪些数据
  • 特点
    • 仅在 <script setup> 中可用
    • 无需导入,由编译器自动处理
    • 支持 TypeScript 类型声明
  • 替代选项式 APIprops 选项

2. defineEmits

  • 作用:声明组件可以触发的事件
  • 功能:定义组件可以向父组件发送哪些事件
  • 特点
    • 仅在 <script setup> 中可用
    • 支持完整类型标注
    • 返回一个 emit 函数用于触发事件
  • 替代选项式 APIemits 选项

3. defineExpose

  • 作用:暴露组件的公共属性/方法
  • 功能:明确指定哪些组件内部内容可以被父组件通过 ref 访问
  • 特点
    • 仅在 <script setup> 中可用
    • 默认情况下 <script setup> 的组件是封闭的
    • 用于定义组件公共 API
  • 替代选项式 APIexpose 选项

完整示例:用户资料卡片组件

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue3 组合式 API 宏详解</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <style>
    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    }
    body {
      background: linear-gradient(135deg, #1a2a6c, #b21f1f, #1a2a6c);
      color: white;
      min-height: 100vh;
      padding: 2rem;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    .container {
      max-width: 1000px;
      background: rgba(255, 255, 255, 0.08);
      border-radius: 15px;
      padding: 2rem;
      box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255, 255, 255, 0.1);
    }
    header {
      text-align: center;
      margin-bottom: 2rem;
    }
    h1 {
      font-size: 2.5rem;
      margin-bottom: 1rem;
      color: #61dafb;
    }
    .subtitle {
      font-size: 1.2rem;
      opacity: 0.8;
      margin-bottom: 2rem;
    }
    .content {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 2rem;
    }
    .panel {
      background: rgba(0, 0, 0, 0.2);
      border-radius: 10px;
      padding: 1.5rem;
      border: 1px solid rgba(255, 255, 255, 0.1);
    }
    h2 {
      font-size: 1.8rem;
      margin-bottom: 1rem;
      color: #38ef7d;
      text-align: center;
    }
    .card {
      background: rgba(255, 255, 255, 0.1);
      border-radius: 10px;
      padding: 1.5rem;
      margin: 1rem 0;
    }
    .profile {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 1rem;
    }
    .avatar {
      width: 120px;
      height: 120px;
      border-radius: 50%;
      background: linear-gradient(135deg, #3494e6, #ec6ead);
      display: flex;
      justify-content: center;
      align-items: center;
      font-size: 3rem;
    }
    .user-info {
      text-align: center;
    }
    .user-name {
      font-size: 1.5rem;
      font-weight: bold;
      margin-bottom: 0.5rem;
    }
    .user-email {
      opacity: 0.8;
      margin-bottom: 1rem;
    }
    .stats {
      display: flex;
      justify-content: space-around;
      width: 100%;
      margin: 1rem 0;
    }
    .stat {
      text-align: center;
    }
    .stat-value {
      font-size: 1.5rem;
      font-weight: bold;
      color: #f8b400;
    }
    .stat-label {
      font-size: 0.9rem;
      opacity: 0.8;
    }
    .actions {
      display: flex;
      gap: 1rem;
      margin-top: 1rem;
      flex-wrap: wrap;
    }
    button {
      background: linear-gradient(135deg, #3494e6, #ec6ead);
      color: white;
      border: none;
      padding: 0.8rem 1.5rem;
      border-radius: 50px;
      cursor: pointer;
      font-size: 1rem;
      font-weight: bold;
      transition: all 0.3s ease;
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
      flex: 1;
      min-width: 120px;
    }
    button:hover {
      transform: translateY(-3px);
      box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
    }
    .code-block {
      background: rgba(0, 0, 0, 0.3);
      padding: 1rem;
      border-radius: 8px;
      font-family: 'Fira Code', monospace;
      font-size: 0.9rem;
      overflow-x: auto;
      margin: 1rem 0;
    }
    .explanation {
      margin-top: 1.5rem;
      padding: 1.5rem;
      background: rgba(0, 0, 0, 0.2);
      border-radius: 10px;
      border-left: 4px solid #61dafb;
    }
    h3 {
      font-size: 1.5rem;
      margin-bottom: 1rem;
      color: #ff7e5f;
    }
    ul {
      padding-left: 1.5rem;
      margin: 1rem 0;
    }
    li {
      margin-bottom: 0.5rem;
      line-height: 1.6;
    }
    .usage {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 1rem;
      margin-top: 1.5rem;
    }
    .usage-card {
      background: rgba(255, 255, 255, 0.1);
      border-radius: 8px;
      padding: 1rem;
    }
    .usage-title {
      font-weight: bold;
      margin-bottom: 0.5rem;
      color: #38ef7d;
    }
    @media (max-width: 768px) {
      .content {
        grid-template-columns: 1fr;
      }
      .usage {
        grid-template-columns: 1fr;
      }
    }
  </style>
</head>
<body>
  <div id="app">
    <div class="container">
      <header>
        <h1>Vue3 组合式 API 核心宏</h1>
        <p class="subtitle">defineProps, defineEmits, defineExpose 详解</p>
      </header>
      
      <div class="content">
        <!-- 父组件 -->
        <div class="panel">
          <h2>父组件 (App.vue)</h2>
          
          <div class="card">
            <div class="code-block">
// 父组件模板
&lt;UserProfile 
  ref="profileRef"
  :user="userData"
  :show-stats="true"
  @update-name="handleNameUpdate"
  @follow="handleFollow"
/>

// 父组件逻辑
import { ref } from 'vue'
import UserProfile from './UserProfile.vue'

const profileRef = ref(null)
const userData = ref({
  name: '张三',
  email: 'zhangsan@example.com',
  posts: 42,
  followers: 1200,
  following: 350
})

function handleNameUpdate(newName) {
  userData.value.name = newName
}

function handleFollow() {
  userData.value.followers++
}

function resetStats() {
  profileRef.value.resetStats()
}
            </div>
            
            <div class="actions">
              <button @click="resetStats">调用子组件方法 (resetStats)</button>
              <button @click="showChildData">查看子组件数据</button>
            </div>
          </div>
          
          <div class="card">
            <h3>当前用户数据</h3>
            <div class="code-block">
{{ JSON.stringify(parentUser, null, 2) }}
            </div>
          </div>
        </div>
        
        <!-- 子组件 -->
        <div class="panel">
          <h2>子组件 (UserProfile.vue)</h2>
          
          <div class="card">
            <div class="profile">
              <div class="avatar">
                {{ userInitials }}
              </div>
              <div class="user-info">
                <div class="user-name">{{ props.user.name }}</div>
                <div class="user-email">{{ props.user.email }}</div>
              </div>
              
              <div v-if="props.showStats" class="stats">
                <div class="stat">
                  <div class="stat-value">{{ props.user.posts }}</div>
                  <div class="stat-label">文章</div>
                </div>
                <div class="stat">
                  <div class="stat-value">{{ props.user.followers }}</div>
                  <div class="stat-label">粉丝</div>
                </div>
                <div class="stat">
                  <div class="stat-value">{{ props.user.following }}</div>
                  <div class="stat-label">关注</div>
                </div>
              </div>
              
              <div class="actions">
                <button @click="updateName">更新名称</button>
                <button @click="followUser">关注用户</button>
              </div>
            </div>
          </div>
          
          <div class="card">
            <div class="code-block">
// 子组件逻辑
import { computed } from 'vue'

// 1. 使用 defineProps 声明接收的属性
const props = defineProps({
  user: {
    type: Object,
    required: true
  },
  showStats: {
    type: Boolean,
    default: true
  }
})

// 2. 使用 defineEmits 声明可触发的事件
const emit = defineEmits(['update-name', 'follow'])

// 计算属性
const userInitials = computed(() => 
  props.user.name.split(' ').map(n => n[0]).join('')
)

// 3. 使用 defineExpose 暴露公共方法
defineExpose({
  resetStats: () => {
    props.user.posts = 0
    props.user.followers = 0
    props.user.following = 0
  }
})

// 组件方法
function updateName() {
  const newName = prompt('请输入新名称', props.user.name)
  if (newName) {
    emit('update-name', newName)
  }
}

function followUser() {
  emit('follow')
}
            </div>
          </div>
        </div>
      </div>
      
      <div class="explanation">
        <h3>核心宏详解</h3>
        
        <div class="usage">
          <div class="usage-card">
            <div class="usage-title">defineProps</div>
            <p>用于声明组件接收的属性:</p>
            <ul>
              <li>类型验证</li>
              <li>默认值</li>
              <li>必需性检查</li>
            </ul>
            <div class="code-block">
const props = defineProps({
  title: String,
  count: {
    type: Number,
    default: 0,
    required: true
  }
})
            </div>
          </div>
          
          <div class="usage-card">
            <div class="usage-title">defineEmits</div>
            <p>用于声明组件触发的事件:</p>
            <ul>
              <li>事件名声明</li>
              <li>事件参数类型</li>
              <li>事件验证</li>
            </ul>
            <div class="code-block">
const emit = defineEmits({
  'update-name': (newName) => {
    return newName.length > 0
  },
  'follow': null // 无需验证
})
            </div>
          </div>
          
          <div class="usage-card">
            <div class="usage-title">defineExpose</div>
            <p>用于暴露公共API:</p>
            <ul>
              <li>暴露方法</li>
              <li>暴露属性</li>
              <li>控制访问权限</li>
            </ul>
            <div class="code-block">
const internalData = ref('私有数据')

defineExpose({
  publicMethod() {
    console.log('公共方法')
  },
  publicData: '公共数据'
})
            </div>
          </div>
        </div>
        
        <div class="important-note">
          <h3>最佳实践</h3>
          <ul>
            <li>始终为 props 定义类型验证,提高代码健壮性</li>
            <li>使用 defineEmits 明确声明事件,提供更好的类型支持</li>
            <li>谨慎使用 defineExpose,只暴露必要的公共 API</li>
            <li>对于复杂组件,使用 TypeScript 接口定义 props 和 emits 类型</li>
            <li>避免直接修改 props 的值,应该通过触发事件让父组件修改</li>
          </ul>
        </div>
      </div>
    </div>
  </div>

  <script>
    const { createApp, ref, computed } = Vue;
    
    // 创建Vue应用
    const app = createApp({
      setup() {
        // 父组件数据
        const parentUser = ref({
          name: '张三',
          email: 'zhangsan@example.com',
          posts: 42,
          followers: 1200,
          following: 350
        });
        
        // 引用子组件
        const profileRef = ref(null);
        
        // 处理名称更新
        const handleNameUpdate = (newName) => {
          parentUser.value.name = newName;
        };
        
        // 处理关注事件
        const handleFollow = () => {
          parentUser.value.followers++;
          alert('关注成功!');
        };
        
        // 重置统计数据
        const resetStats = () => {
          if (profileRef.value && profileRef.value.resetStats) {
            profileRef.value.resetStats();
            alert('统计数据已重置!');
          }
        };
        
        // 查看子组件数据
        const showChildData = () => {
          if (profileRef.value) {
            console.log('子组件暴露的内容:', profileRef.value);
            alert('请在控制台查看子组件暴露的内容');
          }
        };
        
        return {
          parentUser,
          profileRef,
          handleNameUpdate,
          handleFollow,
          resetStats,
          showChildData
        };
      }
    });
    
    // 注册子组件
    app.component('UserProfile', {
      // 使用 <script setup> 语法等效代码
      props: {
        user: {
          type: Object,
          required: true
        },
        showStats: {
          type: Boolean,
          default: true
        }
      },
      
      emits: ['update-name', 'follow'],
      
      setup(props, { emit, expose }) {
        // 计算用户首字母
        const userInitials = computed(() => 
          props.user.name.split(' ').map(n => n[0]).join('')
        );
        
        // 更新名称方法
        const updateName = () => {
          const newName = prompt('请输入新名称', props.user.name);
          if (newName) {
            emit('update-name', newName);
          }
        };
        
        // 关注用户方法
        const followUser = () => {
          emit('follow');
        };
        
        // 重置统计方法
        const resetStats = () => {
          props.user.posts = 0;
          props.user.followers = 0;
          props.user.following = 0;
        };
        
        // 暴露公共API
        expose({
          resetStats
        });
        
        return {
          props,
          userInitials,
          updateName,
          followUser
        };
      },
      
      template: `
        <div class="profile">
          <div class="avatar">
            {{ userInitials }}
          </div>
          <div class="user-info">
            <div class="user-name">{{ props.user.name }}</div>
            <div class="user-email">{{ props.user.email }}</div>
          </div>
          
          <div v-if="props.showStats" class="stats">
            <div class="stat">
              <div class="stat-value">{{ props.user.posts }}</div>
              <div class="stat-label">文章</div>
            </div>
            <div class="stat">
              <div class="stat-value">{{ props.user.followers }}</div>
              <div class="stat-label">粉丝</div>
            </div>
            <div class="stat">
              <div class="stat-value">{{ props.user.following }}</div>
              <div class="stat-label">关注</div>
            </div>
          </div>
          
          <div class="actions">
            <button @click="updateName">更新名称</button>
            <button @click="followUser">关注用户</button>
          </div>
        </div>
      `
    });
    
    // 挂载应用
    app.mount('#app');
  </script>
</body>
</html>

核心宏详细说明

1. defineProps - 属性声明

用法:

javascript 复制代码
const props = defineProps({
  // 基础类型检查
  title: String,
  
  // 多个可能的类型
  content: [String, Number],
  
  // 必填且为数字
  count: {
    type: Number,
    required: true
  },
  
  // 带默认值的对象
  user: {
    type: Object,
    default: () => ({ name: 'Guest' })
  },
  
  // 自定义验证函数
  age: {
    validator(value) {
      return value >= 18 && value <= 100;
    }
  }
})

TypeScript 用法:

typescript 复制代码
interface User {
  name: string
  age: number
}

const props = defineProps<{
  title: string
  count?: number  // 可选
  user: User      // 必填
}>()

2. defineEmits - 事件声明

用法:

javascript 复制代码
const emit = defineEmits({
  // 无验证
  'change': null,
  
  // 有验证
  'update': (payload) => {
    return typeof payload === 'string'
  },
  
  // 多个参数验证
  'submit': (email, password) => {
    return email.includes('@') && password.length >= 8
  }
})

// 触发事件
emit('update', 'new value')

TypeScript 用法:

typescript 复制代码
const emit = defineEmits<{
  (e: 'change'): void
  (e: 'update', value: string): void
  (e: 'submit', email: string, password: string): void
}>()

3. defineExpose - 暴露公共API

用法:

javascript 复制代码
import { ref } from 'vue'

const count = ref(0)
const internalMethod = () => {
  console.log('内部方法')
}

// 暴露公共API
defineExpose({
  increment() {
    count.value++
  },
  getCount: () => count.value,
  publicData: '可访问数据'
})

// 父组件通过 ref 访问:
// childRef.value.increment()

最佳实践总结

  1. 明确声明

    • 始终使用 defineProps 声明组件属性
    • 使用 defineEmits 明确组件事件
    • 使用 defineExpose 控制组件公共API
  2. 类型安全

    • 优先使用 TypeScript 类型声明
    • 为 props 添加验证逻辑
    • 为事件参数定义类型
  3. 封装原则

    • 只暴露必要的公共方法
    • 避免直接暴露内部状态
    • 使用前缀(如 _)表示私有属性
  4. 组合式API优势

    • 逻辑复用更简单
    • 类型推导更强大
    • 代码组织更灵活

这个示例展示了如何在实际组件中使用这三个核心宏,通过用户资料卡片的实现,您可以清楚地看到它们在实际开发中的应用场景和交互方式。

相关推荐
独立开阀者_FwtCoder8 分钟前
"页面白屏了?别慌!前端工程师必备的排查技巧和面试攻略"
java·前端·javascript
慧一居士8 分钟前
Vite 完整功能详解与 Vue 项目实战指南
前端·vue.js
Hilaku16 分钟前
说实话,React的开发体验,已经被Vue甩开几条街了
前端·javascript·vue.js
星语卿16 分钟前
Js事件循环
javascript
datagear16 分钟前
如何在DataGear 5.4.1 中快速制作HTTP数据源服务端分页的数据表格看板
javascript·数据可视化
艾克马斯奎普特23 分钟前
为什么响应性语法糖最终被废弃了?尤雨溪也曾经试图让你不用写 .value
前端·vue.js·代码规范
namehu24 分钟前
“什么?视频又双叒叕不能播了!”—— 移动端视频兼容性填坑指南
javascript·html
MR_发25 分钟前
万字实现带@和表情包的输入框
vue.js·typescript
多啦C梦a26 分钟前
React Hooks 编程:`useState` 和 `useEffect`,再不懂就OUT了!
前端·javascript
yvvvy41 分钟前
# React Hooks 全面解析:从 useState 到 useEffect,掌握状态与副作用管理
javascript