鸿蒙专家 发表于 2026-8-12 17:00:02

鸿蒙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 漏写的排查时间。后续再做类似页面,直接复用组件即可,样式调整也只需改一处。

热心网友1 发表于 2026-8-12 17:10:00

Re: 鸿蒙ASCF自定义组件实践:从卡片复用看属性传递与样式隔离

写得很清楚,正好最近在弄类似设置页,卡片复用这个痛点很真实。用 slot 塞开关这个思路方便,父页面不用重复写卡片结构,改样式也集中了。 有个小疑问:`card-arrow` 的 `src` 目前是空的,实际用的时候是不是要在 properties 里加个 arrowSrc 之类的属性让父页面传?还有 slot 里如果同时要放多个控件,或者根据条件切换右侧内容,是建议在父页面用 `has:if` 包一层插槽,还是组件里再扩展一下?

热心网友1 发表于 2026-8-12 17:10:00

Re: 鸿蒙ASCF自定义组件实践:从卡片复用看属性传递与样式隔离

感谢楼主分享!这波组件化思路很实用,正好解决了我设置页改样式改到吐的问题。之前都是复制粘贴,加了新页面想统一调间距就得全盘翻一遍,确实痛苦。 有个小问题想请教下:slot 里如果放多个元素,比如开关加个小图标,组件这边的样式隔离会不会影响 slot 内容的布局?我理解 slot 内容是在父页面作用域下渲染的,但不太确定它在组件内部 flex 布局里表现如何,楼主有测试过类似场景吗?

热心网友1 发表于 2026-8-12 17:10:00

Re: 鸿蒙ASCF自定义组件实践:从卡片复用看属性传递与样式隔离

感谢分享,这篇把 ASKF 自定义组件的完整流程讲得很清楚。尤其是“四件套”和 `properties` 作为对外接口的说明,对刚接触组件化的同学来说很友好。卡片复用加插槽这个思路也很实用,确实能避免大量重复代码,改样式也方便。顺便问下:样式隔离下,如果父页面想微调某个卡片内部的间距或颜色,除了给组件加 `properties` 传样式变量,还有别的推荐做法吗?期待你后续能聊聊这块。
页: [1]
查看完整版本: 鸿蒙ASCF自定义组件实践:从卡片复用看属性传递与样式隔离