img

📖 引言

在《奇妙科学乐园》的开发过程中,组件通信是最基础也最频繁涉及的技术点。我们的应用拥有超过20个自定义组件,从首页的BannerCarousel轮播图到答题页的QuizOptionItem选项卡,从通用基础组件AppBar到业务组件TopicCard,数据在组件树中不断流动。父子组件如何传递数据?子组件如何通知父组件用户操作?跨层级的Tab切换指令如何下发?这些问题都需要一套清晰的组件通信策略来解决。

HarmonyOS ArkTS提供了多种组件通信机制:@Prop单向传递、事件回调(类似Vue的emit)、@Provide/@Consume跨层级注入、AppStorage全局状态共享。本篇将结合项目中的真实代码,逐一拆解这些通信方式的使用场景、实现细节和注意事项,帮助你构建清晰的组件数据流向。

源码地址:https://atomgit.com/2301_79280419/WonderSciencePark


🎯 学习目标

完成本文后,你将能够:


💡 需求分析

组件通信场景总览

通信方向 项目实例 技术方案 数据流特征
父→子(单向) MainTabs → Topics 传递initialCategory @Prop装饰器 父组件数据变更时同步到子组件
子→父(回调) TopicCard → Index 通知点击事件 回调函数属性 onItemClick 子组件触发,父组件处理
跨Tab通信 Index → MainTabs 切换到科普Tab AppStorage + @StorageLink + @Watch 全局状态,多组件响应
跨层级注入 暂未使用(项目层级较浅) @Provide/@Consume 适合深层嵌套场景
弹窗通信 Profile → CheckInDialog CustomDialogController 父组件控制弹窗,弹窗内自治
单例共享 全局组件访问scienceData 单例模式 + import 非组件状态,全局共享

组件树与数据流向

MainTabs(@Entry)
├── Index(首页)
│   ├── BannerCarousel(onBannerClick回调)
│   └── TopicCard[](topic @Prop, onItemClick回调)
├── Topics(科普列表)
│   └── TopicCard[](topic @Prop, onItemClick回调)
└── Profile(个人中心)
    ├── CheckInDialog(CustomDialogController)
    ├── ListItemComponent[](itemIcon, onItemClick回调)
    └── SettingsSection(sectionTitle, items, onItemClick回调)

🛠️ 核心实现

步骤1: 父→子通信——@Prop单向传递分类筛选参数

功能说明

MainTabs页面作为底部Tab容器,需要在用户点击首页的分类卡片时,将分类ID传递给Topics组件进行筛选。我们使用@Prop装饰器实现父→子的单向数据传递,确保数据流向清晰可控。

完整代码

// pages/MainTabs.ets —— 父组件向Topics传递初始分类

@Entry
@Component
struct MainTabs {
    // 当前Tab索引,控制底部导航高亮
    @State currentIndex: number = 0;
    // 科普页面的分类筛选值,通过@Prop传递给Topics子组件
    @State topicsCategory: string = 'all';

    // 监听AppStorage中的分类变化指令
    @StorageLink('topicsCategory') @Watch('onCategoryChange')
    storageTopicsCategory: string = 'all';

    private tabsController: TabsController = new TabsController();

    aboutToAppear() {
        // 读取路由参数,支持从外部跳转时指定初始分类
        const params = RouterUtil.getParams() as Record<string, Object>;
        if (params) {
            if (params.categoryId) {
                // ✅ 从路由参数中获取分类ID
                this.topicsCategory = params.categoryId as string;
            }
        }
    }

    /**
     * 监听AppStorage中分类变化,同步到本地状态
     * 当首页点击分类卡片时,会通过AppStorage触发此回调
     */
    onCategoryChange(): void {
        if (this.storageTopicsCategory
            && this.storageTopicsCategory !== ''
            && this.storageTopicsCategory !== 'all') {
            this.topicsCategory = this.storageTopicsCategory;
            // 如果当前不在科普Tab,自动切换过去
            if (this.currentIndex !== 1) {
                this.currentIndex = 1;
                this.tabsController.changeIndex(1);
            }
        }
    }

