做设置页面时,最容易遇到的重复劳动就是卡片式布局:标题、描述、右侧一个开关或箭头,几乎每个页面都长一个样。一开始复制粘贴同一段结构代码还能接受,等页面多起来,想统一调整样式就得每个文件改一遍,改到怀疑人生。这种场景下,组件化就是最自然的解法。ASCF 支持自定义组件,把公共 UI 抽出来,所有页面复用,改一处就全局生效。
自定义组件的基础结构
一个自定义组件由 json + hxml + css + js 四件套组成,和 Page 相比只有两点区别:
- components/
- my-card/
- my-card.json # 声明 component: true
- my-card.hxml # 模板
- my-card.css # 样式
- my-card.js # 逻辑(Component 构造器)
复制代码
json 里多了一个 "component": true,js 里改用 Component({...}) 而不是 Page({...}),其他能力——数据绑定、事件处理、setData、生命周期——都和 Page 保持一致。
实现一个基础卡片组件
以卡片组件为例,需求是卡片包含标题、描述和右侧内容区,右侧可以由父页面自由塞入开关、箭头或其他内容。
先写 json 声明:
这一句就是告诉框架:这不是页面,这是组件。
组件逻辑文件:
- Component({
- properties: {
- title: {
- type: String,
- value: '',
- },
- desc: {
- type: String,
- value: '',
- },
- // 右侧是否显示箭头
- showArrow: {
- type: Boolean,
- value: false,
- },
- },
- data: {},
- methods: {
- onCardTap() {
- // 触发自定义事件,父页面监听
- this.triggerEvent('cardtap', { title: this.properties.title });
- },
- },
- });
复制代码
properties 就是组件的对外接口:父组件传什么值,组件收到后在模板中展示。type 限制数据类型,value 设置默认值。
模板文件:
- <view class="my-card" bindtap="onCardTap">
- <view class="card-body">
- <view class="card-info">
- <text class="card-title" has:if="{{title}}">{{title}}</text>
- <text class="card-desc" has:if="{{desc}}">{{desc}}</text>
- </view>
- <view class="card-right">
- <slot></slot>
- <image has:if="{{showArrow}}" class="card-arrow"
- src="" />
- </view>
- </view>
- </view>
复制代码
slot 是插槽,父组件可以在组件标签中间塞内容,比如在卡片右侧放一个 switch。
样式文件:
- .my-card {
- background: #fff;
- border-radius: 12px;
- padding: 16px;
- margin-bottom: 12px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
- }
- .card-body {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .card-info {
- flex: 1;
- margin-right: 12px;
- }
- .card-title {
- font-size: 15px;
- color: #1a1a1a;
- font-weight: 500;
- }
- .card-desc {
- font-size: 12px;
- color: #999;
- margin-top: 4px;
- display: block;
- }
- .card-right {
- display: flex;
- align-items: center;
- }
- .card-arrow {
- width: 16px;
- height: 16px;
- margin-left: 8px;
- }
复制代码
到这里基础卡片组件就完成了。组件内部样式默认是隔离的,组件的 css 不会影响外部,外部的全局样式也不会污染组件内部,这套机制叫样式隔离。
在页面中使用自定义组件
页面里使用时,先要在页面的 json 中声明 usingComponents:
- {
- "usingComponents": {
- "my-card": "./components/my-card/my-card"
- }
- }
复制代码
然后在 hxml 中像使用基础组件一样直接写标签:
- <my-card title="消息推送" desc="接收订单状态通知" showArrow="{{true}}">
- <switch checked="{{pushEnabled}}" bindchange="onPushChange" />
- </my-card>
- <my-card title="夜间模式" desc="22:00-07:00 自动开启" showArrow="{{false}}">
- <switch checked="{{nightMode}}" />
- </my-card>
- <my-card title="关于应用" desc="当前版本 1.0.0" bindcardtap="onAboutTap">
- <!-- 不用 slot 内容也可以 -->
- </my-card>
复制代码
最终效果就是三张统一样式的卡片,每张卡片的右侧可以放不同内容:开关、箭头或者什么都不放。
这里有一个非常容易踩的坑:在页面 json 的 usingComponents 中漏写声明,直接在 hxml 写了 <my-card>,IDE 不报错,渲染出来却是空白。排查了半天,最后发现就是 usingComponents 没写。
properties:父组件向子组件传值
properties 是接收父组件数据的入口,支持简写和完整写法:
- Component({
- properties: {
- name: String, // 简写
- age: { type: Number, value: 0 }, // 完整写法
- user: { type: Object, value: {} },
- tags: { type: Array, value: [] },
- },
- });
复制代码
hxml 中使用驼峰命名绑定:
- <my-card user="{{userObj}}" age="{{25}}" />
复制代码
properties 的值在组件内部可以借助 this.properties.xxx 或 this.data.xxx 读取。要特别注意,properties 和 data 里的字段名不能相同,否则修改 properties 时会覆盖 data 的同名字段。这个问题在实际开发中出现过:在 data 里声明了与 properties 同名的字段,父组件传值后 data 里的值被覆盖,页面展示和预期完全对不上。
triggerEvent:子组件向父组件通信
子组件通过 this.triggerEvent 触发自定义事件,父组件监听后接收数据:
- // 子组件
- methods: {
- onTap() {
- this.triggerEvent('myevent', {
- value: '一些数据',
- timestamp: Date.now(),
- }, {
- bubbles: false, // 是否冒泡
- composed: false, // 是否穿越组件边界
- capturePhase: false, // 是否有捕获阶段
- });
- },
- },
复制代码- <!-- 父页面 -->
- <my-card bindmyevent="onMyEvent" />
- <!-- 或者 -->
- <my-card bind:myevent="onMyEvent" />
复制代码- // 父页面
- onMyEvent(e) {
- console.info('收到子组件事件:', e.detail);
- // e.detail = { value: '一些数据', timestamp: ... }
- }
复制代码
事件名命名上,驼峰写法在 bind 时用 bindmyevent,连字符写法用 bind:my-event,两者等价。团队协作时建议统一成一种风格。
slot 插槽:让父组件决定内容区
slot 就是在组件模板里预留的坑位,父组件往坑里填内容:
- <!-- 组件模板 -->
- <view class="my-card">
- <view class="card-header">
- <slot name="header"></slot>
- </view>
- <view class="card-body">
- <slot></slot> <!-- 默认插槽 -->
- </view>
- </view>
复制代码- <!-- 父页面 -->
- <my-card>
- <view slot="header">
- <text>自定义标题区域</text>
- </view>
- <switch checked="{{value}}" /> <!-- 放入默认插槽 -->
- </my-card>
复制代码
多个 slot 时用 name 区分,不指定 name 的就是默认插槽。注意,组件模板里必须有对应的 <slot> 标签,父组件传进来的子节点才会被渲染。
组件生命周期与页面生命周期
自定义组件有 4 个核心生命周期:
- Component({
- lifetimes: {
- created() {
- console.info('组件创建');
- // 不能 setData
- },
- attached() {
- console.info('组件进入页面');
- // 可以 setData 了
- },
- ready() {
- console.info('组件渲染完成');
- // 可以操作 DOM
- },
- detached() {
- console.info('组件移除');
- // 清理
- },
- },
- });
复制代码
lifetimes 写法优先级最高。如果同时写了顶层 attached 和 lifetimes.attached,最终走的是 lifetimes 里的实现。
组件还可以监听所在页面的生命周期:
- Component({
- pageLifetimes: {
- show() {
- // 页面显示时触发
- console.info('所在页面显示');
- },
- hide() {
- // 页面隐藏时触发
- console.info('所在页面隐藏');
- },
- },
- });
复制代码
这个能力在特定场景下非常好用。比如计数器组件在页面切回来时需要刷新数据,直接在 pageLifetimes.show 里处理即可,不需要页面侧额外调用。
observers:数据监听器
observers 用于监听 properties 或 data 的变化:
- Component({
- properties: {
- count: { type: Number, value: 0 },
- },
- observers: {
- // 单个字段
- 'count': function(newVal, oldVal) {
- console.info('count 从', oldVal, '变为', newVal);
- },
- // 多个字段
- 'count, step': function(count, step) {
- this.setData({
- total: count * step,
- });
- },
- // 子字段
- 'user.name': function(name) {
- console.info('用户名变了:', name);
- },
- // 通配符:所有字段
- '**': function() {
- console.info('数据有变化');
- },
- },
- });
复制代码
observers 在组件 attached 阶段之后才会触发。如果在 created 里 setData,observers 不会执行。
实际开发中,做一个购物车组件时商品数量变化需要自动计算总价,就可以用 observers 监听 count 变化,一变更立即重新计算,省去手动调用的麻烦。
三种通信方式对比
除了 properties + triggerEvent,还有 selectComponent 可以获取子组件实例:
- // 父页面,通过 id 或 class 获取子组件实例
- const child = this.selectComponent('#my-counter');
- if (child) {
- console.info('子组件数据:', child.data);
- child.setData({ count: 100 }); // 直接操作子组件
- }
复制代码
但用 selectComponent 直接操作子组件会破坏封装性,应优先使用 properties + triggerEvent 的通信方式,selectComponent 留作兜底方案。
另外,如果组件需要在多个页面复用,可以在 app.json 的 usingComponents 里声明为全局组件,所有页面和组件都可以直接用,不需要每个页面单独引用。实际做设置页面时,把卡片组件全局注册后,其他页面直接写 <my-card> 就能用,省事不少。
完整示例:计数器组件
把上面的知识点串起来,做一个计数器组件:
my-counter.json:
my-counter.js:
- Component({
- properties: {
- count: {
- type: Number,
- value: 0,
- },
- min: { type: Number, value: 0 },
- max: { type: Number, value: 99 },
- step: { type: Number, value: 1 },
- },
- observers: {
- 'count, min, max': function(count, min, max) {
- // 父组件传了新的 count 时校验范围
- if (count < min || count > max) {
- console.warn('count 超出范围');
- }
- },
- },
- methods: {
- onDecrease() {
- const newVal = this.properties.count - this.properties.step;
- if (newVal >= this.properties.min) {
- this.triggerEvent('change', { value: newVal });
- }
- },
- onIncrease() {
- const newVal = this.properties.count + this.properties.step;
- if (newVal <= this.properties.max) {
- this.triggerEvent('change', { value: newVal });
- }
- },
- },
- });
复制代码
my-counter.hxml:
- <view class="counter">
- <view class="counter-btn" bindtap="onDecrease">-</view>
- <text class="counter-value">{{count}}</text>
- <view class="counter-btn" bindtap="onIncrease">+</view>
- </view>
复制代码
my-counter.css:
- .counter {
- display: flex;
- align-items: center;
- gap: 12px;
- }
- .counter-btn {
- width: 32px;
- height: 32px;
- border: 1px solid #ddd;
- border-radius: 50%;
- text-align: center;
- line-height: 32px;
- font-size: 18px;
- color: #333;
- }
- .counter-btn:active {
- background: #f0f0f0;
- }
- .counter-value {
- font-size: 16px;
- font-weight: 600;
- min-width: 24px;
- text-align: center;
- }
复制代码
父页面使用:
- {
- "usingComponents": {
- "my-counter": "./components/my-counter/my-counter"
- }
- }
复制代码- <my-counter count="{{cartCount}}" min="0" max="10"
- bindchange="onCountChange" />
复制代码- Page({
- data: { cartCount: 1 },
- onCountChange(e) {
- this.setData({ cartCount: e.detail.value });
- console.info('数量变为:', e.detail.value);
- },
- });
复制代码
这样一个可复用的计数器组件就做好了。properties 控制步长和范围,triggerEvent 通知父组件数量变化。
组件样式隔离与外部样式覆盖
默认情况下,组件样式是隔离的:组件内部 css 不影响外部,外部 css 也不影响组件内部(继承性属性如 color、font-size 除外)。
如果需要外部覆盖组件样式,可以用 externalClasses:
- Component({
- externalClasses: ['my-class', 'custom-class'],
- });
复制代码- <my-card my-class="custom-card-style" />
复制代码- /* 外部样式 */
- .custom-card-style {
- border-color: #0A59F7;
- }
复制代码
或者通过 options 调整隔离策略:
- Component({
- options: {
- styleIsolation: 'apply-shared', // 外部样式可以影响组件
- },
- });
复制代码
styleIsolation 有三个取值:
isolated:默认,完全隔离。
apply-shared:外部样式可以影响组件,但组件不影响外部。
shared:双向影响。
大部分场景默认的 isolated 已经够用。需要外部定制样式的组件,用 externalClasses 更干净,也不会破坏封装。
自定义组件和 Page 的定位差异
Page 是路由入口,Component 是 UI 零件。Page 通过组合多个 Component 来搭建页面。如果整个页面都用 Component 构造,URL 上的 query 参数会被自动赋值到 properties 里。比如访问 detail?paramA=abc,properties 里的 paramA 会自动收到 "abc",这个行为在实际开发中容易被忽略。
踩坑清单汇总
回顾整个实践过程,有几个问题值得记录:
1. usingComponents 忘了写。最常见的错误。在声明 usingComponents 之前直接写自定义组件标签,渲染不出来也不报错。排查时先确认 json 里有没有声明。
2. properties 和 data 字段名重复。同名字段会导致 properties 覆盖 data 的值,写组件时注意命名区分。
3. created 里不能 setData。created 阶段组件还没进入页面节点树,调 setData 会报错。初始数据在 created 里直接赋值给 this.data,需要触发渲染的操作放到 attached 里。
4. slot 内容不显示。组件模板里必须有对应的 <slot> 标签,否则父组件传的任何子节点都不会渲染。
5. 组件路径写错。usingComponents 里的路径是相对路径,以页面文件所在目录为基准,不是相对于项目根目录。路径写错了不会报错,表现同样是渲染不出来。
6. 外部全局样式不生效。默认样式隔离下,外部 css 写了 * { box-sizing: border-box; } 这类全局样式,组件内部不会受影响。这是预期行为,但如果不了解样式隔离机制,容易误判为 bug。
写在最后
自定义组件最大的价值不是节省代码量,而是统一维护。卡片组件、计数器组件、按钮组件——抽出来之后,改样式、改逻辑都集中在一个地方。ASCF 的组件机制和常见小程序组件模型很相似,properties + triggerEvent 的通信模式熟练之后,页面开发的整体效率会有明显提升。
这次把卡片组件抽出来,从动手到跑通总共花了半小时,其中还包括一次 usingComponents 漏写的排查时间。后续再做类似页面,直接复用组件即可,样式调整也只需改一处。 |