乐于分享
好东西不私藏

手把手带你深度解剖Uniapp子组件封装及其应用开发实战教程

手把手带你深度解剖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: {typeObject,requiredtrue  },showPrice: {typeBoolean,defaulttrue  }})</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 { border1pxsolid#eee; }</style>

跨平台注意

  • 在 H5 中,scoped 通过添加 data-v-xxx 属性实现。

  • 在小程序中,scoped 同样有效,但需要注意:scoped 样式不会影响子组件的根元素,这是 Vue 的规则,与平台无关。

  • 如果需要修改子组件内部的样式(比如第三方组件),可以使用 :deep() 深度选择器。

:deep(.inner-class) {colorred;}

三、组件间通信的各种姿势

3.1 父子通信(最基础)

  • 父传子:props

  • 子传父:$emit

上面已经讲过,不再赘述。

3.2 兄弟组件通信

兄弟组件不能直接通信,通常有两种方式:

  • 通过共同的父组件作为桥梁:子 A 触发事件,父组件接收后修改数据,再通过 props 传递给子 B。

  • 事件总线(Event Bus):创建一个全局的事件中心,组件之间通过它来通信。

在 uni-app 中,可以使用 uni.$onuni.$emituni.$off 来实现事件总线。

示例:组件 A 触发一个事件,组件 B 监听。

// 组件 Auni.$emit('update-cart', { count1 })// 组件 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 { provideref } 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 { refonMounted } from'vue'constprops=defineProps({goods: {typeObject,requiredtrue  },showPrice: {typeBoolean,defaulttrue  }})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 {displayflex;padding20rpx;border-bottom1rpxsolid#eee;background-color#fff;positionrelative;}.goods-image {width160rpx;height160rpx;margin-right20rpx;}.goods-info {flex1;displayflex;flex-directioncolumn;justify-contentcenter;}.goods-name {font-size28rpx;color#333;margin-bottom10rpx;}.goods-price {font-size32rpx;color#ff5500;font-weightbold;}.card-footer {margin-leftauto;displayflex;align-itemscenter;}.add-btn {background-color#ff5500;color#fff;font-size24rpx;padding10rpx20rpx;border-radius30rpx;}.dot {width10px;height10px;background-colorred;positionabsolute;right0;bottom0;}</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 { refonLoadonUnload } from'@dcloudio/uni-app'importGoodsCardfrom'@/components/GoodsCard.vue'constgoodsList=ref([  { id1name'商品1'price99image'/static/goods1.png' },  { id2name'商品2'price199image'/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', { countcartCount.value })uni.navigateTo({ url'/pages/cart/cart' })}</script><stylescoped>.goods-list {height100vh;positionrelative;}.scroll-view {heightcalc(100vh - 120rpx);}.cart-icon {positionfixed;bottom30rpx;right30rpx;width100rpx;height100rpx;background-color#fff;border-radius50%;box-shadow04rpx20rpxrgba(0,0,0,0.1);displayflex;align-itemscenter;justify-contentcenter;z-index100;}.badge {positionabsolute;top0;right0;background-color#ff5500;color#fff;font-size20rpx;padding4rpx8rpx;border-radius20rpx;}.test-btn {positionfixed;bottom150rpx;right30rpx;z-index200;background-color#07c160;color#fff;font-size24rpx;padding10rpx20rpx;border-radius30rpx;}.custom-footer {displayflex;gap10rpx;}.buy-btn {background-color#ff5500;color#fff;font-size24rpx;padding10rpx20rpx;border-radius30rpx;}.fav-btn {background-color#ffaa00;color#fff;font-size24rpx;padding10rpx20rpx;border-radius30rpx;}</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 查询和样式穿透上。

最后送大家一句话:组件化开发的核心是“高内聚,低耦合”。设计组件时,想清楚它需要什么数据、需要对外暴露什么行为,然后用合适的通信方式将它们连接起来。而跨平台开发,则是要多一份敬畏心,多测试,多总结。

希望这篇教程能帮你少踩坑,多产出。如果在实际开发中遇到问题,欢迎带着代码来问我。

—— 一个喜欢琢磨组件设计、跨平台坑踩遍的老前辈 !

加油,未来的全栈大佬!💪如果你也对移动端跨端开发感兴趣,关注我,后续还有更多优质文章分享!

往期相关文章推荐

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 手把手带你深度解剖Uniapp子组件封装及其应用开发实战教程

评论 抢沙发

8 + 1 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