react-okr-tree Logoreact-okr-tree
指南

数据变更与源数据回写

React 下的 data 变更检测(换引用 / 同引用原地改 / refreshData)、增删方法回写源数据、冻结数据边界。

data 是唯一必填 prop,也是 React 版与 Vue 版行为差别最大的一块。这一页说明三件事:改了数据要怎么生效、组件会反过来改你的数据哪些部分、以及数据被冻结时会发生什么。

组件怎么发现 data 变了

源项目用 watch(() => props.data, …, { deep }),一个监听同时覆盖「引用变化」和「同引用原地变更」。React 没有这层能力:同引用变异不会触发重渲染,因此根本进不了比较逻辑。React 版把判定放在每次渲染之后做一次只读脏检查,四条路径分别是:

你做了什么组件行为节点状态(展开 / 选中 / 勾选)
data 引用,内容确实不同store.setData() 全量重建重建;传入受控 expandedKeys / currentKey 时按受控值恢复
data 引用,但元素逐个是同一批对象视为未变,跳过重建全部保留
同一引用、内部 push / splice,且宿主组件恰好重渲染结构脏检查命中 → 走 updateChildren() 增量重建复用既有子节点实例的状态
同一引用内部变更,宿主没有重渲染感知不到不变。需要显式调 handle.refreshData()

第二行是 React 特有的:元素逐个相同就认定为没变。因为 data={[...]}data={rows.map(...)} 这类每次渲染新建数组字面量的写法在 React 里远比 Vue 常见,若无条件重建,用户的展开态会被反复冲掉。代价是一个边角情况:如果你既原地改了元素、又恰好换了外层数组引用而元素没变,脏检查不会跑(它只在两个引用都相同时执行),此时仍要靠 refreshData()

增量重建的判据是「逐项同引用 + 长度一致」,逐层向下检查;命中差异的那一层才重建。

推荐写法:交给引用变化

'use client'

