HarmonyOS 「星办OA」App应用实战34 : 不可变状态更新模式:ArkUI 中的数组替换与对象克隆
不可变状态更新模式:ArkUI 中的数组替换与对象克隆
引言
在响应式 UI 框架中,状态更新的方式直接影响着应用的性能和可预测性。HarmonyOS NEXT 的 ArkUI 框架推荐使用不可变更新模式(Immutable Update Pattern),即不直接修改已有的状态对象,而是创建新的对象或数组来替换旧的状态。这种模式虽然看起来有些"浪费"(创建新对象需要额外的内存),但它带来了更可预测的响应式更新和更好的性能表现。

星办 OA 项目在 ApprovalStore 和 ApprovalDomain 中全面采用了不可变状态更新模式。本文将深入分析这种模式的原理、实现方式,以及为什么在 ArkUI 中需要这样做。
一、不可变更新的基本原理
1.1 什么是不可变更新
不可变更新是指:当状态需要更新时,不修改原始状态对象,而是创建一个包含更改的新对象或数组。在星办 OA 中,最典型的不可变更新是数组替换:
// 不可变更新:创建新数组并赋值
this.approvals = result.approvals
// 而不是直接修改原数组
// this.approvals.push(newItem) // 不推荐
1.2 为什么需要不可变更新
在 ArkUI 的响应式系统中,@Trace 装饰器通过比较对象的引用(reference)来判断状态是否发生了变化。对于基本类型(string、number、boolean),比较的是值;对于对象和数组,比较的是引用。
@ObservedV2
export class ApprovalStore {
@Trace approvals: ApprovalRequest[] = []
update() {
// 以下操作不会触发响应式更新:
this.approvals[0].status = '已通过' // 修改的是数组元素内部的属性,数组引用未变
this.approvals.push(newItem) // 数组引用未变
// 以下操作会触发响应式更新:
this.approvals = newArray // 数组引用改变
}
}
这就是为什么在星办 OA 中,所有的状态更新都通过数组替换来实现。
二、ApprovalStore 中的不可变更新实践
2.1 submit 方法
submit(type: string, title: string, summary: string, reason: string): ApprovalMutation {
let input: ApprovalCreateInput = new ApprovalCreateInput()
input.type = type
input.title = title
input.summary = summary
input.reason = reason
let baseId: string = `A${Date.now()}`
let id: string = baseId
let sequence: number = 1
while (this.getApproval(id) !== undefined) {
id = `${baseId}${sequence}`
sequence++
}
let result: ApprovalMutation = submitApproval(this.approvals, input, id, '刚刚')
if (result.success) {
this.approvals = result.approvals // ★ 数组替换,触发响应式更新
this.addActionMessage(result, '申请已提交', `${type}申请已进入审批流程。`)
}
return result
}
核心机制:
submitApproval()是一个纯函数,它接收当前approvals数组作为输入- 它创建一个新的
ApprovalRequest实例 - 返回的新数组是
[item, ...approvals],即在原数组头部插入新元素 this.approvals = result.approvals通过赋值操作触发响应式更新
2.2 approve 方法
approve(id: string, comment: string): ApprovalMutation {
let result: ApprovalMutation = approveApproval(this.approvals, id, comment, '刚刚')
if (result.success) {
this.approvals = result.approvals // ★ 数组替换
this.messages = resolvePendingMessages(this.messages, id, result.action) // ★ 数组替换
this.addActionMessage(result, '审批操作已完成', result.message)
}
return result
}
approve 方法同时更新了两个数组:
approvals数组:更新审批状态(从"审批中"变为"已通过")messages数组:将待办提醒解析为审批结果消息
2.3 reject 方法
reject(id: string, comment: string): ApprovalMutation {
let result: ApprovalMutation = rejectApproval(this.approvals, id, comment, '刚刚')
if (result.success) {
this.approvals = result.approvals // ★ 数组替换
this.messages = resolvePendingMessages(this.messages, id, result.action) // ★ 数组替换
this.addActionMessage(result, '申请已驳回', comment.trim())
}
return result
}
2.4 withdraw 方法
withdraw(id: string): ApprovalMutation {
let result: ApprovalMutation = withdrawApproval(this.approvals, id, '刚刚')
if (result.success) {
this.approvals = result.approvals // ★ 数组替换
this.addActionMessage(result, '申请已撤回', '你提交的申请已成功撤回。')
}
return result
}
2.5 markMessageRead 方法
markMessageRead(id: string): void {
let changed: boolean = false
this.messages.forEach((item: ApprovalMessage) => {
if (item.id === id && !item.isRead) {
item.isRead = true
changed = true
}
})
if (changed) {
this.messages = [...this.messages] // ★ 通过展开运算符创建新数组
}
}
这里有一个有趣的细节:markMessageRead 使用 forEach 直接修改了 item.isRead 属性,然后通过 [...this.messages] 创建了一个新数组来触发响应式更新。这是因为 ApprovalMessage 类没有使用 @ObservedV2 装饰,修改其属性不会自动触发 UI 更新,必须通过数组引用替换来触发。
三、approveApproval 纯函数详解
approveApproval 是 ApprovalDomain.ts 中的纯函数,它完整实现了不可变更新模式:
export function approveApproval(approvals: ApprovalRequest[], id: string,
comment: string, nowLabel: string): ApprovalMutation {
let source: ApprovalRequest | undefined = getApprovalById(approvals, id)
if (source === undefined || source.status !== ApprovalStatus.PENDING || !source.pendingForMe) {
return failedMutation(approvals, id, '当前审批不可执行同意操作')
}
let updated: ApprovalRequest[] = []
approvals.forEach((item: ApprovalRequest) => {
if (item.id !== id) {
updated.push(item) // 未修改的元素直接复用
return
}
let target: ApprovalRequest = cloneApproval(item) // ★ 克隆需要修改的对象
target.pendingForMe = false
target.updatedAt = nowLabel
target.comment = comment.trim().length > 0 ? comment.trim() : '同意'
target.handledByMe = true
target.events.push(newApprovalEvent(target.currentNode, '李明', target.comment, nowLabel, '同意'))
if (target.hasNextNode) {
target.currentNode = '部门负责人审批'
target.approver = '王芳'
target.hasNextNode = false
} else {
target.status = ApprovalStatus.APPROVED
target.currentNode = '流程已完成'
}
updated.push(target) // ★ 将修改后的克隆对象加入新数组
})
let result: ApprovalMutation = new ApprovalMutation()
result.approvals = updated
result.success = true
result.message = source.hasNextNode ? '已同意,流程已转交下一审批人' : '审批已通过'
result.approvalId = id
result.action = '同意'
return result
}
3.1 不可变更新的三个步骤
- 克隆需要修改的对象:使用
cloneApproval(item)创建待修改元素的深拷贝 - 修改克隆对象:在克隆对象上进行所有修改操作
- 构建新数组:将未修改的元素和修改后的克隆对象组合成新数组
3.2 不变元素的复用
对于数组中不需要修改的元素,approveApproval 直接复用原对象引用:
if (item.id !== id) {
updated.push(item) // 直接复用,不创建新对象
return
}
这避免了不必要的对象创建,是一种性能优化策略。
四、cloneApproval 深度克隆分析
cloneApproval 函数实现了 ApprovalRequest 对象的深度克隆:
function cloneApproval(source: ApprovalRequest): ApprovalRequest {
let target: ApprovalRequest = new ApprovalRequest()
target.id = source.id
target.type = source.type
target.title = source.title
target.applicant = source.applicant
target.applicantDepartment = source.applicantDepartment
target.summary = source.summary
target.reason = source.reason
target.status = source.status
target.currentNode = source.currentNode
target.approver = source.approver
target.submittedAt = source.submittedAt
target.updatedAt = source.updatedAt
target.urgency = source.urgency
target.comment = source.comment
target.isMine = source.isMine
target.pendingForMe = source.pendingForMe
target.handledByMe = source.handledByMe
target.hasNextNode = source.hasNextNode
target.events = source.events.map((item: ApprovalEvent) => {
return newApprovalEvent(item.title, item.operator, item.comment, item.createdAt, item.action)
})
return target
}
4.1 浅克隆 vs 深克隆
- 基本类型属性(string、number、boolean):直接赋值,值拷贝
- 数组属性(events):使用
map创建新数组,每个元素也创建新对象(深克隆)
events 数组,这里使用了 map 来创建新数组和新的 ApprovalEvent 实例,确保克隆后的对象与原对象完全独立。
4.2 为什么需要深克隆
如果只进行浅克隆(只创建新对象,但内部的 events 数组引用同一个对象),那么修改新对象的 events 数组会影响原对象,导致不可预测的副作用。
五、消息不可变更新:resolvePendingMessages
resolvePendingMessages 函数处理消息的不可变更新:
export function resolvePendingMessages(messages: ApprovalMessage[], approvalId: string,
action: string): ApprovalMessage[] {
let changed: boolean = false
let result: ApprovalMessage[] = []
messages.forEach((item: ApprovalMessage) => {
if (item.approvalId === approvalId && item.category === '待办提醒') {
let resolved: ApprovalMessage = cloneMessage(item) // ★ 克隆需要修改的消息
resolved.category = '审批结果'
resolved.title = `待办已${action}`
resolved.content = `你已完成该申请的${action}操作。`
resolved.createdAt = '刚刚'
resolved.isRead = true
result.push(resolved)
changed = true
} else {
result.push(item) // 不变的消息直接复用
}
})
return changed ? result : messages // ★ 如果没有变化,返回原数组
}
5.1 引用相等性优化
return changed ? result : messages 是一个重要的优化:如果不需要修改任何消息(changed === false),直接返回原数组。这样,this.messages = resolvePendingMessages(...) 的赋值操作会使用相同的引用,不会触发不必要的 UI 更新。
5.2 cloneMessage 的实现
function cloneMessage(source: ApprovalMessage): ApprovalMessage {
return newMessage(source.id, source.category, source.title, source.content,
source.approvalId, source.createdAt, source.isRead)
}
cloneMessage 直接调用 newMessage 工厂函数创建新对象,实现浅克隆(因为 ApprovalMessage 的所有属性都是基本类型)。
六、数组替换的触发器机制
6.1 触发器的本质
在 ArkUI 中,@Trace 装饰的属性的赋值操作就是触发器。当执行 this.approvals = newArray 时:
- ArkUI 框架检测到
@Trace属性被赋值 - 比较新旧值的引用是否相同
- 如果引用不同,标记该组件为"脏"(dirty)
- 在下一个渲染帧中,重新渲染依赖于该属性的 UI 组件
6.2 数组展开运算符作为触发器
在 markAllMessagesRead 中,使用数组展开运算符创建新数组:
markAllMessagesRead(): void {
this.messages.forEach((item: ApprovalMessage) => {
item.isRead = true
})
this.messages = [...this.messages] // 展开运算符创建新数组
}
[...this.messages] 创建了一个包含相同元素的新数组,但数组引用不同,从而触发响应式更新。
6.3 触发器的性能考量
数组替换触发器的性能开销主要在 ArkUI 框架的 diff 算法中:
- 框架需要比较新旧数组的元素
- 对于
@Type标注的数组,框架需要为新增元素建立响应式绑定 - 对于移除的元素,框架需要清理响应式绑定
在星办 OA 的典型场景中,数组长度通常不超过 10 个元素,性能开销可以忽略不计。
七、不可变更新模式的优势
7.1 可预测性
不可变更新使得状态变化可预测:每次状态更新都是显式的赋值操作,不会有隐式的状态变化。
7.2 调试友好
在调试时,可以清晰地看到状态的变化轨迹:
// 每个状态变更都是显式的
this.approvals = result.approvals // 审批列表更新
this.messages = [...this.messages] // 消息列表更新
7.3 避免副作用
不可变更新避免了隐式的副作用:修改一个对象不会意外地影响其他引用该对象的组件。
7.4 性能优化
通过引用比较,框架可以快速判断状态是否变化,而不需要深度比较对象内容。
八、常见陷阱与解决方案
8.1 直接修改数组元素
陷阱:直接修改 @Trace 数组中的元素属性,然后期望 UI 自动更新。
// 错误:不会触发 UI 更新
this.approvals[0].status = '已通过'
解决方案:使用不可变更新模式,创建新数组替换旧数组。
8.2 数组方法不触发更新
陷阱:使用 push、pop、splice 等数组方法修改数组。
// 错误:不会触发 UI 更新
this.messages.push(newMessage)
解决方案:使用展开运算符或 concat 创建新数组。
// 正确:创建新数组
this.messages = [newMessage, ...this.messages]
8.3 忘记克隆嵌套对象
陷阱:浅克隆了数组元素,但嵌套对象(如 events)仍然共享引用。
// 错误:shallowClone 只克隆了顶层属性
let target = { ...source }
target.events.push(newEvent) // 修改了原对象的 events 数组
解决方案:使用深克隆,如 cloneApproval 中的 events.map(...)。
九、总结
不可变状态更新模式是星办 OA 项目状态管理的核心模式。通过全面使用数组替换和对象克隆,项目实现了:
- 可靠的响应式更新:每次数组替换都触发 UI 更新
- 可预测的状态变化:状态变更都是显式的赋值操作
- 避免副作用:深克隆确保修改不会影响原始数据
- 性能优化:引用比较和不变元素复用减少不必要的渲染
这种模式虽然需要更多的代码编写(如 cloneApproval 函数),但它带来的可维护性和可预测性远远超过了额外代码的成本。
更多推荐


所有评论(0)