    build() {
        Column() {
            Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) {
                TabContent() {
                    Index();
                }
                TabContent() {
                    // ✅ 通过topicsCategory变量传递给Topics子组件
                    Topics({ initialCategory: this.topicsCategory });
                }
                TabContent() {
                    Profile();
                }
            }
            .barHeight(0)
            .onChange((index: number) => {
                this.currentIndex = index;
            })
            .layoutWeight(1)

            this.CustomTabBar();
        }
    }
}
// pages/Topics.ets —— 子组件通过@Prop接收父组件传递的分类参数

@Component
export struct Topics {
    // ✅ @Prop装饰器:从父组件接收初始分类,单向同步
    // 父组件的topicsCategory变化时,会自动更新此值
    @Prop initialCategory: string = 'all';
    // 组件内部维护的当前分类状态
    @State currentCategory: string = 'all';

    aboutToAppear() {
        // 首次加载时使用父组件传入的分类值
        if (this.initialCategory) {
            this.currentCategory = this.initialCategory;
        }
    }

    // ✅ aboutToUpdate在每次属性更新前调用
    // 当父组件的topicsCategory变化时,此处会同步更新内部状态
    aboutToUpdate() {
        if (this.initialCategory
            && this.initialCategory !== this.currentCategory
            && this.searchKeyword === '') {
            this.currentCategory = this.initialCategory;
            if (scienceData.getIsInitialized()) {
                this.loadTopics();
            }
        }
    }

    /**
     * 用户在科普页面点击分类标签切换
     * 注意:这不会修改@Prop,而是修改内部的@State
     */
    selectCategory(categoryId: string) {
        this.currentCategory = categoryId;
        this.searchKeyword = '';
        this.loadTopics();
    }
}

代码解析

1. @Prop的单向传递特性

@Prop装饰器建立的父子关系是单向的:

  • 父组件修改topicsCategory → 子组件的initialCategory自动更新
  • 子组件内部修改currentCategory → 不影响父组件的topicsCategory
// ✅ 正确:父组件通过@State管理数据,通过@Prop传递给子组件
@State topicsCategory: string = 'all';  // 父组件的@State
Topics({ initialCategory: this.topicsCategory });  // 传递给子组件的@Prop

// ❌ 错误:子组件直接修改@Prop值(@Prop是只读的)
@Prop initialCategory: string = 'all';
this.initialCategory = 'nature';  // ❌ 编译错误,@Prop不允许子组件修改

// ✅ 正确:子组件用@State维护自己的可变副本
@Prop initialCategory: string = 'all';
@State currentCategory: string = 'all';  // 内部可变副本

2. aboutToUpdate的生命周期配合

当父组件数据变化触发子组件@Prop更新时,aboutToUpdate()会在重新渲染前被调用,我们在这里同步内部状态:

// ✅ 在aboutToUpdate中检测外部传入值的变化
aboutToUpdate() {
    if (this.initialCategory !== this.currentCategory) {
        this.currentCategory = this.initialCategory;
        this.loadTopics();  // 重新加载对应分类的数据
    }
}

步骤2: 子→父通信——事件回调函数传递用户操作

功能说明

子组件TopicCard被点击时,需要通知父组件Index跳转到文章详情页。由于ArkTS没有Vue的$emit,我们采用"回调函数属性"模式:父组件将一个函数通过属性传递给子组件,子组件在合适的时机调用它。

完整代码

// components/topic/TopicCard.ets —— 子组件定义回调函数类型

@Component
export struct TopicCard {
    // ✅ @Prop接收父组件传递的Topic数据(只读)
    @Prop topic: Topic = getDefaultTopic();
    // ✅ 回调函数属性:子组件点击时通知父组件
    onItemClick?: (topic: Topic) => void;

