本节导读:选型之后是封装。本节把商城首页的商品卡片抽成自定义组件,沿线讲 props 的单向数据流、事件的命名与传参、插槽的差异化布局、跨层级的通信手段,最后交代动态组件与异步组件在小程序端的能力边界与替代方案。读完你应当能设计一套三端行为一致的组件通信结构。
首页瀑布流里每张商品卡片的结构完全一致,运营却要求"猜你喜欢"与"限时秒杀"两个板块的卡片长得有差别。团队的第一刀切下去:结构公共部分抽成 goods-card 组件,差异区域留插槽。动手前先定通信契约——数据从哪进、行为往哪报、差异怎么留口。这三件事对应 props、事件与插槽,是组件通信的三大主通道,先把组件骨架立起来:
<!-- components/goods-card/goods-card.vue --> <template> <view class="card" @tap="onTap"> <image class="cover" :src="goods.cover" mode="aspectFill" /> <view class="info"> <text class="name">{{ goods.name }}</text> <text class="price">¥{{ goods.price }}</text> </view> <!-- 差异区域:秒杀板块在这里塞倒计时,猜你喜欢塞推荐语 --> <view class="extra"> <slot name="extra"> <text v-if="goods.tag" class="tag">{{ goods.tag }}</text> </slot> </view> </view> </template> <script> export default { name: 'goods-card', props: { goods: { type: Object, required: true }, clickable: { type: Boolean, default: true } }, emits: ['pick'], methods: { onTap() { if (!this.clickable) return; // 组件只报行为,不带业务路由——去哪个页面由页面自己决定 this.$emit('pick', { id: this.goods.id, from: 'card' }); } } }; </script>
这份骨架里有三个值得抄走的决定。其一,props 只收数据不带回调,组件不反向调用页面逻辑;其二,事件名用小写单词加 pick 这种动词,不拼 card-click 之类的复合描述——事件是行为通知不是样式说明;其三,emits 声明写全,Vue3 工程里未声明的事件会透传到根元素,排查起来极其隐蔽。
props 的铁律是单向:页面把 goods 传进卡片,卡片想改数量,正确做法不是直接改 goods.num,而是把意图上报给页面,由页面改完再传回来。初学者常在子组件里写 this.goods.num += 1,H5 端看着"也能跑",到小程序端某些写法直接静默失败,而且任何工具都查不出这种"反向改数"的所有权混乱。规范写法:
<!-- 页面侧 --> <goods-card :goods="item" @pick="onPick" @count-change="onCountChange" />
// 页面持有数据与修改权 export default { methods: { onCountChange({ id, delta }) { const target = this.list.find(g => g.id === id); if (target) target.num = Math.max(1, target.num + delta); } } };
跨层级的场景(深层子组件要通知顶层弹窗、多个页面共享的角标状态)分两档处理:跨一两层用 provide 与 inject,祖先声明、后代取用,适合主题配置、表单上下文这类"环境变量";跨页面或全局共享进状态管理,第 5 章的 Pinia 接管。在组件通信里引入全局状态要克制——组件一旦依赖全局 store,复用性就打了折,商业组件库里常见折中是 props 给默认值、store 只做兜底。
默认插槽、具名插槽、作用域插槽三种形态在 uni-app 各端都支持,但小程序端的插槽是"按编译产物展开"的,两个习惯要养成:一是插槽内容里不要依赖页面作用域的复杂表达式,二是作用域插槽传出去的数据保持扁平(对象一层,别嵌三层),因为小程序端作用域插槽的数据经编译器中转,深层结构在部分基础库版本上表现不稳。回到商城案例,秒杀板块这样消费具名插槽:
<goods-card :goods="flashGoods"> <template v-slot:extra> <view class="countdown"> <text class="cd-num">{{ hh }}</text> <text class="cd-sep">:</text> <text class="cd-num">{{ mm }}</text> </view> </template> </goods-card>
倒计时的状态归页面管,卡片只留口子——组件越"笨",复用面越宽。
Vue 的动态组件 <component :is="..."> 在 H5 端畅通无阻,到了小程序端就有一条硬边界:is 绑定的组件必须是页面里已注册的组件,且部分端的编译产物不支持把页面级组件当动态组件切换。异步组件同理,defineAsyncComponent 这类按需加载手段主要服务于 H5 端拆包,小程序端的分包才是官方指定的减载体积通道(第 4 章展开)。工程上用两个替代方案覆盖绝大多数需求:
<template> <!-- 方案一:组件数量有限且已知,v-if 分支最稳 --> <goods-card v-if="mode === 'card'" :goods="goods" /> <goods-row v-else :goods="goods" /> </template>
// 方案二:真需要按需加载时,把"晚一点渲染"做成显示控制 export default { data() { return { heavyPanelReady: false }; }, mounted() { // 首屏渲染完成后再挂重组件,效果接近异步组件 setTimeout(() => { this.heavyPanelReady = true; }, 300); } };
前者牺牲一点灵活性换三端确定性,后者把"延迟加载"翻译成"延迟渲染",绕开各端加载机制的差异。商城的抽奖大转盘组件就是方案二的受益者:首屏只渲染占位骨架,转盘库在首页可交互后才挂载,首屏时间没有因为重组件劣化。
组件结构与通信立住了,下一节解决"长得对"的最后一块拼图:rpx 的换算规则、样式隔离,以及要不要引 UI 框架的取舍。