查看: 135|回复: 3

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

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
做设置页面时,最容易遇到的重复劳动就是卡片式布局:标题、描述、右侧一个开关或箭头,几乎每个页面都长一个样。一开始复制粘贴同一段结构代码还能接受,等页面多起来,想统一调整样式就得每个文件改一遍,改到怀疑人生。这种场景下,组件化就是最自然的解法。ASCF 支持自定义组件,把公共 UI 抽出来,所有页面复用,改一处就全局生效。

自定义组件的基础结构

一个自定义组件由 json + hxml + css + js 四件套组成,和 Page 相比只有两点区别:
  1. components/
  2. my-card/
  3. my-card.json   # 声明 component: true
  4. my-card.hxml   # 模板
  5. my-card.css    # 样式
  6. my-card.js     # 逻辑(Component 构造器)
复制代码

json 里多了一个 "component": true,js 里改用 Component({...}) 而不是 Page({...}),其他能力——数据绑定、事件处理、setData、生命周期——都和 Page 保持一致。

实现一个基础卡片组件

以卡片组件为例,需求是卡片包含标题、描述和右侧内容区,右侧可以由父页面自由塞入开关、箭头或其他内容。

先写 json 声明:
  1. {
  2.   "component": true
  3. }
复制代码

这一句就是告诉框架:这不是页面,这是组件。

组件逻辑文件:
  1. Component({
  2.   properties: {
  3.     title: {
  4.       type: String,
  5.       value: '',
  6.     },
  7.     desc: {
  8.       type: String,
  9.       value: '',
  10.     },
  11.     // 右侧是否显示箭头
  12.     showArrow: {
  13.       type: Boolean,
  14.       value: false,
  15.     },
  16.   },
  17.   data: {},
  18.   methods: {
  19.     onCardTap() {
  20.       // 触发自定义事件,父页面监听
  21.       this.triggerEvent('cardtap', { title: this.properties.title });
  22.     },
  23.   },
  24. });
复制代码

properties 就是组件的对外接口:父组件传什么值,组件收到后在模板中展示。type 限制数据类型,value 设置默认值。

模板文件:
  1. <view class="my-card" bindtap="onCardTap">
  2.   <view class="card-body">
  3.     <view class="card-info">
  4.       <text class="card-title" has:if="{{title}}">{{title}}</text>
  5.       <text class="card-desc" has:if="{{desc}}">{{desc}}</text>
  6.     </view>
  7.     <view class="card-right">
  8.       <slot></slot>
  9.       <image has:if="{{showArrow}}" class="card-arrow"
  10.         src="" />
  11.     </view>
  12.   </view>
  13. </view>
复制代码

slot 是插槽,父组件可以在组件标签中间塞内容,比如在卡片右侧放一个 switch。

样式文件:
  1. .my-card {
  2.   background: #fff;
  3.   border-radius: 12px;
  4.   padding: 16px;
  5.   margin-bottom: 12px;
  6.   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  7. }
  8. .card-body {
  9.   display: flex;
  10.   justify-content: space-between;
  11.   align-items: center;
  12. }
  13. .card-info {
  14.   flex: 1;
  15.   margin-right: 12px;
  16. }
  17. .card-title {
  18.   font-size: 15px;
  19.   color: #1a1a1a;
  20.   font-weight: 500;
  21. }
  22. .card-desc {
  23.   font-size: 12px;
  24.   color: #999;
  25.   margin-top: 4px;
  26.   display: block;
  27. }
  28. .card-right {
  29.   display: flex;
  30.   align-items: center;
  31. }
  32. .card-arrow {
  33.   width: 16px;
  34.   height: 16px;
  35.   margin-left: 8px;
  36. }
复制代码

到这里基础卡片组件就完成了。组件内部样式默认是隔离的,组件的 css 不会影响外部,外部的全局样式也不会污染组件内部,这套机制叫样式隔离。

在页面中使用自定义组件

页面里使用时,先要在页面的 json 中声明 usingComponents:
  1. {
  2.   "usingComponents": {
  3.     "my-card": "./components/my-card/my-card"
  4.   }
  5. }
复制代码

然后在 hxml 中像使用基础组件一样直接写标签:
  1. <my-card title="消息推送" desc="接收订单状态通知" showArrow="{{true}}">
  2.   <switch checked="{{pushEnabled}}" bindchange="onPushChange" />
  3. </my-card>
  4. <my-card title="夜间模式" desc="22:00-07:00 自动开启" showArrow="{{false}}">
  5.   <switch checked="{{nightMode}}" />
  6. </my-card>
  7. <my-card title="关于应用" desc="当前版本 1.0.0" bindcardtap="onAboutTap">
  8.   <!-- 不用 slot 内容也可以 -->
  9. </my-card>
复制代码

最终效果就是三张统一样式的卡片,每张卡片的右侧可以放不同内容:开关、箭头或者什么都不放。

这里有一个非常容易踩的坑:在页面 json 的 usingComponents 中漏写声明,直接在 hxml 写了 <my-card>,IDE 不报错,渲染出来却是空白。排查了半天,最后发现就是 usingComponents 没写。