    build() {
        Column() {
            // 卡片UI内容...
            Image(this.getCategoryCover())
                .width('100%')
                .height(110)
                .objectFit(ImageFit.Cover);

            Text(this.topic.title)
                .fontSize(15)
                .fontWeight(FontWeight.Bold);
        }
        .onClick(() => {
            // ✅ 子组件点击时,调用父组件传入的回调函数
            if (this.onItemClick) {
                this.onItemClick(this.topic);
            }
        });
    }
}
// pages/Index.ets —— 父组件传递回调函数

@Component
export struct Index {
    @State recommendedTopics: Topic[] = [];

    /**
     * 跳转到文章详情页
     * 由子组件TopicCard的onItemClick回调触发
     */
    goToTopicDetail(topic: Topic): void {
        const params: RouterParams = { topicId: topic.id };
        const options: RouterOptions = {
            url: RouteUrls.TOPIC_DETAIL,
            params: params
        };
        RouterUtil.pushUrl(options, 'Index');
    }

    build() {
        Scroll() {
            Column() {
                ForEach(this.recommendedTopics, (topic: Topic) => {
                    // ✅ 传递topic数据 + 点击回调函数
                    TopicCard({
                        topic: topic,
                        onItemClick: (t: Topic) => this.goToTopicDetail(t)
                    });
                }, (topic: Topic) => topic.id.toString());
            }
        }
    }
}

代码解析

1. 回调函数属性的安全调用

子组件在调用回调前,必须检查回调是否存在:

// ✅ 正确:先检查回调是否存在再调用
.onClick(() => {
    if (this.onItemClick) {
        this.onItemClick(this.topic);
    }
});

// ❌ 错误:直接调用,如果父组件未传回调会崩溃
.onClick(() => {
    this.onItemClick!(this.topic);  // ❌ 可能运行时报错
});

// ✅ 正确:声明时给默认值,即使不传也不会报错
onItemClick?: (topic: Topic) => void;  // 可选属性
// 或
onItemClick: (topic: Topic) => void = () => {};  // 空函数默认值

2. 答题组件中的回调链——Quiz → QuizOptionItem → Quiz

答题流程中存在更复杂的回调链:Quiz页面将选项点击回调和反馈状态一起传递给QuizOptionItem:

// pages/Quiz.ets —— 父组件传递多个属性给子组件
ForEach(this.currentQuestion.options, (option: string, index: number) => {
    QuizOptionItem({
        option: option,                          // 选项文本
        index: index,                            // 选项索引
        selected: this.selectedOption === index, // 是否被选中
        showFeedback: this.showFeedback,         // 是否显示反馈
        isCorrect: this.isCorrect,               // 是否答对
        correctIndex: this.correctIdx,           // 正确答案索引
        onSelect: (idx: number) => {             // ✅ 选择回调
            this.selectOption(idx);
        }
    });
}, (_: string, index: number) => 'q' + (this.currentQuestion?.id ?? 0)
    + '-opt-' + index.toString());
// components/quiz/QuizOptionItem.ets —— 子组件处理点击并回调

@Component
export struct QuizOptionItem {
    option: string = '';
    index: number = 0;
    selected: boolean = false;
    showFeedback: boolean = false;
    isCorrect: boolean = false;
    correctIndex: number = -1;
    onSelect?: (index: number) => void = () => {};

    build() {
        Row() {
            // 选项字母标识:A/B/C/D
            Text(String.fromCharCode(65 + this.index))
                .fontSize(14)
                .fontColor(this.getOptionTextColor());

            Text(this.option)
                .fontSize(15)
                .layoutWeight(1);

            // 反馈状态下显示对错标记
            if (this.showFeedback) {
                if (this.index === this.correctIndex) {
                    Text('✓')  // 正确答案
                        .fontColor(ThemeColors.SUCCESS);
                } else if (this.selected && !this.isCorrect) {
                    Text('✗')  // 用户选错的选项
                        .fontColor(ThemeColors.PRIMARY);
                }
            }
        }
        .onClick(() => {
            // ✅ 仅在未显示反馈时允许选择(防止重复点击)
            if (!this.showFeedback && this.onSelect) {
                this.onSelect(this.index);
            }
        });
    }
}

