不可变状态更新模式:ArkUI 中的数组替换与对象克隆

引言

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

星办 OA 项目在 ApprovalStoreApprovalDomain 中全面采用了不可变状态更新模式。本文将深入分析这种模式的原理、实现方式,以及为什么在 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 纯函数详解

approveApprovalApprovalDomain.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 数组方法不触发更新

陷阱:使用 pushpopsplice 等数组方法修改数组。

// 错误:不会触发 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 函数),但它带来的可维护性和可预测性远远超过了额外代码的成本。

Logo

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

更多推荐