手把手带你深度解剖Uniapp子组件封装及其应用开发实战教程
书接上篇,我们今天继续来学习一下 UniApp 聊一个大中型项目中都会用到的技术:子组件封装与组件间通信,从入门到跨平台实战,附带实现思路和所有源码示例,以及常见避坑指南和开发实践。
此系列文章将带领你从移动端跨平台开发入门到精通,如果你也喜欢关注APP、小程序、公众号、H5等等应用的开发,可以持续关注后续更新,避免错过宝贵的知识分享。
致开发者的忠告: AI编程盛行的今天,我们并不是不需要学习技术,而是更应该专研技术,拥有把控全局的架构设计思维才能在AI盛行的未来有立足之地。
言归正传,咱们今天继续来聊一聊开发应用中用的最多的技术:uniapp子组件的封装、传参、通信、使用示例和避坑指南等等。在 UniApp 中,这个问题还多了一层挑战——跨平台。同样的代码,在 H5 跑得好好的,到小程序里可能就失灵了;在 Vue3 中可行的写法,到了 UniApp 可能需要调整。
别担心,今天我就带你系统地梳理一遍,从最基础的封装,到各种通信方式,再到跨平台差异和坑点,最后用一个完整的示例把所有知识点串起来。学完这节课,你不仅能写出健壮、可复用的组件,还能在不同平台间游刃有余,H5/App/微信小程序等等环境都兼容和支持。
一、为什么要封装子组件?
先从一个实际场景说起:我们有一个商品列表页,每个商品卡片都要展示图片、名称、价格,还有一个“加入购物车”按钮。如果直接在列表页写死每个卡片的代码,不仅代码冗余,而且难以维护。这时我们就可以把商品卡片封装成一个独立的组件 GoodsCard,然后在列表页循环使用。
封装的优点:
-
复用性:同一组件可以在多个地方使用。
-
维护性:修改卡片样式只需改一处。
-
可读性:页面结构更清晰。
二、子组件封装基础(与Vue3的异同)
2.1 定义 props(父传子)
在 Vue3 中,我们使用 defineProps 来声明 props。UniApp 同样支持组合式 API,所以写法一致。
<!-- GoodsCard.vue --><scriptsetup>constprops=defineProps({goods: {type: Object,required: true },showPrice: {type: Boolean,default: true }})</script>
与Vue3的差异:无。UniApp 完全遵循 Vue3 语法。
注意:props 是单向数据流,子组件不能直接修改 props,否则会在控制台发出警告。如果确实需要修改,可以通过 $emit 让父组件去改,或者用子组件自己的 data 拷贝一份。
2.2 触发事件(子传父)
使用 defineEmits 声明事件。
<scriptsetup>constemit=defineEmits(['add-to-cart'])consthandleAdd= () => {emit('add-to-cart', props.goods.id)}</script>
父组件监听:
<GoodsCard@add-to-cart="onAddToCart"/>
与Vue3的差异:无。
2.3 插槽(slot)让组件更灵活
如果卡片底部需要额外内容(比如“立即购买”按钮),可以用插槽。
<!-- GoodsCard.vue --><template><viewclass="card"><image:src="goods.image"/><text>{{ goods.name }}</text><textv-if="showPrice">¥{{ goods.price }}</text><slotname="footer"></slot><!-- 具名插槽 --></view></template>
父组件使用:
<GoodsCard:goods="item"><template#footer><button@click="buyNow(item.id)">立即购买</button></template></GoodsCard>
与Vue3的差异:无。
2.4 样式隔离:scoped
在 <style> 上添加 scoped 属性,可以让样式只作用于当前组件,避免污染全局。
<stylescoped>.card { border: 1pxsolid#eee; }</style>
跨平台注意:
-
在 H5 中,scoped 通过添加
data-v-xxx属性实现。 -
在小程序中,scoped 同样有效,但需要注意:scoped 样式不会影响子组件的根元素,这是 Vue 的规则,与平台无关。
-
如果需要修改子组件内部的样式(比如第三方组件),可以使用
:deep()深度选择器。
:deep(.inner-class) {color: red;}
三、组件间通信的各种姿势
3.1 父子通信(最基础)
-
父传子:props
-
子传父:
$emit
上面已经讲过,不再赘述。
3.2 兄弟组件通信
兄弟组件不能直接通信,通常有两种方式:
-
通过共同的父组件作为桥梁:子 A 触发事件,父组件接收后修改数据,再通过 props 传递给子 B。
-
事件总线(Event Bus):创建一个全局的事件中心,组件之间通过它来通信。
在 uni-app 中,可以使用 uni.$on、uni.$emit、uni.$off 来实现事件总线。
示例:组件 A 触发一个事件,组件 B 监听。
// 组件 Auni.$emit('update-cart', { count: 1 })// 组件 B(通常在 onLoad 中监听)onLoad(() => {uni.$on('update-cart', (data) => {console.log('收到更新', data) })})// 别忘了在 onUnload 中取消监听,防止内存泄漏onUnload(() => {uni.$off('update-cart')})
跨平台注意:
-
uni.$on/$off/$emit是 uni-app 提供的全局事件总线,在 H5、App、小程序中都可用。 -
但在小程序中,页面销毁后监听依然存在,所以必须手动取消。
-
事件总线适合简单的跨组件通信,但如果项目复杂,建议用状态管理(Pinia/Vuex)。
3.3 跨级组件通信(provide / inject)
如果父组件和孙组件(甚至更深)需要通信,一层层传 props 会很繁琐。这时可以用 provide 和 inject。
// 祖先组件<scriptsetup>import { provide, ref } from'vue'constuser=ref({ name: '张三' })provide('user', user) // 传递响应式对象</script>// 后代组件<scriptsetup>import { inject } from'vue'constuser=inject('user')</script>
注意:
-
provide默认不是响应式的,如果需要响应式,可以传递一个 ref 或 reactive。 -
在跨平台中,
provide/inject在所有平台表现一致。
3.4 状态管理(Pinia / Vuex)
对于大型项目,推荐使用 Pinia(Vue 官方推荐)。Pinia 在 uni-app 中需要额外安装,但支持所有平台。这里不展开,但要记住:它是解决复杂通信的终极方案。
与Vue3的差异:无。Pinia 用法完全一致。
四、方法调用与元素位置计算(难点与坑点)
4.1 父组件调用子组件的方法
有时候父组件需要主动触发子组件的某个行为,比如刷新子组件的数据。这时可以用 ref 获取子组件实例,直接调用其方法。
<!-- 父组件 --><template><GoodsCardref="cardRef"/><button@click="callChildMethod">调用子组件方法</button></template><scriptsetup>import { ref } from'vue'constcardRef=ref()constcallChildMethod= () => {cardRef.value?.doSomething()}</script>
子组件需要暴露方法:
<!-- GoodsCard.vue --><scriptsetup>constdoSomething= () => {console.log('子组件方法被调用')}defineExpose({ doSomething }) // 关键!</script>
跨平台注意:
-
defineExpose在 H5/App/小程序中都有效。 -
在小程序中,通过 ref 获取的组件实例是代理对象,但方法调用依然有效。
4.2 在组件内获取元素位置
很多场景需要获取某个 DOM 元素的位置,比如实现动画、滚动定位。在 uni-app 中,我们使用 uni.createSelectorQuery()。
关键点:必须在元素渲染完成后才能获取,并且要在查询时指定当前组件上下文 .in(this)。
<scriptsetup>import { onMounted } from'vue'onMounted(() => {constquery=uni.createSelectorQuery().in(this) // 注意 .in(this) 指定当前组件query.select('#my-element').boundingClientRect(rect=> {console.log('元素位置', rect) }).exec()})</script>
常见错误:
-
忘记
.in(this)导致查询不到元素(返回 null 或 0)。 -
在
onLoad中查询,此时 DOM 可能还没渲染完成(页面级用onReady,组件级用onMounted)。 -
异步加载的数据渲染的元素,需要在数据更新后使用
nextTick再查询。
解决方案:使用 nextTick 确保 DOM 已更新。
import { nextTick } from'vue'nextTick(() => {// 查询操作})
跨平台注意:
-
小程序中,
uni.createSelectorQuery()返回的节点信息是准确的,但需要注意:隐藏元素(display: none)无法获取位置,会返回 0。如果需要获取隐藏元素的位置,可以用getSystemInfo等替代方案。
五、与 Vue3 子组件的差异总结
为了让大家更清晰,我把 UniApp 中的子组件与 Vue3 标准做对比:
| 方面 | Vue3 标准 | UniApp | 差异说明 |
|---|---|---|---|
| 语法 | 组合式 API 或选项式 | 完全支持组合式 API | 无差异 |
| props | defineProps | defineProps | 完全一致 |
| emits | defineEmits | defineEmits | 完全一致 |
| 插槽 | v-slot | 支持具名插槽和作用域插槽 | 完全一致 |
| 样式隔离 | scoped | scoped | 一致,但需注意小程序中可能不支持某些 CSS 选择器 |
| 全局事件 | mitt 等第三方库 | uni.$on/$off/$emit | UniApp 内置了事件总线,但用法类似 |
| 获取 DOM | this.$refs / useTemplateRef | uni.createSelectorQuery | 完全不同!Vue3 用 ref 获取组件或 DOM,UniApp 必须用查询 API |
| 生命周期 | onMounted, onUnmounted | onMounted, onUnmounted | 一致,但页面级还有 onLoad/onReady |
| 响应式 | ref, reactive | ref, reactive | 完全一致 |
核心差异:UniApp 中不能直接通过 ref 获取 DOM 元素并操作,必须用 uni.createSelectorQuery。这是为了跨平台兼容(小程序没有完整的 DOM)。
六、常见错误及解决方案(血泪经验)
💥 错误1:直接修改 props
// 子组件中错误做法props.goods.name='新名称'// 报错
原因:props 是只读的。解决方案:如果确实需要修改,可以复制一份到子组件的 data 中,或者通过 $emit 让父组件改。
💥 错误2:事件总线监听后没有销毁
onLoad() {uni.$on('some-event', this.handler)}// 忘记在 onUnload 中 uni.$off
后果:组件销毁后,事件监听依然存在,下次进入会再次绑定,导致多次触发。解决方案:成对使用,在 onUnload 中取消监听。
💥 错误3:选择器查询没有加 .in(this)
// 错误:没有 .in(this)uni.createSelectorQuery().select('#id').exec()
后果:在小程序中返回 null,在 H5 中可能也能工作但不可靠。解决方案:在组件内使用 .in(this)。
💥 错误4:在 onLoad 中查询元素位置
后果:此时 DOM 未渲染完成,得到的位置为 0。解决方案:在 onReady(页面)或 onMounted(组件)中查询,必要时配合 nextTick。
💥 错误5:ref 调用子组件方法时,子组件未暴露
// 父组件调用cardRef.value?.doSomething() // 报错:doSomething is not a function
原因:子组件没有用 defineExpose 暴露方法。解决方案:子组件中明确 defineExpose({ doSomething })。
💥 错误6:小程序中样式穿透失效
现象:使用 :deep() 修改子组件样式,在小程序中无效。原因:某些小程序平台对 :deep() 支持有限。解决方案:改用全局样式(不加 scoped),或者使用小程序的样式穿透写法(如 >>>),但推荐统一使用 :deep() 并在不同平台测试。
💥 错误7:provide 的数据不是响应式的
// 祖先组件provide('user', { name: '张三' }) // 非响应式
后果:后代组件修改数据不会同步。解决方案:传递 ref 或 reactive。
七、完整代码示例:商品卡片组件 + 列表页
下面我们通过一个完整的示例来串联今天学的知识。这个示例包含:
-
商品卡片组件(封装、props、emit、插槽、暴露方法、元素位置查询)
-
列表页(使用组件、ref 调用子组件方法、事件总线)
7.1 子组件 GoodsCard.vue
<template><viewclass="goods-card":id="'card-' + goods.id"><image:src="goods.image"mode="aspectFill"class="goods-image"/><viewclass="goods-info"><textclass="goods-name">{{ goods.name }}</text><textv-if="showPrice"class="goods-price">¥{{ goods.price }}</text></view><viewclass="card-footer"><!-- 默认插槽:可放自定义按钮 --><slotname="footer"><!-- 默认显示加入购物车按钮 --><buttonclass="add-btn"@click="onAddToCart">加入购物车</button></slot></view><!-- 用于演示元素位置获取的小红点 --><viewclass="dot"ref="dotRef"></view></view></template><scriptsetup>import { ref, onMounted } from'vue'constprops=defineProps({goods: {type: Object,required: true },showPrice: {type: Boolean,default: true }})constemit=defineEmits(['add-to-cart'])constonAddToCart= () => {emit('add-to-cart', props.goods.id)}// 暴露一个方法给父组件,用于获取小红点的位置constdotRef=ref(null)constgetDotPosition= () => {returnnewPromise((resolve) => {// 注意:必须加 .in(this)constquery=uni.createSelectorQuery().in(this)query.select('.dot').boundingClientRect(rect=> {resolve(rect) }).exec() })}// 暴露方法defineExpose({getDotPosition})// 组件挂载后获取卡片自身位置(演示)onMounted(() => {constquery=uni.createSelectorQuery().in(this)query.select('.goods-card').boundingClientRect(rect=> {console.log('卡片位置', rect) }).exec()})</script><stylescoped>.goods-card {display: flex;padding: 20rpx;border-bottom: 1rpxsolid#eee;background-color: #fff;position: relative;}.goods-image {width: 160rpx;height: 160rpx;margin-right: 20rpx;}.goods-info {flex: 1;display: flex;flex-direction: column;justify-content: center;}.goods-name {font-size: 28rpx;color: #333;margin-bottom: 10rpx;}.goods-price {font-size: 32rpx;color: #ff5500;font-weight: bold;}.card-footer {margin-left: auto;display: flex;align-items: center;}.add-btn {background-color: #ff5500;color: #fff;font-size: 24rpx;padding: 10rpx20rpx;border-radius: 30rpx;}.dot {width: 10px;height: 10px;background-color: red;position: absolute;right: 0;bottom: 0;}</style>
7.2 父组件 GoodsList.vue
<template><viewclass="goods-list"><scroll-viewscroll-yclass="scroll-view"><GoodsCardv-for="item in goodsList":key="item.id":goods="item":show-price="true"@add-to-cart="onAddToCart"ref="cardRefs"><!-- 使用插槽自定义底部 --><template#footer><viewclass="custom-footer"><buttonclass="buy-btn"@click="onBuyNow(item.id)">立即购买</button><buttonclass="fav-btn"@click="onFavorite(item.id)">收藏</button></view></template></GoodsCard></scroll-view><!-- 底部购物车图标,用于演示事件总线和导航 --><viewclass="cart-icon"id="cart-icon"@click="goToCart"><uni-iconstype="cart-filled"size="30"color="#ff5500"/><textv-if="cartCount"class="badge">{{ cartCount }}</text></view><!-- 测试按钮:获取第一个卡片小红点位置 --><buttonclass="test-btn"@click="testGetPosition">测试获取第一个卡片红点位置</button></view></template><scriptsetup>import { ref, onLoad, onUnload } from'@dcloudio/uni-app'importGoodsCardfrom'@/components/GoodsCard.vue'constgoodsList=ref([ { id: 1, name: '商品1', price: 99, image: '/static/goods1.png' }, { id: 2, name: '商品2', price: 199, image: '/static/goods2.png' }])constcartCount=ref(0)constcardRefs=ref([]) // 用于存储所有卡片组件的引用// 监听子组件的 add-to-cart 事件constonAddToCart= (goodsId) => {console.log('加入购物车', goodsId)cartCount.value++// 可以在这里触发动画等}// 立即购买constonBuyNow= (id) => {console.log('立即购买', id)}// 收藏constonFavorite= (id) => {console.log('收藏', id)}// 测试调用子组件方法获取位置consttestGetPosition=async () => {if (cardRefs.value.length>0) {constfirstCard=cardRefs.value[0]constpos=awaitfirstCard.getDotPosition()console.log('第一个卡片红点位置', pos)uni.showToast({ title: `位置:${pos.left},${pos.top}`, icon: 'none' }) }}// 事件总线:监听购物车更新事件(假设另一个组件会触发)onLoad(() => {uni.$on('cart-updated', (data) => {console.log('购物车更新', data)cartCount.value=data.count })})onUnload(() => {uni.$off('cart-updated')})// 触发兄弟通信constgoToCart= () => {uni.$emit('cart-updated', { count: cartCount.value })uni.navigateTo({ url: '/pages/cart/cart' })}</script><stylescoped>.goods-list {height: 100vh;position: relative;}.scroll-view {height: calc(100vh - 120rpx);}.cart-icon {position: fixed;bottom: 30rpx;right: 30rpx;width: 100rpx;height: 100rpx;background-color: #fff;border-radius: 50%;box-shadow: 04rpx20rpxrgba(0,0,0,0.1);display: flex;align-items: center;justify-content: center;z-index: 100;}.badge {position: absolute;top: 0;right: 0;background-color: #ff5500;color: #fff;font-size: 20rpx;padding: 4rpx8rpx;border-radius: 20rpx;}.test-btn {position: fixed;bottom: 150rpx;right: 30rpx;z-index: 200;background-color: #07c160;color: #fff;font-size: 24rpx;padding: 10rpx20rpx;border-radius: 30rpx;}.custom-footer {display: flex;gap: 10rpx;}.buy-btn {background-color: #ff5500;color: #fff;font-size: 24rpx;padding: 10rpx20rpx;border-radius: 30rpx;}.fav-btn {background-color: #ffaa00;color: #fff;font-size: 24rpx;padding: 10rpx20rpx;border-radius: 30rpx;}</style>
7.3 示例图说明
为了帮助理解,这里描述一下页面的布局:
+-----------------------------------+| [商品1图片] 商品1 ¥99 || [立即购买][收藏] |+-----------------------------------+| [商品2图片] 商品2 ¥199 || [立即购买][收藏] |+-----------------------------------+| || (底部购物车图标) |+-----------------------------------+
底部购物车图标固定,点击可跳转。右上角测试按钮用于获取第一个卡片的小红点位置。
八、总结与最后叮嘱
今天我们系统地学习了 UniApp 中组件封装和通信的方方面面,并重点对比了与 Vue3 的差异,以及跨平台需要注意的坑点。
核心要点回顾:
-
封装组件:props、emit、插槽、
defineExpose。 -
通信方式:父子(props/emit)、兄弟(事件总线)、跨级(provide/inject)、状态管理(Pinia)。
-
方法调用:
ref+defineExpose。 -
元素位置:
uni.createSelectorQuery().in(this),注意生命周期。 -
跨平台差异:主要在 DOM 查询和样式穿透上。
最后送大家一句话:组件化开发的核心是“高内聚,低耦合”。设计组件时,想清楚它需要什么数据、需要对外暴露什么行为,然后用合适的通信方式将它们连接起来。而跨平台开发,则是要多一份敬畏心,多测试,多总结。
希望这篇教程能帮你少踩坑,多产出。如果在实际开发中遇到问题,欢迎带着代码来问我。
—— 一个喜欢琢磨组件设计、跨平台坑踩遍的老前辈 !
加油,未来的全栈大佬!💪如果你也对移动端跨端开发感兴趣,关注我,后续还有更多优质文章分享!


往期相关文章推荐
夜雨聆风