功能说明

首页Index组件和MainTabs组件分别位于不同的TabContent中,属于兄弟组件关系。当用户在首页点击某个分类卡片时,需要切换到"科普"Tab并自动筛选该分类。这种跨Tab通信通过AppStorage全局状态管理实现。

完整代码

// pages/Index.ets —— 发送端:首页点击分类卡片时写入AppStorage

goToCategory(category: Category): void {
    // ✅ 向AppStorage写入Tab切换指令
    AppStorage.setOrCreate<string>('switchToTab', 'topics');
    // ✅ 同时写入要筛选的分类ID
    AppStorage.setOrCreate<string>('topicsCategory', category.id);
}

goToTopics(): void {
    // ✅ 跳转科普Tab,不限分类
    AppStorage.setOrCreate<string>('switchToTab', 'topics');
    AppStorage.setOrCreate<string>('topicsCategory', 'all');
}

onBannerClick(banner: BannerItem): void {
    // ✅ Banner点击也走同样的跨Tab通信
    if (banner.category) {
        AppStorage.setOrCreate<string>('switchToTab', 'topics');
        AppStorage.setOrCreate<string>('topicsCategory', banner.category);
    }
}
// pages/MainTabs.ets —— 接收端:监听AppStorage变化并响应

@Entry
@Component
struct MainTabs {
    @State currentIndex: number = 0;
    @State topicsCategory: string = 'all';

    // ✅ @StorageLink:双向绑定AppStorage中的'switchToTab'
    // 值变化时自动触发onTabSwitch回调
    @StorageLink('switchToTab') @Watch('onTabSwitch')
    switchToTab: string = '';

    // ✅ @StorageLink:双向绑定AppStorage中的'topicsCategory'
    // 值变化时自动触发onCategoryChange回调
    @StorageLink('topicsCategory') @Watch('onCategoryChange')
    storageTopicsCategory: string = 'all';

    private tabsController: TabsController = new TabsController();

    /**
     * 监听AppStorage中Tab切换指令
     * 由首页的分类点击/Banner点击触发
     */
    onTabSwitch(): void {
        if (this.switchToTab === 'topics') {
            this.currentIndex = 1;
            this.tabsController.changeIndex(1);
            // ✅ 同步分类筛选
            if (this.storageTopicsCategory
                && this.storageTopicsCategory !== '') {
                this.topicsCategory = this.storageTopicsCategory;
            }
            // ✅ 清除指令,避免重复触发
            AppStorage.setOrCreate<string>('switchToTab', '');
            AppStorage.setOrCreate<string>('topicsCategory', 'all');
        }
    }

    /**
     * 监听AppStorage中分类变化
     */
    onCategoryChange(): void {
        if (this.storageTopicsCategory
            && this.storageTopicsCategory !== ''
            && this.storageTopicsCategory !== 'all') {
            this.topicsCategory = this.storageTopicsCategory;
            if (this.currentIndex !== 1) {
                this.currentIndex = 1;
                this.tabsController.changeIndex(1);
            }
        }
    }
}

代码解析

1. AppStorage通信的"指令-清除"模式

为避免指令被重复处理,我们在消费完指令后立即清除:

// ✅ 正确:消费后清除指令
onTabSwitch(): void {
    if (this.switchToTab === 'topics') {
        this.tabsController.changeIndex(1);
        // 处理完毕后清除,防止下次Tab切换时重复触发
        AppStorage.setOrCreate<string>('switchToTab', '');
        AppStorage.setOrCreate<string>('topicsCategory', 'all');
    }
}

// ❌ 错误:不清除指令,导致每次组件重新渲染都重复执行
onTabSwitch(): void {
    if (this.switchToTab === 'topics') {
        this.tabsController.changeIndex(1);
        // ❌ 没有清除指令!下次任何状态变化都会再次触发
    }
}

