如果你是从 Web 开发转过来的,第一次用 ArkTS 写鸿蒙页面,大概率会干一件事:在 build 里写个 for 循环,想把数组挨个渲染出来。然后编译器直接给你标红,告诉你这种写法不被支持。
渲染控制这个词,听着玄乎,拆开其实就三件事:条件渲染、循环渲染、按需懒加载。我做了好几个鸿蒙应用之后回头看,这恰恰是声明式 UI 和传统命令式 UI 最本质的分水岭。这篇就把 if/else、ForEach、LazyForEach 这三板斧彻底讲透,顺便把我踩过的坑都摆出来。
适合谁看?刚写完静态页面、准备开始处理动态数据的开发者,以及列表一多就卡顿、想搞清楚渲染优化思路的朋友。这章节的位置很关键,前面几篇讲完了语言基础和状态管理,这一篇就是让那些状态真正“驱动”起页面来。
1. 渲染控制到底是什么:从“手动拼 UI”到“声明式编排”
很多新手对渲染控制的理解停留在“这是框架里的一个特殊写法”,其实它的底层逻辑是整套开发范式的转变。先花点时间把这个基础概念捋清楚,后面遇到问题你才找得着方向。
1.1 命令式时代,渲染是体力活
以前写界面,哪怕是做一个列表展示,也要经历一个很机械的过程:拿到数据数组,创建一个容器组件,然后遍历数组,每遍历到一个元素就手动创建一个子组件,设置好它的属性、绑定好数据,再把它 add 到容器里。数据一变化,你还得先把旧的子组件清掉,再重新创建一遍。
这个模式有俩问题:一是模板代码极多,写 300 行代码可能只是搭了个壳;二是开发者一不小心就会漏掉“清理旧视图”这一步,结果就是界面数据错乱、内存悄悄涨上去。
1.2 声明式 UI 把“如何渲染”交给了框架
ArkUI 这套体系的思路完全反过来。你在 build 里描述的,不是“一步步怎么把组件加到屏幕上”,而是“当前状态下,页面应该是长什么样的”。框架会自动把你声明的结构和数据绑定在一起,当状态发生变化时,它会自动找出哪些地方变了,然后只更新那部分。
// 传统思路(伪代码):手动创建、添加、清理 for (let item of list) { let view = createItemView(item); container.addChild(view); } // ArkTS 思路:声明数据到 UI 的映射关系 Column() { ForEach(this.list, (item: string) => { Text(item) }, (item: string) => item) }第二种写法里,你不 care 数据是新增了一条还是删除了一条,只需要把数组交给 ForEach,框架自己会算出该创建谁、该回收谁。渲染控制就是在实现这种“状态到 UI 的自动映射”。
1.3 ArkTS 渲染控制的“三件套”
ArkTS 里真正承担渲染控制的就三样东西:
| 能力 | 关键字 | 适用场景 | 核心特点 |
|---|---|---|---|
| 条件渲染 | if / else | 登录态切换、空态、加载态 | 组件树整体重建 |
| 循环渲染 | ForEach | 中等数量列表、网格 | 全量渲染、依赖 key 复用 |
| 懒加载渲染 | LazyForEach | 长列表、聊天记录、信息流 | 按需创建、可视区渲染 |
提示:Visibility、opacity 这些属性也能改变“看不看得见”,但那只是显示层面的控制,组件本身还在视图树里占着位置。真正的渲染控制是让组件“存在”或者“不存在”,这两者在性能和生命周期上差异巨大。
2. 条件渲染 if/else:分支切换背后的组件生命周期
条件渲染是做页面时用得最多的,也是我见过出错频率最高的。它本身语法很简单,真正的学问在它切换分支时做了什么。
2.1 基本用法与同步求值特性
@State hasData: boolean = false; build() { Column() { if (this.hasData) { Text('数据已加载') } else { Text('加载中...') } } }if/else 在 ArkTS 里是同步求值的。什么意思?就是说当前 UI 帧渲染的时候,条件表达式执行一次,结果为 true 就渲染第一个分支,为 false 就渲染 else 分支。它不会异步等待什么,也不是注册一个回调后面再触发。
这个特性也意味着:你在条件表达式里放的内容,会在每次状态变化时被重新求值。所以不要在条件里塞过于复杂的逻辑,比如this.list.filter(...).length > 0这种,每次状态一刷新整个列表就被过滤一遍,数据量大时纯属浪费。
2.2 分支切换会销毁组件,状态会丢
这是条件渲染最经典的坑。我之前做一个单据填写页面,用户在填备注时不小心触发了某个状态变化,页面从编辑态切到预览态再切回来,输入框里的文字全没了。
为什么?因为 if/else 切换分支时,ArkUI 会把旧分支的组件树直接销毁,再创建新分支的组件树。如果你输入框里的文字只是组件内部的临时状态,没有提升到父级的状态变量里,组件一销毁,那些内容就跟着没了。
解决办法很粗暴但有效:把“不能丢的数据”交给 @State 管理,让它待在组件外部。
@State remarkText: string = ''; build() { Column() { if (this.isEditing) { TextInput({ text: this.remarkText }) .onChange((value: string) => { this.remarkText = value; }) } else { Text(this.remarkText) } } }这样即使分支切来切去,数据源始终在状态变量里,组件重建后也能拿到旧值。
2.3 分支很长时,拆子组件而不是堆代码
我见过有人在一个 if 分支里写几十行组件,整个 build 方法膨胀到没法读。更难受的是,父组件里任何状态一变,这个巨大的 if 块都会重新执行。
把每个分支抽成独立的子组件是更聪明的做法。比如:
build() { Column() { if (this.status === 'loading') { LoadingView() } else if (this.status === 'empty') { EmptyView() } else { TaskListView() } } }每个 View 只关注自己的渲染逻辑,父组件只需要当一个“调度器”。这样代码可读性高,各分支的重渲染粒度也更小,性能上更友好。
2.4 if/else 和 Visibility、opacity 到底怎么选
很多初学者分不清这三个东西的使用边界,我直接给个对比表:
| 方案 | 组件是否存在于视图树 | 是否保留布局空间 | 推荐场景 |
|---|---|---|---|
| if/else | 否,直接销毁/创建 | 否 | 低频切换,内容差异大 |
| Visibility.Hidden | 是,保留组件但隐藏 | 是 | 频繁切换且要保留组件状态 |
| opacity(0) | 是,只是透明 | 是 | 动画过渡、渐变隐藏 |
提示:如果你只是想让某个区域暂时不可见、但又不希望每次切换都经历“销毁-重建”的损耗,用 Visibility 更合适;如果你要的是“这个分支不渲染任何东西”,才用 if/else。这两者的取舍直接关系到性能和交互体验。
3. ForEach 循环渲染:数组、key 与组件复用的关系
条件渲染解决的是“显示谁”的问题,循环渲染解决的是“重复渲染一批”的问题。ForEach 是新手第一次接触数据列表时用的最多的 API,但大多数人只用了它的最浅层。
3.1 三参数写法,一个都不能少
标准写法是这样的:
ForEach ( this.todoList, // 第一个参数:数据源 (item: TodoItem, index: number) => { // 第二个参数:单个元素生成函数 TodoItemView({ item: item, idx: index }) }, (item: TodoItem, index: number) => `${item.id}` // 第三个参数:key 生成器 )第一个参数是数组,第二个参数定义每个元素怎么生成组件,第三个参数就是整个 ForEach 的精髓——key 生成器。它决定了一个元素在 UI 里的身份标识。
3.2 key 生成器:稳定且唯一,不要用 index
很多教程的示例代码里,key 生成器直接返回index.toString(),图省事。但这是我在实际项目里踩过的最深的坑之一。
key 的作用是让框架识别“这个元素和之前那个元素是不是同一个”。当你用 index 当 key 时,问题就来了:删除列表第一项后,原来的第二项变成了 index 0,它的 key 从 "1" 变成了 "0"。框架一看,key 变了,就会把这个组件销毁、重新创建一个。本来只是删了一条数据,结果后面所有的组件都被重建了一遍。
表现是什么?列表删除时后面几项突然闪烁一下,如果有焦点或输入状态,会全部丢失。数据量不大时还好,几百条以上就能明显感觉到卡顿。
正确的做法是使用数据里真实唯一的字段,比如服务端返回的 id:
(item: TodoItem) => item.id如果服务端没给 id,就在数据进入列表时自己生成一个唯一标识,不要在 render 里临时拼。
3.3 数组增删改背后的刷新机制
ForEach 的数据源如果是 @State 修饰的数组,框架会对数组的方法调用做拦截。你用 push、splice、shift 这些方式改数组时,UI 会自动刷新。
但有几个“以为会刷新、其实没刷新”的场景:
// 不推荐:直接修改数组元素内部的属性 this.taskList[0].name = '新名字'; // 推荐:用数组方法整体操作,或替换整个数组项 this.taskList = this.taskList.map((item, index) => index === 0 ? { ...item, name: '新名字' } : item );直接改嵌套属性,状态管理经常监听不到,界面纹丝不动。所以我的习惯是:数据要改就改数组层面,不要怼着对象属性改。这套思路在 ArkTS 的状态管理章节也提到过,渲染控制这里会直接体现出来。
3.4 不要在 item 生成函数里做重活
item 生成函数看起来就是个箭头函数,但它会在很多时机被调用:数据变化时、父组件刷新时、key 变化时。你在这个函数里做 JSON.parse、做深拷贝、做复杂计算,每次调用都是真金白银的时间开销。
我之前接手过一个代码,在 item 生成函数里对每条数据做正则清洗,列表 200 条,每次操作都要卡一下。后来把清洗逻辑放到了数据层,数据进列表前就已经洗干净了,item 生成函数只做最简单的组件渲染,问题立刻消失。
4. LazyForEach 懒加载:长列表的渲染救星
ForEach 最大的问题是全量渲染:你有 10000 条数据,它就会尝试创建 10000 个组件。这在移动端是灾难性的,内存直接飙升,滑动掉帧。LazyForEach 就是为了解决这个而生的。
4.1 为什么要懒加载
懒加载的核心思想是“只渲染可视区域内的组件”,滑动的时候动态创建即将出现的,回收已经滑出屏幕的。你看到的内容永远只有屏幕上下那么一点,但滑动体验跟全量渲染一样顺畅。
它不能直接传一个普通数组,而是需要一个实现了IDataSource接口的数据源类。
4.2 IDataSource 接口四件套
很多人在这一步被劝退,觉得这接口看着复杂。其实拆开就四个方法:
class TaskDataSource implements IDataSource { private tasks: TaskItem[] = []; private listeners: DataChangeListener[] = []; // 数据总量 totalCount(): number { return this.tasks.length; } // 根据索引拿数据 getData(index: number): TaskItem { return this.tasks[index]; } // 注册监听器,LazyForEach 会主动调用 registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); } } // 取消监听 unregisterDataChangeListener(listener: DataChangeListener): void { const pos = this.listeners.indexOf(listener); if (pos >= 0) { this.listeners.splice(pos, 1); } } // 业务方法:新增数据 addTask(task: TaskItem): void { this.tasks.push(task); this.listeners.forEach(listener => listener.onDataAdd(this.tasks.length - 1)); } // 业务方法:按 id 删除数据 removeTaskById(id: string): void { const index = this.tasks.findIndex(t => t.id === id); if (index < 0) return; this.tasks.splice(index, 1); this.listeners.forEach(listener => listener.onDataDelete(index)); } }注意 components/DataChangeListener 的注册机制:LazyForEach 会通过registerDataChangeListener订阅你的数据源,你在增删数据后,要手动调用onDataAdd、onDataDelete这些回调,它才知道去局部刷新。
这是使用 LazyForEach 最容易疏忽的点。很多人改了数组却忘了通知监听器,结果页面迟迟不刷新,还以为是框架的 bug。
4.3 数据变更监听:局部增量的秘密
为什么 LazyForEach 能做到局部刷新?就是因为有这套监听机制。你调onDataAdd(2),框架就知道“第 3 条位置新增了一条”,它只需要在那个位置插入一个组件,而不是把整个列表重来一遍。
LazyForEach ( this.dataSource, (item: TaskItem) => { ListItem() { TaskItemView({ itemTitle: item.title }) } }, (item: TaskItem) => item.id )LazyForEach 的 key 生成器同样重要,规则和 ForEach 一样:稳定、唯一。因为懒加载本身要管理组件的创建和回收,key 判断错了会导致组件位置错乱、数据对不上号。
4.4 怎么取舍 ForEach 和 LazyForEach
| 对比维度 | ForEach | LazyForEach |
|---|---|---|
| 渲染方式 | 全量创建 | 按需创建,滑动时动态回收 |
| 数据量适应性 | 几十条以内尚可 | 几百条以上优势明显 |
| 数据变更 | 数组方法触发全量 diff | 监听器触发局部更新 |
| 实现成本 | 简单,直接传数组 | 需要实现 IDataSource |
| 适用场景 | 简单表单、固定选项 | 聊天记录、信息流、历史数据 |
我的建议比较直接:数据量超过 50 条,或者列表会频繁增删,就直接上 LazyForEach。ForEach 虽然写起来省事,但数据一大就进退两难,回头改造的成本更高。
5. 综合实战:带空态、加载态、删除更新的待办列表
单独讲每个 API 都比较容易理解,难的是把它们组合在一个页面里,用一套状态逻辑串起来。这节我带大家做一个实际的任务列表页面,把今天讲的东西全部用上。
5.1 页面需求
做一个等待办列表页面,完整流程是这样的:
- 进入页面先显示加载中状态
- 加载完成后,如果数据为空,显示空态提示
- 有数据时,用 LazyForEach 渲染列表
- 每条任务可以删除,删除时列表局部更新
- 删除到 0 条时,自动切回空态
5.2 数据结构与页面状态设计
数据源用前面写的TaskDataSource,页面状态用一个字符串类型变量控制。这里要注意:业务状态和数据源最好分开管理。业务状态管“页面处于什么阶段”,数据源管“有哪些数据要展示”。
@Entry @Component struct TodoListPage { @State private pageStatus: string = 'loading'; private dataSource: TaskDataSource = new TaskDataSource(); aboutToAppear(): void { this.loadTasks(); } loadTasks(): void { // 模拟网络请求 setTimeout(() => { const resp: TaskItem[] = [ { id: '1', title: '写周报' }, { id: '2', title: '代码审查' }, { id: '3', title: '修复闪退' } ]; resp.forEach(item => this.dataSource.addTask(item)); this.pageStatus = resp.length > 0 ? 'normal' : 'empty'; }, 500); } handleDelete(id: string): void { this.dataSource.removeTaskById(id); if (this.dataSource.totalCount() === 0) { this.pageStatus = 'empty'; } } build() { Column() { if (this.pageStatus === 'loading') { LoadingProgress() .width(60) .height(60) } else if (this.pageStatus === 'empty') { Text('暂无任务,休息一下') .fontSize(16) .fontColor('#999999') } else { List() { LazyForEach ( this.dataSource, (item: TaskItem) => { ListItem() { TaskItemView({ itemTitle: item.title, onDelete: () => this.handleDelete(item.id) }) } }, (item: TaskItem) => item.id ) } .cachedCount(3) .layoutWeight(1) } } .width('100%') .height('100%') .padding(12) } }5.3 子组件与数据更新的呼应
任务条目的子组件长这样:
@Component struct TaskItemView { @Prop itemTitle: string = ''; onDelete: () => void = () => {}; build() { Row() { Text(this.itemTitle) .fontSize(16) .layoutWeight(1) Button('删除') .fontSize(14) .backgroundColor('#FF4D4F') .onClick(() => this.onDelete()) } .width('100%') .padding(12) .backgroundColor('#FFFFFF') .borderRadius(8) .margin({ bottom: 8 }) } }重点说一下删除流程的设计。很多人会习惯性传 index 给删除函数,但在我这个例子里,LazyForEach 是懒加载渲染的,列表项的闭包里捕获的 index 是这个组件创建时的下标。一旦前面有任务被删了,数组整体移位,这个 index 指向的数据就已经不是当初那个 item 了。
所以我直接用item.id作为删除依据,在数据源内部去 find 出真实位置,从根源上规避 index 错位的问题。这也是长列表开发的核心经验之一:用唯一标识驱动更新,而不是用下标。
5.4 体验优化:别让空态出现得太突兀
这个示例比较完整了,但我会再做两个优化:
第一,cachedCount(3)是给 List 设置的缓存项数。它表示在可视区域外预创建多少项组件,这样滑动时不容易出现白屏,但也不要设得太夸张,缓存太多反而增加内存负担。经验值是 2 到 5 之间。
第二,空态和加载态的切换,如果没有动画,会觉得很生硬。可以在 pageStatus 变化时用animateTo包裹状态切换,或者给容器加一个transition效果:
animateTo({ duration: 300 }, () => { this.pageStatus = 'empty'; });状态驱动的页面,切换逻辑要尽量收拢到一个入口,不然页面业务一复杂,到处都在改 pageStatus,状态就乱套了。
6. 渲染控制的常见坑与我的避坑习惯
最后一节,我把这几类写法相关的典型问题整理了一下。很多问题不是语法不懂,而是对“状态什么时候驱动渲染、怎么驱动渲染”理解不深。
6.1 改了下标,UI 没变化
这是一个很普遍的现象:
this.list[0] = '新值';在 ForEach 的数据源里这样改,很多时候 UI 不会刷新。原因在前文提到过:状态管理对数组的观测是有边界的,直接改下标属于“绕过了观测机制”。
我现在的固定写法是:
// 方法一:整体替换数组 const newList = [...this.list]; newList[0] = '新值'; this.list = newList; // 方法二:使用数组方法 this.list.splice(0, 1, '新值');这两种方式都能被框架识别到,稳定可靠。
6.2 条件表达式里塞复杂计算,白费 CPU
之前说过了,if 条件里写this.list.filter(...).length > 0会把每次刷新都变成一次遍历。如果这个列表很大,每一次无关状态变化都在做无谓计算。
正确做法是把判断结果提前存成状态变量,业务逻辑改数据时同步改这个标记位,UI 层面只读标记位。
6.3 key 的稳定性比你想象的重要
我测试过同一个列表,用 index 做 key 和用 id 做 key,删除一条数据时的组件重建数量差异非常大。用 index 时,删掉第一条会导致后面所有组件 key 变化,框架会逐个重建;用 id 时,只有被删的那条消失了,其他组件原地不动。
在需要频繁增删的页面,key 写错,性能直接劣化一个量级。而且这种劣化在数据量少时肉眼看不出来,等列表上千条再用,卡到你怀疑人生。
6.4 渲染控制只是“果”,状态设计才是“因”
最后一句实在话。if/else、ForEach、LazyForEach 说到底只是工具,真正决定页面性能的不是工具本身,而是你有没有把状态设计清楚。哪些数据提升到全局状态,哪些数据留在组件内部,哪些数据要变成可观察的数组,这比任何 API 技巧都重要。
我的个人习惯是:渲染控制表达式里只放已经算好的状态变量,列表 key 一律用业务主键,数据量一大就换 LazyForEach,改数据只走数组方法或整体赋值。这几条做到了,渲染控制这块基本不会再出大问题。