import { useState } from 'react'
import { OkrTree, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'

export default function App() {
  const [rows, setRows] = useState<TreeNodeData[]>(() => [
    { id: 1, label: '总部', children: [{ id: 2, label: '研发部' }] },
  ])

  const addDept = () => {
    setRows(prev => [
      {
        ...prev[0],
        children: [...(prev[0].children ?? []), { id: Date.now(), label: '新部门' }],
      },
      ...prev.slice(1),
    ])
  }

  return (
    <>
      <button type="button" onClick={addDept}>
        新增部门
      </button>
      <OkrTree data={rows} nodeKey="id" showCollapsable defaultExpandAll />
    </>
  )
}

不可变更新在 React 版里同样是有效的路径——新数组元素引用变了,脏检查同样能命中。

refreshData:状态库直接 mutate 时的兜底

zustand / jotai 之类的 store 里直接改对象、或从别处拿到同一个数组引用做了增删,宿主重渲染与否全凭运气。此时调 handle.refreshData(),它等价于 store.setData(store.data),强制走一次增量路径:

'use client'

import { useRef } from 'react'
import { OkrTree, type OkrTreeHandle, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'

const rows: TreeNodeData[] = [{ id: 1, label: '总部', children: [{ id: 2, label: '研发部' }] }]

export default function App() {
  const tree = useRef<OkrTreeHandle>(null)

  const pushFromOutside = () => {
    rows[0].children.push({ id: 3, label: '设计部' })
    tree.current?.refreshData()
  }

  return (
    <>
      <button type="button" onClick={pushFromOutside}>
        外部变更后同步
      </button>
      <OkrTree ref={tree} data={rows} nodeKey="id" showCollapsable defaultExpandAll />
    </>
  )
}

这是 D7:能力降级 + 新增 API

「同引用原地变更」的自动感知在 React 下不可能等价复刻,因此默认行为是尽力检测(渲染时脏检查),显式 refreshData() 是保证。这条差异在迁移说明里编号 D7。

deepWatch:超大数据量时关掉

deepWatch 默认 true,即每次 OkrTree 渲染后跑一次结构脏检查。2000+ 节点的数据集下这笔开销每次都付,而你的数据如果本来就只换引用,可以直接省掉:

<OkrTree data={rows} nodeKey="id" deepWatch={false} showCollapsable />

false 时只响应引用变化,与源项目的 deep-watch: false 同名同语义。它是创建期快照类 prop,运行时变更不会生效;需要切换请给组件绑 key 重挂载。

组件会写回你的源数据

append / insertBefore / insertAfter / remove / moveNode / updateKeyChildren 以及懒加载 resolve,都会同步修改你传入的源数据children 数组,而不只是内部视图状态。这是源项目的正式行为(其 Q3 结论),React 版照抄,理由很直接:视图与源数据不分叉,宿主换引用重建时不会看到「刚加出来的节点消失」。

'use client'

import { useRef } from 'react'
import { OkrTree, type OkrTreeHandle, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'

const makeData = (): TreeNodeData[] => [
  { id: 1, label: '总部', children: [{ id: 2, label: '研发部' }] },
]

export default function App() {
  const tree = useRef<OkrTreeHandle>(null)
  const data = makeData()

  return (
    <>
      <button
        type="button"
        onClick={() => tree.current?.append({ id: 9, label: '销售三部' }, 1)}
      >
        追加子节点
      </button>
      <button type="button" onClick={() => tree.current?.remove(2)}>
        删除 id=2
      </button>
      <OkrTree ref={tree} data={data} nodeKey="id" showCollapsable defaultExpandAll />
    </>
  )
}

这份数据会被改写

写回之后 data 里的对象已经和你当初传进去的不再是同一批。同一份数组字面量被多个用例或两个组件实例共用时,一个实例的增删会污染另一个——源项目的 Demo 数据因此全部写成工厂函数,每个用例拿独立副本。你在测试与示例里也应当这么做(上面示例中的 makeData())。

remove 若删掉的正是当前选中节点,会同步触发 onCurrentKeyChange

冻结 / 只读源数据的边界

只读操作全部正常:渲染、展开收起、过滤、勾选、键盘导航在 Object.freeze 或外部 store 的 readonly 数据上都不会抛错。被影响的只有上面那批回写操作——它们跳过写入并输出一条开发期警告(检测到冻结/只读源数据,"…" 需要回写源数据,本次操作不会生效),视图那一层仍会照常改动。也就是说下一次数据重建会把这个节点「弹」回来,因为源数据里它还在。

要冻结数据就得自己管状态

Object.freeze 的数据上做增删,正确做法是更新上层状态、产生新引用让组件重建,而不是指望组件替你改。反过来,只想在可变数据上关掉回写也没有开关:回写是正式行为。

未设 nodeKey 时的内部 key 策略

不配 nodeKey 也能正常渲染与交互:组件在每个源数据对象上写入一个不可枚举$treeNodeId 作为 React key(写不进去时降级到内部 WeakMap 记同样的 id,行为不变、不抛错)。

代价是节点注册表是空的,凡按 key 或按 data 对象定位节点的能力都查不到:

受影响表现
getNode(data 对象)返回 null
getCheckedKeys / getHalfCheckedKeys返回空数组
defaultExpandedKeys / expandedKeys / currentKey / defaultCheckedKeys不生效(开发期警告)
updateKeyChildren / setCurrentKey / setCurrentNode / getCurrentKey[Tree] nodeKey is required in <方法名>
remove静默无效

抛错文案里的方法名沿用源项目,包括 updateKeyChildren 那条保留了原版被截断的 updateKeyChild 拼写——按文案搜索日志时注意这点。

仍然可用的是TreeNode 实例的那批:renderNode 作用域里的 node、各事件回调的节点参数,以及不依赖注册表的 getVisibleNodes() / expandAll() / collapseAll() / filter()

另外,深拷贝源数据(JSON.parse(JSON.stringify(data))、某些状态库的快照恢复)会丢掉这个不可枚举标记,克隆出的对象会被分配新的内部 id、被当作不同节点,展开态随之丢失。需要持久化、跨拷贝定位或使用任何按 key 的能力,就配 nodeKey

过滤语义

handle.filter(value) 触发全树过滤,配 filterNodeMethod

  • 全部根节点开始遍历,所有节点(含第一层)都执行 filterNodeMethod;父节点自身不匹配但有可见后代时保持可见(element-ui 语义)。
  • filter('') 同样会对每个节点执行方法,因此必须对空值返回 true 才能恢复全部显示。
  • 未传 filterNodeMethod 时调用 filter()[Tree] filterNodeMethod is required when filter
  • 过滤不会触发懒加载节点的 load(未加载子树内容未知)。
  • 被过滤掉的节点不渲染(组件直接返回 null),DOM 里没有它们;这与「折叠」不同——折叠的子树仍然挂载在 DOM 中(容器带 is-hidden 类与 visibility / height 内联),只是不可见。
  • showNodeNum 的数字只计未被过滤隐藏的可见子节点;aria-setsize / aria-posinset 也只按可见兄弟计数。
'use client'

import { useRef, useState } from 'react'
import { OkrTree, type OkrTreeHandle, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'

export default function App({ data }: { data: TreeNodeData[] }) {
  const tree = useRef<OkrTreeHandle>(null)
  const [keyword, setKeyword] = useState('')

  return (
    <>
      <input
        value={keyword}
        onChange={e => {
          setKeyword(e.target.value)
          tree.current?.filter(e.target.value)
        }}
      />
      <OkrTree
        ref={tree}
        data={data}
        nodeKey="id"
        showCollapsable
        filterNodeMethod={(value, item) =>
          !value ? true : String(item.label).includes(String(value))
        }
      />
    </>
  )
}

OKR 模式下 filter 同时命中左右两棵树;两棵树允许共用同一批 id,因为注册表本就分开。

本页目录