properties:父组件向子组件传值

properties 是接收父组件数据的入口,支持简写和完整写法:
  1. Component({
  2.   properties: {
  3.     name: String, // 简写
  4.     age: { type: Number, value: 0 }, // 完整写法
  5.     user: { type: Object, value: {} },
  6.     tags: { type: Array, value: [] },
  7.   },
  8. });
复制代码

hxml 中使用驼峰命名绑定:
  1. <my-card user="{{userObj}}" age="{{25}}" />
复制代码

properties 的值在组件内部可以借助 this.properties.xxx 或 this.data.xxx 读取。要特别注意,properties 和 data 里的字段名不能相同,否则修改 properties 时会覆盖 data 的同名字段。这个问题在实际开发中出现过:在 data 里声明了与 properties 同名的字段,父组件传值后 data 里的值被覆盖,页面展示和预期完全对不上。

triggerEvent:子组件向父组件通信

子组件通过 this.triggerEvent 触发自定义事件,父组件监听后接收数据:
  1. // 子组件
  2. methods: {
  3.   onTap() {
  4.     this.triggerEvent('myevent', {
  5.       value: '一些数据',
  6.       timestamp: Date.now(),
  7.     }, {
  8.       bubbles: false, // 是否冒泡
  9.       composed: false, // 是否穿越组件边界
  10.       capturePhase: false, // 是否有捕获阶段
  11.     });
  12.   },
  13. },
复制代码
  1. <!-- 父页面 -->
  2. <my-card bindmyevent="onMyEvent" />
  3. <!-- 或者 -->
  4. <my-card bind:myevent="onMyEvent" />
复制代码
  1. // 父页面
  2. onMyEvent(e) {
  3.   console.info('收到子组件事件:', e.detail);
  4.   // e.detail = { value: '一些数据', timestamp: ... }
  5. }
复制代码

事件名命名上,驼峰写法在 bind 时用 bindmyevent,连字符写法用 bind:my-event,两者等价。团队协作时建议统一成一种风格。

slot 插槽:让父组件决定内容区

slot 就是在组件模板里预留的坑位,父组件往坑里填内容:
  1. <!-- 组件模板 -->
  2. <view class="my-card">
  3.   <view class="card-header">
  4.     <slot name="header"></slot>
  5.   </view>
  6.   <view class="card-body">
  7.     <slot></slot> <!-- 默认插槽 -->
  8.   </view>
  9. </view>
复制代码
  1. <!-- 父页面 -->
  2. <my-card>
  3.   <view slot="header">
  4.     <text>自定义标题区域</text>
  5.   </view>
  6.   <switch checked="{{value}}" /> <!-- 放入默认插槽 -->
  7. </my-card>
复制代码

多个 slot 时用 name 区分,不指定 name 的就是默认插槽。注意,组件模板里必须有对应的 <slot> 标签,父组件传进来的子节点才会被渲染。

组件生命周期与页面生命周期

自定义组件有 4 个核心生命周期:
  1. Component({
  2.   lifetimes: {
  3.     created() {
  4.       console.info('组件创建');
  5.       // 不能 setData
  6.     },
  7.     attached() {
  8.       console.info('组件进入页面');
  9.       // 可以 setData 了
  10.     },
  11.     ready() {
  12.       console.info('组件渲染完成');
  13.       // 可以操作 DOM
  14.     },
  15.     detached() {
  16.       console.info('组件移除');
  17.       // 清理
  18.     },
  19.   },
  20. });
复制代码

lifetimes 写法优先级最高。如果同时写了顶层 attached 和 lifetimes.attached,最终走的是 lifetimes 里的实现。

组件还可以监听所在页面的生命周期:
  1. Component({
  2.   pageLifetimes: {
  3.     show() {
  4.       // 页面显示时触发
  5.       console.info('所在页面显示');
  6.     },
  7.     hide() {
  8.       // 页面隐藏时触发
  9.       console.info('所在页面隐藏');
  10.     },
  11.   },
  12. });
复制代码

这个能力在特定场景下非常好用。比如计数器组件在页面切回来时需要刷新数据,直接在 pageLifetimes.show 里处理即可,不需要页面侧额外调用。

observers:数据监听器

observers 用于监听 properties 或 data 的变化:
  1. Component({
  2.   properties: {
  3.     count: { type: Number, value: 0 },
  4.   },
  5.   observers: {
  6.     // 单个字段
  7.     'count': function(newVal, oldVal) {
  8.       console.info('count 从', oldVal, '变为', newVal);
  9.     },
  10.     // 多个字段
  11.     'count, step': function(count, step) {
  12.       this.setData({
  13.         total: count * step,
  14.       });
  15.     },
  16.     // 子字段
  17.     'user.name': function(name) {
  18.       console.info('用户名变了:', name);
  19.     },
  20.     // 通配符:所有字段
  21.     '**': function() {
  22.       console.info('数据有变化');
  23.     },
  24.   },
  25. });