2. @StorageLink + @Watch的配合使用

// ✅ @StorageLink绑定AppStorage值 + @Watch监听变化执行副作用
@StorageLink('switchToTab') @Watch('onTabSwitch') switchToTab: string = '';

// ❌ 错误:仅用@StorageLink不用@Watch,无法在值变化时执行逻辑
@StorageLink('switchToTab') switchToTab: string = '';
// switchToTab变化了,但无法自动调用tabsController.changeIndex()

// ❌ 错误:仅用@Watch不用@StorageLink,无法感知AppStorage的变化
@Watch('onTabSwitch') switchToTab: string = '';
// 这只是普通变量,不会和AppStorage关联

步骤4: 通用组件的回调设计——AppBar导航栏

功能说明

AppBar是项目中使用最广泛的通用基础组件,出现在Quiz、Lab、Settings、ParentControl等多个页面。它通过回调函数onBack将返回按钮的点击事件传递给宿主页面,实现了"UI与业务逻辑解耦"的设计目标。

完整代码

// components/base/AppBar.ets —— 通用导航栏组件

export interface AppBarAction {
    icon?: string;
    text?: string;
    onClick: () => void;
}

@Component
export struct AppBar {
    barTitle: string = '';             // 标题文字
    showBack: boolean = true;          // 是否显示返回按钮
    onBack?: () => void;              // ✅ 返回按钮回调(最常用的通信方式)
    rightAction?: AppBarAction;       // ✅ 右侧操作按钮(可选)
    barBgColor: string = '#ffffff';   // 背景色
    barTitleColor: string = ThemeColors.TEXT_PRIMARY;
    backIconColor: string = ThemeColors.TEXT_PRIMARY;
    barBorderBottom: boolean = true;  // 是否显示底部边框

    @Builder
    BackButton() {
        if (this.showBack) {
            Button() {
                Text('←')
                    .fontSize(18)
                    .fontColor(this.backIconColor);
            }
            .type(ButtonType.Circle)
            .width(36)
            .height(36)
            .backgroundColor(this.barBgColor)
            .onClick(() => {
                // ✅ 点击返回按钮时,调用宿主页面传入的回调
                if (this.onBack) {
                    this.onBack();
                }
            });
        }
    }

    build() {
        Row() {
            this.BackButton();
            Text(this.barTitle)
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .layoutWeight(1)
                .textAlign(TextAlign.Center);
            // 右侧操作区域(可选)
            if (this.rightAction) {
                this.RightActionButton();
            } else {
                Column() { }
                    .width(36);  // 占位,保持标题居中
            }
        }
        .width('100%')
        .padding({ top: 16, left: 16, right: 16, bottom: 12 })
        .backgroundColor(this.barBgColor)
        .border(this.barBorderBottom
            ? { width: { bottom: 1 }, color: ThemeColors.BORDER_COLOR }
            : undefined);
    }
}
// pages/Quiz.ets —— 宿主页面传递返回回调

@Builder
SelectCategoryView() {
    Column() {
        AppBar({
            barTitle: '趣味问答',
            showBack: true,
            // ✅ 将页面的goBack方法作为回调传入AppBar
            onBack: () => this.goBack(),
            barBorderBottom: false,
            barBgColor: ThemeColors.BG_SECONDARY
        });
        // 页面内容...
    }
}

代码解析

1. 可选回调的设计哲学

AppBar的onBack被设计为可选属性(onBack?: () => void),这意味着:

// ✅ 场景1:需要返回功能的页面,传入回调
AppBar({
    barTitle: '趣味问答',
    showBack: true,
    onBack: () => this.goBack()
});

// ✅ 场景2:仅做标题展示,不需要返回按钮
AppBar({
    barTitle: '设置',
    showBack: true,
    onBack: () => this.goBack(),
    barBorderBottom: false,
    barBgColor: ThemeColors.BG_SECONDARY
});

