随着小程序业务逻辑的日益复杂,单一页面内堆砌全部 UI 与交互逻辑的开发模式已难以维护。组件化架构成为构建中大型小程序项目的必然选择。本文将从小程序自定义组件的核心机制出发,逐步深入到 Behavior 复用、Slot 插槽、组件间通信,以及组件库的工程化建设,提供一套完整的小程序组件化设计方法论。
一、组件化设计原则
在小程序中推行组件化,需要遵循几个核心原则:单一职责、封装隔离、可组合性和可复用性。每个组件应只负责一块独立的 UI 区域与对应的交互逻辑,对外通过 Properties 暴露配置接口,通过 Events 暴露回调接口,内部状态对外不可见。
以电商小程序为例,页面可以拆解为以下组件层级:
product-detail-page
├── product-swiper(商品轮播图)
├── price-display(价格展示)
├── sku-selector(规格选择器)
├── stock-indicator(库存提示)
├── action-bar(底部操作栏)
│ ├── favorite-btn(收藏按钮)
│ ├── cart-btn(购物车按钮)
│ └── buy-btn(购买按钮)
└── review-list(评价列表)
├── review-card(单条评价)
└── star-rating(星级评分)
这种树状结构清晰反映了页面的组件依赖关系。父组件通过属性向子组件传递数据,子组件通过事件向父组件报告状态变化,形成单向数据流。
二、自定义组件基础
2.1 组件文件结构
自定义组件由四个必需文件组成,存放于独立的组件目录中:
components/star-rating/
├── star-rating.js # 组件逻辑
├── star-rating.json # 组件配置
├── star-rating.wxml # 组件模板
└── star-rating.wxss # 组件样式
组件需在页面的 JSON 配置中声明后方能使用:
{
"usingComponents": {
"star-rating": "/components/star-rating/star-rating"
}
}
2.2 组件核心配置
// components/star-rating/star-rating.js
Component({
options: {
// 样式隔离策略
// isolated: 完全隔离(默认)
// apply-shared: 组件影响页面外的样式,页面影响组件内的样式
// shared: 双向共享
styleIsolation: 'isolated',
// 允许多个 slot
multipleSlots: true,
// 使组件支持外部类(方便父组件覆写样式)
addGlobalClass: true,
// 纯数据字段(不用于渲染,不触发 setData 的观察者)
pureDataPattern: /^_/
},
externalClasses: ['rating-class', 'star-class'],
properties: {
score: {
type: Number,
value: 0,
observer(newVal, oldVal) {
this._updateDisplay(newVal);
}
},
maxScore: {
type: Number,
value: 5
},
size: {
type: String,
value: 'medium' // small | medium | large
},
interactive: {
type: Boolean,
value: false
},
// 对象类型的属性需要指定 observer 深度
config: {
type: Object,
value: {}
}
},
data: {
fullStars: 0,
hasHalfStar: false,
emptyStars: 0,
_internalCache: null // pure data,不触发渲染更新
},
lifetimes: {
created() {
// 组件实例被创建,此时还不能调用 setData
console.log('[star-rating] created');
},
attached() {
// 组件进入页面节点树
this._updateDisplay(this.data.score);
},
ready() {
// 组件布局完成,可以获取节点信息
},
detached() {
// 组件被移除,清理资源
clearTimeout(this._debounceTimer);
},
error(err) {
console.error('[star-rating] error:', err);
}
},
pageLifetimes: {
show() {
// 所在页面显示时触发
},
hide() {
// 所在页面隐藏时触发
}
},
methods: {
_updateDisplay(score) {
const { maxScore } = this.data;
const clamped = Math.max(0, Math.min(score, maxScore));
const full = Math.floor(clamped);
const hasHalf = clamped - full >= 0.5;
this.setData({
fullStars: full,
hasHalfStar: hasHalf,
emptyStars: maxScore - full - (hasHalf ? 1 : 0)
});
},
onTapStar(e) {
if (!this.data.interactive) return;
const { index } = e.currentTarget.dataset;
const newScore = index + 1;
// 触发 change 事件给父组件
this.triggerEvent('change', { score: newScore }, { bubbles: false });
// 更新内部显示
this._updateDisplay(newScore);
},
// 提供给父组件调用的方法
reset() {
this._updateDisplay(0);
}
}
});
组件的 properties 定义了外部可配置的属性,支持 String、Number、Boolean、Object、Array 和 null 六种类型。observer 函数在属性值变化时触发,但应注意避免在 observer 中再次修改被观察的属性,否则可能导致无限递归。
lifetimes 中的生命周期函数与页面生命周期相对应,但粒度更细。created 时组件实例虽已创建,但 DOM 尚未挂载,不能调用 setData;attached 时组件已进入页面节点树,此时可以安全初始化数据;ready 时所有子组件也已准备就绪,可以执行依赖节点信息的操作;detached 是释放资源(定时器、事件监听、网络请求)的最后时机。
2.3 组件模板编写
<!-- components/star-rating/star-rating.wxml -->
<view class="star-rating rating-class {{size}} {{interactive ? 'interactive' : ''}}">
<slot name="prefix" />
<view class="stars" bindtap="onTapStar">
<!-- 满星 -->
<view
class="star star-class full"
wx:for="{{fullStars}}"
wx:key="index"
data-index="{{index}}"
>
<image src="/assets/star-full.png" mode="aspectFit" />
</view>
<!-- 半星 -->
<view class="star star-class half" wx:if="{{hasHalfStar}}">
<image src="/assets/star-half.png" mode="aspectFit" />
</view>
<!-- 空星 -->
<view
class="star star-class empty"
wx:for="{{emptyStars}}"
wx:key="index"
data-index="{{fullStars + (hasHalfStar ? 1 : 0) + index}}"
>
<image src="/assets/star-empty.png" mode="aspectFit" />
</view>
</view>
<text class="score-text" wx:if="{{score > 0}}">{{score}}分</text>
<slot name="suffix" />
</view>
组件模板中使用了 slot 机制预留占位区域,使父组件可以灵活地在评分前后插入自定义内容。这体现了组件设计的可扩展性——核心功能内置,扩展点通过 Slot 开放。
三、Behavior 行为复用
当多个组件需要共享相同的逻辑(如日志记录、表单校验、动画效果)时,小程序提供了 Behavior 机制来实现代码复用,类似于 Vue 的 mixins 或 React 的高阶组件。
3.1 定义与使用 Behavior
// behaviors/trackable.js
module.exports = Behavior({
properties: {
trackId: String,
trackParams: {
type: Object,
value: {}
}
},
data: {
_trackStartTime: 0
},
lifetimes: {
attached() {
this._trackStartTime = Date.now();
this._trackEvent('component_view');
},
detached() {
const duration = Date.now() - this.data._trackStartTime;
this._trackEvent('component_leave', { duration });
}
},
methods: {
_trackEvent(eventName, extra = {}) {
if (!this.data.trackId) return;
const params = {
event: eventName,
component: this.is,
trackId: this.data.trackId,
...this.data.trackParams,
...extra
};
// 上报到埋点服务
wx.request({
url: 'https://analytics.example.com/track',
method: 'POST',
data: params
});
},
trackClick(action, detail = {}) {
this._trackEvent('component_click', { action, detail });
}
}
});
// components/buy-button/buy-button.js
const trackable = require('../../behaviors/trackable');
Component({
behaviors: [trackable],
properties: {
productId: String,
price: Number
},
methods: {
onTap() {
this.trackClick('buy', { productId: this.data.productId });
this.triggerEvent('buy', { productId: this.data.productId });
}
}
});
一个组件可以引入多个 Behavior,各自提供独立的属性、数据和方法。当多个 Behavior 或 Behavior 与组件自身定义同名属性时,小程序按照特定的优先级算法进行合并:组件本身 > 最后一个 Behavior > 倒数第二个 Behavior > … > 第一个 Behavior。
3.2 Behavior 的组合策略
对于大型组件库,推荐将通用能力抽象为细粒度 Behavior:
| Behavior | 职责 |
|---|---|
trackable | 埋点追踪,自动上报曝光与点击 |
validatable | 表单校验,支持规则配置与错误提示 |
animatable | 动画管理,封装 Animation 实例 |
scrollable | 滚动监听,自动触发上拉加载与下拉刷新 |
// behaviors/validatable.js
module.exports = Behavior({
properties: {
rules: {
type: Array,
value: []
},
required: Boolean
},
data: {
_error: ''
},
methods: {
validate() {
const value = this.getValue?.();
if (this.data.required && !value) {
this.setData({ _error: '此字段为必填项' });
return false;
}
for (const rule of this.data.rules) {
if (rule.validator && !rule.validator(value)) {
this.setData({ _error: rule.message });
return false;
}
}
this.setData({ _error: '' });
return true;
},
getError() {
return this.data._error;
},
clearError() {
this.setData({ _error: '' });
}
}
});
四、Slot 插槽机制
Slot 插槽是实现组件内容分发的关键机制,小程序支持单 Slot 和多 Slot 两种模式。
4.1 默认插槽与具名插槽
// components/drawer/drawer.js
Component({
options: {
multipleSlots: true // 必须声明才能使用多个 slot
},
properties: {
visible: Boolean,
title: String,
position: {
type: String,
value: 'bottom' // top | right | bottom | left
}
}
});
<!-- components/drawer/drawer.wxml -->
<view class="drawer-mask {{visible ? 'show' : ''}}" catchtap="onClose"></view>
<view class="drawer drawer-{{position}} {{visible ? 'show' : ''}}">
<view class="drawer-header" wx:if="{{title}}">
<text class="title">{{title}}</text>
<slot name="header-action" />
</view>
<view class="drawer-body">
<slot />
</view>
<view class="drawer-footer">
<slot name="footer" />
</view>
</view>
<!-- 使用抽屉组件 -->
<drawer visible="{{showFilter}}" title="筛选条件" position="right">
<view slot="header-action">
<text class="reset-btn" bindtap="resetFilters">重置</text>
</view>
<view class="filter-content">
<filter-group title="价格区间" options="{{priceOptions}}" />
<filter-group title="品牌" options="{{brandOptions}}" />
<filter-group title="排序方式" options="{{sortOptions}}" />
</view>
<view slot="footer">
<button class="confirm-btn" bindtap="applyFilters">确认筛选</button>
</view>
</drawer>
通过 name 属性定义的具名插槽(header-action、footer)使父组件可以精确控制组件各区域的内容。未命名 slot 称为默认插槽,父组件中不在 <view slot="xxx"> 标签内的内容将填充到默认插槽位置。
五、组件间通信
小程序中组件间的通信可以分为父子通信、兄弟通信和跨层级通信三种场景。
5.1 父子通信
父子通信是最常见的场景。父传子通过 Properties,子传父通过 Events:
// 子组件派发事件
this.triggerEvent('submit', { formData: this.data }, { bubbles: true, composed: true });
// bubbles: 事件是否冒泡
// composed: 事件是否跨越组件边界
<!-- 父组件监听事件 -->
<address-form bind:submit="onAddressSubmit" bind:error="onFormError" />
5.2 兄弟组件通信
兄弟组件无法直接通信,通常需要借助父组件中转。但对于频繁交互的兄弟组件(如表单中的联动字段),可以通过共享一个共同的父组件状态来同步:
// 父组件作为状态容器
Component({
data: {
formState: {
province: '',
city: '',
district: '',
address: ''
}
},
methods: {
updateField({ detail }) {
this.setData({
[`formState.${detail.field}`]: detail.value
});
// 子组件通过 properties 绑定 formState 的对应字段
}
}
});
5.3 跨层级通信与全局事件
对于深层嵌套或跨页面的通信需求,可以使用小程序的页面实例或全局事件总线:
// utils/event-bus.js
class EventBus {
constructor() {
this._events = {};
}
on(event, callback) {
if (!this._events[event]) this._events[event] = [];
this._events[event].push(callback);
return () => this.off(event, callback);
}
off(event, callback) {
if (!this._events[event]) return;
this._events[event] = this._events[event].filter(cb => cb !== callback);
}
emit(event, data) {
if (!this._events[event]) return;
this._events[event].forEach(cb => {
try { cb(data); } catch (e) { console.error(e); }
});
}
}
module.exports = new EventBus();
// 组件 A 发布事件
const eventBus = require('../../utils/event-bus');
eventBus.emit('cart:updated', { count: 3 });
// 组件 B 订阅事件(需在 detached 中取消订阅)
const eventBus = require('../../utils/event-bus');
Component({
attached() {
this._unsubscribe = eventBus.on('cart:updated', ({ count }) => {
this.setData({ cartCount: count });
});
},
detached() {
this._unsubscribe?.();
}
});
六、组件库工程化
当组件数量超过 20 个时,手动管理组件目录、文档和版本会变得异常困难。建立组件库的工程化流程是维持代码质量的必要条件。
6.1 目录与命名规范
components/
├── button/ # 基础组件
├── input/
├── toast/
├── loading/
├── drawer/ # 复合组件
├── sku-selector/
├── image-uploader/
├── behaviors/ # 公共行为
│ ├── trackable.js
│ ├── validatable.js
│ └── animatable.js
└── index.js # 组件导出
组件命名采用小写短横线连接(kebab-case),JavaScript 文件中以驼峰命名引用。基础组件保持简单单一,复合组件可组合多个基础组件形成高阶功能。
6.2 按需加载策略
小程序支持 lazyCodeLoading: "requiredComponents" 配置,使自定义组件在首次被使用时才注入代码。对于大型组件库,配合构建工具的 Tree Shaking 能力(如 Webpack 或 Gulp),可以在打包阶段剔除未使用的组件代码,有效降低主包体积。
6.3 文档与用例
每个组件应配备独立的 Markdown 文档,包含 Props 定义、Events 定义、Slots 定义和使用示例。可以参考 Storybook 的理念,在小程序中搭建组件预览页面:
{
"pages": [
"pages/component-showcase/index",
"pages/component-showcase/button",
"pages/component-showcase/toast"
]
}
每个预览页面独立展示组件的各种用法和边界情况,既方便开发者查阅,也作为视觉回归测试的基线。
七、最佳实践与常见陷阱
7.1 样式隔离策略选择
| 策略 | 场景 | 风险 |
|---|---|---|
isolated | 通用组件库 | 无法覆写内部样式 |
apply-shared | 需要外部样式注入 | 样式污染风险低 |
shared | 主题定制需求强烈 | 全局样式冲突风险高 |
推荐基础组件使用 isolated 保证封装性,通过 externalClasses 提供有限的样式扩展点;业务组件根据主题需求选择 apply-shared。
7.2 setData 性能优化
组件与页面共享相同的 setData 通信机制,高频调用会导致 Bridge 拥塞:
// 避免:逐个更新
this.setData({ 'items[0].name': 'A' });
this.setData({ 'items[1].name': 'B' });
this.setData({ 'items[2].name': 'C' });
// 推荐:批量合并
const updates = {};
updates['items[0].name'] = 'A';
updates['items[1].name'] = 'B';
updates['items[2].name'] = 'C';
this.setData(updates);
// 避免:传递大对象
this.setData({ hugeList: this.data.hugeList });
// 推荐:使用纯数据字段或简化结构
this.setData({ displayList: simplifiedList });
7.3 组件卸载清理
未清理的定时器和事件监听是小程序内存泄漏的首要原因。所有在 attached 或 ready 中注册的资源,必须在 detached 中逐一释放:
lifetimes: {
attached() {
this._pollTimer = setInterval(() => this.pollData(), 5000);
this._observer = wx.createIntersectionObserver(this);
this._observer.relativeToViewport().observe('.target', (res) => {
this.setData({ visible: res.intersectionRatio > 0 });
});
},
detached() {
clearInterval(this._pollTimer);
this._observer?.disconnect?.();
this._unsubscribe?.();
}
}
八、总结
组件化是小程序应对复杂度增长的必要武器。从单个自定义组件的开发,到 Behavior 行为的复用,再到 Slot 插槽实现内容分发,小程序提供了一套完整的组件化工具链。配合工程化的目录组织、按需加载策略和完善的文档体系,开发者可以构建出易于维护、高效复用的小程序组件库。
在大规模应用中,组件间的数据流向设计比组件本身的实现更为重要。单向数据流、明确的 Props/Events 边界、统一的状态管理策略,这些架构层面的决策直接影响项目的长期可维护性。当组件数量膨胀到难以驾驭时,引入状态管理框架(将在下一篇文章中讨论)将是自然的选择。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。