复制代码

observers 在组件 attached 阶段之后才会触发。如果在 created 里 setData,observers 不会执行。

实际开发中,做一个购物车组件时商品数量变化需要自动计算总价,就可以用 observers 监听 count 变化,一变更立即重新计算,省去手动调用的麻烦。

三种通信方式对比

除了 properties + triggerEvent,还有 selectComponent 可以获取子组件实例:
  1. // 父页面,通过 id 或 class 获取子组件实例
  2. const child = this.selectComponent('#my-counter');
  3. if (child) {
  4.   console.info('子组件数据:', child.data);
  5.   child.setData({ count: 100 }); // 直接操作子组件
  6. }
复制代码

但用 selectComponent 直接操作子组件会破坏封装性,应优先使用 properties + triggerEvent 的通信方式,selectComponent 留作兜底方案。

另外,如果组件需要在多个页面复用,可以在 app.json 的 usingComponents 里声明为全局组件,所有页面和组件都可以直接用,不需要每个页面单独引用。实际做设置页面时,把卡片组件全局注册后,其他页面直接写 <my-card> 就能用,省事不少。

完整示例:计数器组件

把上面的知识点串起来,做一个计数器组件:

my-counter.json:
  1. {
  2.   "component": true
  3. }
复制代码

my-counter.js:
  1. Component({
  2.   properties: {
  3.     count: {
  4.       type: Number,
  5.       value: 0,
  6.     },
  7.     min: { type: Number, value: 0 },
  8.     max: { type: Number, value: 99 },
  9.     step: { type: Number, value: 1 },
  10.   },
  11.   observers: {
  12.     'count, min, max': function(count, min, max) {
  13.       // 父组件传了新的 count 时校验范围
  14.       if (count < min || count > max) {
  15.         console.warn('count 超出范围');
  16.       }
  17.     },
  18.   },
  19.   methods: {
  20.     onDecrease() {
  21.       const newVal = this.properties.count - this.properties.step;
  22.       if (newVal >= this.properties.min) {
  23.         this.triggerEvent('change', { value: newVal });
  24.       }
  25.     },
  26.     onIncrease() {
  27.       const newVal = this.properties.count + this.properties.step;
  28.       if (newVal <= this.properties.max) {
  29.         this.triggerEvent('change', { value: newVal });
  30.       }
  31.     },
  32.   },
  33. });
复制代码

my-counter.hxml:
  1. <view class="counter">
  2.   <view class="counter-btn" bindtap="onDecrease">-</view>
  3.   <text class="counter-value">{{count}}</text>
  4.   <view class="counter-btn" bindtap="onIncrease">+</view>
  5. </view>
复制代码

my-counter.css:
  1. .counter {
  2.   display: flex;
  3.   align-items: center;
  4.   gap: 12px;
  5. }
  6. .counter-btn {
  7.   width: 32px;
  8.   height: 32px;
  9.   border: 1px solid #ddd;
  10.   border-radius: 50%;
  11.   text-align: center;
  12.   line-height: 32px;
  13.   font-size: 18px;
  14.   color: #333;
  15. }
  16. .counter-btn:active {
  17.   background: #f0f0f0;
  18. }
  19. .counter-value {
  20.   font-size: 16px;
  21.   font-weight: 600;
  22.   min-width: 24px;
  23.   text-align: center;
  24. }
复制代码

父页面使用:
  1. {
  2.   "usingComponents": {
  3.     "my-counter": "./components/my-counter/my-counter"
  4.   }
  5. }
复制代码
  1. <my-counter count="{{cartCount}}" min="0" max="10"
  2.   bindchange="onCountChange" />
复制代码
  1. Page({
  2.   data: { cartCount: 1 },
  3.   onCountChange(e) {
  4.     this.setData({ cartCount: e.detail.value });
  5.     console.info('数量变为:', e.detail.value);
  6.   },
  7. });
复制代码

这样一个可复用的计数器组件就做好了。properties 控制步长和范围,triggerEvent 通知父组件数量变化。

组件样式隔离与外部样式覆盖

默认情况下,组件样式是隔离的:组件内部 css 不影响外部,外部 css 也不影响组件内部(继承性属性如 color、font-size 除外)。

如果需要外部覆盖组件样式,可以用 externalClasses:
  1. Component({
  2.   externalClasses: ['my-class', 'custom-class'],
  3. });
复制代码
  1. <my-card my-class="custom-card-style" />
复制代码
  1. /* 外部样式 */
  2. .custom-card-style {
  3.   border-color: #0A59F7;
  4. }
复制代码

或者通过 options 调整隔离策略:
  1. Component({
  2.   options: {
  3.     styleIsolation: 'apply-shared', // 外部样式可以影响组件
  4.   },
  5. });
复制代码

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 小时前 | 显示全部楼层

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

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

使用道具 举报

发表于 1 小时前 | 显示全部楼层

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

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

使用道具 举报

发表于 1 小时前 | 显示全部楼层

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

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

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-8-12 18:49 , Processed in 0.022019 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部