// ✅ 场景3:不显示返回按钮
AppBar({
    barTitle: '首页',
    showBack: false
    // onBack不需要传,因为showBack为false时按钮不渲染
});

步骤5: 弹窗通信——CustomDialogController与数据隔离

功能说明

CheckInDialog是一个每日打卡弹窗组件,使用@CustomDialog装饰。它通过CustomDialogController由父组件Profile控制打开和关闭。弹窗内部拥有独立的@State状态,打卡成功后自动关闭弹窗并更新持久化数据。

完整代码

// components/common/CheckInDialog.ets —— 打卡弹窗组件

@CustomDialog
export struct CheckInDialog {
    controller: CustomDialogController;  // ✅ 弹窗控制器(必须属性)
    @State streakDays: number = 0;       // 连续学习天数
    @State weekDays: string[] = ['一', '二', '三', '四', '五', '六', '日'];
    @State checkedDays: boolean[] = [false, false, false, false, false, false, false];
    @State todayChecked: boolean = false;

    aboutToAppear() {
        // ✅ 从单例服务读取打卡数据,弹窗内部维护展示状态
        this.streakDays = userPrefs.getLearnDaysSync();
        this.initWeekCheckedDays();
    }

    /**
     * 执行打卡操作
     * 弹窗内部自治:更新UI状态 + 持久化数据 + 延迟关闭
     */
    onCheckIn(): void {
        if (this.todayChecked) return;
        this.checkedDays[this.getCurrentDayIndex()] = true;
        this.todayChecked = true;
        // ✅ 持久化打卡记录
        userPrefs.checkInAndUpdateDays().then((days: number) => {
            this.streakDays = days;
        });
        // ✅ 延迟关闭弹窗,给用户看到状态变化的时间
        setTimeout(() => {
            this.controller.close();
        }, 800);
    }

    build() {
        Column() {
            // 连续天数展示
            Text('已连续学习 ' + this.streakDays + ' 天')
                .fontSize(18)
                .fontWeight(FontWeight.Bold);

            // 周历视图
            Row() {
                ForEach(this.weekDays, (day: string, index: number) => {
                    Column() {
                        Text(day).fontSize(12);
                        Column()
                            .width(24)
                            .height(24)
                            .borderRadius(12)
                            .backgroundColor(this.checkedDays[index]
                                ? ThemeColors.PRIMARY
                                : ThemeColors.BG_TERTIARY);
                    }
                    .layoutWeight(1)
                    .alignItems(HorizontalAlign.Center);
                }, (_: string, index: number) => 'week-' + index.toString());
            }

            // 打卡按钮
            Button(this.todayChecked ? '今日已打卡 ✓' : '立即打卡')
                .width('100%')
                .height(44)
                .type(ButtonType.Capsule)
                .enabled(!this.todayChecked)
                .onClick(() => {
                    this.onCheckIn();
                });
        }
        .width('80%')
        .padding(24)
        .backgroundColor(ThemeColors.BG_PRIMARY)
        .borderRadius(24);
    }
}
// pages/Profile.ets —— 父组件创建并控制弹窗

@Component
export struct Profile {
    // ✅ 创建弹窗控制器,指定builder为CheckInDialog
    private checkInDialogController: CustomDialogController =
        new CustomDialogController({
            builder: CheckInDialog(),
            alignment: DialogAlignment.Center,
            customStyle: true,
            autoCancel: true  // 点击遮罩层自动关闭
        });

    /**
     * 打开每日打卡弹窗
     */
    showCheckInDialog(): void {
        this.checkInDialogController.open();
    }
}

代码解析

1. CustomDialog的通信特点

// ✅ 正确:弹窗通过controller.close()自主关闭
// 父组件通过controller.open()打开,弹窗内部自治管理状态
setTimeout(() => {
    this.controller.close();
}, 800);

