评论
uni-app 组件通信:$emit vs $refs 完全指南

uni-app 组件通信:$emit vs $refs 完全指南

概述

在 Vue 中,父子组件之间的通信主要有两种方式:

  • $emit:子组件向父组件发送消息(事件驱动)
  • $refs:父组件直接访问子组件(直接引用)

一、this.$emit - 子传父的事件机制

1.1 基本概念

$emit 是 Vue 实例方法,用于子组件向父组件触发事件并传递数据。

// 语法
this.$emit(eventName, ...args)

1.2 使用场景

  • 子组件需要通知父组件某个事件发生了
  • 子组件需要将数据传递给父组件
  • 保持组件之间的松耦合

1.3 完整示例

子组件:ProductGrid.vue

<template>
  <view class="product-grid">
    <view 
      class="product-item" 
      v-for="(item, index) in list" 
      :key="index"
      @click="handleItemClick(item)"
    >
      <image class="product-icon" :src="item.icon" mode="aspectFit" />
      <view class="product-title">{{ item.title }}</view>
    </view>
  </view>
</template>

<script>
export default {
  name: "ProductGrid",
  props: {
    list: {
      type: Array,
      default: () => []
    }
  },
  methods: {
    handleItemClick(item) {
      // 触发 itemClick 事件,将 item 数据传递给父组件
      this.$emit('itemClick', item)

      // 可以传递多个参数
      this.$emit('itemClick', item, index, 'extraData')
    }
  }
}
</script>

父组件:ProductContent.vue

<template>
  <view>
    <!-- 使用 @itemClick 监听子组件触发的事件 -->
    <ProductGrid 
      :list="productList" 
      @itemClick="handleItemClick" 
    />
  </view>
</template>

<script>
import ProductGrid from './ProductGrid.vue'

export default {
  components: { ProductGrid },
  data() {
    return {
      productList: [
        { icon: '/static/a.png', title: '产品A' },
        { icon: '/static/b.png', title: '产品B' }
      ]
    }
  },
  methods: {
    // 接收子组件传递的数据
    handleItemClick(item, index, extra) {
      console.log('点击的产品:', item)
      console.log('索引:', index)
      console.log('额外数据:', extra)

      uni.showToast({
        title: item.title,
        icon: 'none'
      })
    }
  }
}
</script>

1.4 事件流图解

┌─────────────────┐         点击事件          ┌─────────────────┐
│   用户点击产品    │  ──────────────────────> │  handleItemClick │
│                 │                          │   (子组件方法)    │
└─────────────────┘                          └────────┬────────┘
                                                      │
                                                      │ this.$emit('itemClick', item)
                                                      ▼
                                               ┌───────────────┐
                                               │  触发自定义事件  │
                                               └───────┬───────┘
                                                       │
                                                       ▼
┌─────────────────┐                          ┌─────────────────┐
│  父组件处理逻辑   │ <──────────────────────  │  @itemClick     │
│  handleItemClick │                          │  (事件监听)      │
└─────────────────┘                          └─────────────────┘

1.5 最佳实践

// ✅ 好的做法:事件名使用 kebab-case
this.$emit('item-click', item)

// ✅ 好的做法:传递必要的数据
this.$emit('update', { id: 1, name: 'test' })

// ✅ 好的做法:配合 v-model 使用
this.$emit('input', newValue)  // 或 update:modelValue (Vue3)

// ❌ 避免:事件名使用驼峰(HTML不区分大小写)
this.$emit('itemClick', item)

二、this.$refs - 直接引用访问

2.1 基本概念

$refs 是 Vue 实例属性,用于父组件直接访问子组件实例或 DOM 元素

// 语法:通过 ref 名称访问
this.$refs.refName

2.2 使用场景

  • 需要调用子组件的方法
  • 需要访问子组件的数据
  • 需要操作 DOM 元素
  • 表单验证等需要直接控制的场景

2.3 完整示例

子组件:Counter.vue

<template>
  <view class="counter">
    <text>当前计数:{{ count }}</text>
    <button @click="increment">+1</button>
  </view>
</template>

<script>
export default {
  name: "Counter",
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    },
    // 父组件可以通过 $refs 调用这个方法
    reset() {
      this.count = 0
      console.log('计数已重置')
    },
    // 父组件可以通过 $refs 调用这个方法
    setValue(value) {
      this.count = value
    }
  }
}
</script>

父组件

<template>
  <view>
    <!-- 使用 ref 属性给子组件命名 -->
    <Counter ref="counterRef" />

    <!-- 父组件按钮直接操作子组件 -->
    <button @click="resetCounter">重置计数器</button>
    <button @click="setCounterTo100">设置为100</button>
    <button @click="getCounterValue">获取当前值</button>
  </view>
</template>

<script>
import Counter from './Counter.vue'

export default {
  components: { Counter },
  methods: {
    resetCounter() {
      // 直接调用子组件的方法
      this.$refs.counterRef.reset()
    },

    setCounterTo100() {
      // 调用子组件方法并传参
      this.$refs.counterRef.setValue(100)
    },

    getCounterValue() {
      // 直接访问子组件的数据
      const currentValue = this.$refs.counterRef.count
      console.log('当前计数:', currentValue)

      uni.showToast({
        title: `当前值:${currentValue}`,
        icon: 'none'
      })
    }
  }
}
</script>

2.4 访问 DOM 元素

