鸿蒙ASCF自定义组件实践:从卡片复用看属性传递与样式隔离
做设置页面时,最容易遇到的重复劳动就是卡片式布局:标题、描述、右侧一个开关或箭头,几乎每个页面都长一个样。一开始复制粘贴同一段结构代码还能接受,等页面多起来,想统一调整样式就得每个文件改一遍,改到怀疑人生。这种场景下,组件化就是最自然的解法。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": true
}
这一句就是告诉框架:这不是页面,这是组件。
组件逻辑文件:
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:
{
"component": true
}
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 漏写的排查时间。后续再做类似页面,直接复用组件即可,样式调整也只需改一处。
Re: 鸿蒙ASCF自定义组件实践:从卡片复用看属性传递与样式隔离
写得很清楚,正好最近在弄类似设置页,卡片复用这个痛点很真实。用 slot 塞开关这个思路方便,父页面不用重复写卡片结构,改样式也集中了。 有个小疑问:`card-arrow` 的 `src` 目前是空的,实际用的时候是不是要在 properties 里加个 arrowSrc 之类的属性让父页面传?还有 slot 里如果同时要放多个控件,或者根据条件切换右侧内容,是建议在父页面用 `has:if` 包一层插槽,还是组件里再扩展一下?Re: 鸿蒙ASCF自定义组件实践:从卡片复用看属性传递与样式隔离
感谢楼主分享!这波组件化思路很实用,正好解决了我设置页改样式改到吐的问题。之前都是复制粘贴,加了新页面想统一调间距就得全盘翻一遍,确实痛苦。 有个小问题想请教下:slot 里如果放多个元素,比如开关加个小图标,组件这边的样式隔离会不会影响 slot 内容的布局?我理解 slot 内容是在父页面作用域下渲染的,但不太确定它在组件内部 flex 布局里表现如何,楼主有测试过类似场景吗?Re: 鸿蒙ASCF自定义组件实践:从卡片复用看属性传递与样式隔离
感谢分享,这篇把 ASKF 自定义组件的完整流程讲得很清楚。尤其是“四件套”和 `properties` 作为对外接口的说明,对刚接触组件化的同学来说很友好。卡片复用加插槽这个思路也很实用,确实能避免大量重复代码,改样式也方便。顺便问下:样式隔离下,如果父页面想微调某个卡片内部的间距或颜色,除了给组件加 `properties` 传样式变量,还有别的推荐做法吗?期待你后续能聊聊这块。
页:
[1]