// ❌ 错误:试图通过AppStorage或回调通知父组件关闭弹窗
// 弹窗有独立的组件生命周期,应使用controller控制
AppStorage.setOrCreate<string>('closeDialog', 'true');

2. 弹窗内的数据自给自足

弹窗组件在aboutToAppear中直接从单例服务获取数据,不依赖父组件传递props:

// ✅ 正确:弹窗内部自己加载数据
aboutToAppear() {
    this.streakDays = userPrefs.getLearnDaysSync();
    this.initWeekCheckedDays();
}

// ❌ 不推荐:通过props传入大量状态(弹窗的props传递有限制)
// @CustomDialog的builder中传递复杂参数容易出问题

📋 最佳实践

实践1: 选择通信方式的决策树

需要通信的两个组件是什么关系?
├── 父子关系
│   ├── 父→子传递数据?
│   │   ├── 简单值/对象 → @Prop
│   │   └── 需要子组件修改 → @Link(慎用)
│   └── 子→父通知事件?
│       └── 回调函数属性(onXxx: () => void)
├── 兄弟关系 / 跨Tab
│   ├── 简单指令传递 → AppStorage + @StorageLink + @Watch
│   └── 复杂共享状态 → 单例服务(如scienceData)
└── 祖孙跨层级
    ├── 少量数据 → @Provide/@Consume
    └── 全局共享 → AppStorage / 单例服务

实践2: 回调函数的防御性编程

// ✅ 正确:回调定义为可选,调用前判空
onItemClick?: (topic: Topic) => void;

.onClick(() => {
    if (this.onItemClick) {
        this.onItemClick(this.topic);
    }
});

// ✅ 正确:回调定义为必选,给空函数默认值
onSelect: (index: number) => void = () => {};

.onClick(() => {
    this.onSelect(this.index);  // 永远不会崩溃
});

// ❌ 错误:必选属性但不给默认值,未传参时崩溃
onItemClick: (topic: Topic) => void;  // 如果父组件忘记传参会编译警告

实践3: AppStorage键名的统一管理

// ✅ 正确:将AppStorage的key集中管理,避免魔法字符串
const STORAGE_KEYS = {
    SWITCH_TO_TAB: 'switchToTab',
    TOPICS_CATEGORY: 'topicsCategory',
} as const;

// 使用时
AppStorage.setOrCreate<string>(STORAGE_KEYS.SWITCH_TO_TAB, 'topics');

// ❌ 错误:在多处硬编码字符串,容易拼写错误
AppStorage.setOrCreate<string>('switchToTab', 'topics');  // 页面A
AppStorage.setOrCreate<string>('swichToTab', 'topics');   // ❌ 页面B拼错了

实践4: 避免循环依赖

// ✅ 正确:组件只import工具和类型,不import其他组件
// TopicCard.ets
import { Topic } from '../../model/Topic';      // 数据模型
import { scienceData } from '../../viewmodel/ScienceData';  // 单例服务
import { ThemeColors } from '../../constants/AppConstants';  // 常量

// ❌ 错误:组件A import 组件B,组件B又 import 组件A
// 这会导致循环依赖,编译报错

⚠️ 常见问题

Q1: 子组件修改 @Prop 值报编译错误

现象:在 Topics 子组件中尝试修改从父组件 MainTabs 传入的 initialCategory,编译器报错 @Prop is readonlyCannot assign to 'initialCategory' because it is read-only
原因@Prop 装饰器建立的是父到子的单向同步关系。子组件只能读取 @Prop 的值,不能修改。这是 ArkTS 的设计约束,目的是保证数据流向清晰可控。
解决方案:在子组件内部用 @State 维护一个可变的副本,@Prop 仅用于接收外部初始值。

// ❌ 错误写法:子组件直接修改 @Prop 值
@Prop initialCategory: string = 'all';

selectCategory(categoryId: string) {
  this.initialCategory = categoryId;  // 编译错误!@Prop 是只读的
}