<template>
  <view>
    <!-- 给原生元素添加 ref -->
    <input ref="inputRef" type="text" placeholder="请输入" />
    <button @click="focusInput">聚焦输入框</button>
    <button @click="clearInput">清空输入框</button>
  </view>
</template>

<script>
export default {
  methods: {
    focusInput() {
      // 在 uni-app 中操作 DOM 需要使用 createSelectorQuery
      // 但在 H5 中可以直接访问
      this.$refs.inputRef.focus()
    },

    clearInput() {
      this.$refs.inputRef.value = ''
    }
  }
}
</script>

2.5 注意事项

// ⚠️ $refs 在 mounted 之后才可用
export default {
  mounted() {
    // ✅ 正确:可以访问 $refs
    console.log(this.$refs.counterRef)
  },

  created() {
    // ❌ 错误:此时 DOM 还未渲染
    console.log(this.$refs.counterRef)  // undefined
  }
}

三、emitvsemit vsemitvsrefs 对比

3.1 核心区别表

特性$emit$refs
通信方向子组件 → 父组件父组件 → 子组件
触发方式事件驱动(被动)直接调用(主动)
使用场景子组件通知父组件父组件控制子组件
组件耦合松耦合 ✅紧耦合 ⚠️
代码可读性清晰,易于追踪需要查看多处代码
测试友好性易于单元测试测试较复杂
TypeScript 支持类型推断友好需要额外类型断言

3.2 选择指南

是否需要子组件通知父组件?
    ├── 是 → 使用 $emit
    │         └── 是否需要传递数据?
    │               ├── 是 → $emit('event', data)
    │               └── 否 → $emit('event')
    │
    └── 否 → 是否需要父组件调用子组件方法?
              ├── 是 → 使用 $refs
              │         └── 是否需要频繁调用?
              │               ├── 是 → 考虑重构,使用 props + $emit
              │               └── 否 → 使用 $refs 即可
              │
              └── 否 → 使用 props 传递数据即可

四、实战案例

4.1 表单验证场景($refs 适用)

<template>
  <view>
    <Form ref="formRef" :rules="rules" :model="formData">
      <FormItem prop="username">
        <input v-model="formData.username" placeholder="用户名" />
      </FormItem>
      <FormItem prop="password">
        <input v-model="formData.password" type="password" placeholder="密码" />
      </FormItem>
    </Form>

    <button @click="submitForm">提交</button>
  </view>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        username: '',
        password: ''
      },
      rules: {
        username: [{ required: true, message: '请输入用户名' }],
        password: [{ required: true, message: '请输入密码' }]
      }
    }
  },
  methods: {
    async submitForm() {
      // 调用子组件的验证方法
      const valid = await this.$refs.formRef.validate()

      if (valid) {
        console.log('验证通过,提交数据:', this.formData)
      } else {
        console.log('验证失败')
      }
    }
  }
}
</script>

4.2 列表项点击场景($emit 适用)

<!-- 子组件:ProductList.vue -->
<template>
  <view class="product-list">
    <view 
      v-for="item in products" 
      :key="item.id"
      class="product-card"
      @click="onProductClick(item)"
    >
      <image :src="item.image" mode="aspectFill" />
      <text>{{ item.name }}</text>
    </view>
  </view>
</template>

<script>
export default {
  props: {
    products: Array
  },
  methods: {
    onProductClick(item) {
      // 通知父组件用户点击了哪个产品
      this.$emit('product-click', item)

      // 可以同时触发多个事件
      this.$emit('track-event', {
        event: 'product_click',
        productId: item.id
      })
    }
  }
}
</script>
<!-- 父组件 -->
<template>
  <ProductList 
    :products="productList"
    @product-click="handleProductClick"
    @track-event="handleTrackEvent"
  />
</template>

<script>
export default {
  methods: {
    handleProductClick(product) {
      // 跳转到详情页
      uni.navigateTo({
        url: `/pages/product/detail?id=${product.id}`
      })
    },

    handleTrackEvent(data) {
      // 上报埋点数据
      analytics.track(data.event, data)
    }
  }
}
</script>

五、常见误区

5.1 误区一:过度使用 $refs

// ❌ 不好的做法:用 $refs 获取数据,而不是通过事件
// 子组件
this.$parent.productList  // 直接访问父组件数据

// ✅ 正确的做法:通过 props 传递,通过 $emit 通知
// 父组件
<ProductList :products="productList" @update="handleUpdate" />

5.2 误区二:在 created 中使用 $refs

// ❌ 错误
export default {
  created() {
    this.$refs.myComponent.init()  // undefined!
  }
}

// ✅ 正确
export default {
  mounted() {
    this.$refs.myComponent.init()  // OK
  }
}

5.3 误区三:事件名不规范

// ❌ 不推荐(虽然可以工作,但不规范)
this.$emit('ItemClick', data)

// ✅ 推荐(kebab-case)
this.$emit('item-click', data)

// 模板中
<Item @item-click="handler" />

六、总结

推荐方案原因
子组件通知父组件$emit松耦合,符合单向数据流
调用子组件方法$refs直接、简单
表单验证$refs需要同步获取验证结果
列表项点击$emit事件驱动,易于扩展
跨多级组件通信provide/inject 或 Vuex避免层层传递

核心原则

  1. 优先使用 ‎$emit 进行父子通信
  2. $refs 只在必要时使用(如调用方法、表单验证)
  3. 避免在 ‎created 中使用 ‎$refs
  4. 保持组件松耦合,提高可维护性