// ✅ 正确写法:用 @State 维护内部可变副本
@Prop initialCategory: string = 'all';
@State currentCategory: string = 'all';  // 内部可变副本

aboutToAppear() {
  if (this.initialCategory) {
    this.currentCategory = this.initialCategory;  // 首次同步
  }
}

selectCategory(categoryId: string) {
  this.currentCategory = categoryId;  // 修改内部 @State,不触犯 @Prop
}

Q2: AppStorage 指令被重复执行,Tab 不停切换

现象:用户点击首页的分类卡片跳转到科普 Tab 后,科普 Tab 不停地闪烁切换,页面不断重新加载数据。
原因onTabSwitch 回调中处理完指令后没有清除 AppStorage 中的值。后续任何组件的状态变化触发重新渲染时,@StorageLink 检测到值仍然是 'topics',再次触发 onTabSwitch,形成无限循环。
解决方案:消费完 AppStorage 指令后立即清除,将值重置为空字符串或默认值。

// ❌ 错误写法:不清除指令,每次组件重新渲染都重复触发
onTabSwitch(): void {
  if (this.switchToTab === 'topics') {
    this.tabsController.changeIndex(1);
    // ❌ 没有清除指令!下次任何状态变化都会再次触发
  }
}

// ✅ 正确写法:消费后立即清除指令
onTabSwitch(): void {
  if (this.switchToTab === 'topics') {
    this.tabsController.changeIndex(1);
    if (this.storageTopicsCategory && this.storageTopicsCategory !== '') {
      this.topicsCategory = this.storageTopicsCategory;
    }
    // 处理完毕后清除,防止重复触发
    AppStorage.setOrCreate<string>('switchToTab', '');
    AppStorage.setOrCreate<string>('topicsCategory', 'all');
  }
}

Q3: 父组件未传回调函数,子组件点击时崩溃

现象:在 Previewer 中单独预览 TopicCard 组件时,点击卡片后应用闪退,报错 Cannot read property 'onItemClick' of undefinedthis.onItemClick is not a function
原因:子组件的回调属性声明为必选但未提供默认值。在 Previewer 中单独预览时,父组件不存在,onItemClick 为 undefined。即使有父组件,如果开发者忘记传递回调也会崩溃。
解决方案:将回调声明为可选属性(加 ?),并在调用前判空;或给一个空函数默认值。

// ❌ 错误写法:必选属性不给默认值,未传参时崩溃
onItemClick: (topic: Topic) => void;  // 必选,无默认值

.onClick(() => {
  this.onItemClick(this.topic);  // onItemClick 可能是 undefined,直接崩溃
});

// ✅ 正确写法:声明为可选属性,调用前判空
onItemClick?: (topic: Topic) => void;  // 可选

.onClick(() => {
  if (this.onItemClick) {
    this.onItemClick(this.topic);  // 判空后安全调用
  }
});

📝 总结

本文完整解析了《奇妙科学乐园》项目中的五种组件通信方式,覆盖了父子通信、子父回调、跨Tab全局状态、弹窗通信等核心场景。以下是核心要点回顾:

通信方式 适用场景 项目实例 关键要点
@Prop 父→子单向传递 MainTabs → Topics 只读、自动同步、配合aboutToUpdate
回调函数 子→父通知事件 TopicCard → Index 可选属性、判空调用
AppStorage 跨组件/跨Tab Index → MainTabs @StorageLink + @Watch + 指令清除
单例服务 全局数据共享 scienceData 非组件状态、import即用
CustomDialog 弹窗通信 Profile → CheckInDialog controller控制、内部自治

在实际开发中,选择通信方式的核心原则是:数据流向清晰、职责边界分明。父子通信优先用@Prop+回调,跨组件通信用AppStorage,全局数据用单例。避免滥用@Link(双向绑定)和过度使用AppStorage(所有状态都塞进去),保持组件的独立性和可测试性。

源码地址:https://atomgit.com/2301_79280419/WonderSciencePark

🔗 相关链接

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