咱们的JuanTree
组件功能开发完基本的增删改操作,接下来就是保存操作了。为了方便组件的使用者,我们将内置dirtyData
的封装,这样用户对接后台服务提交Tree
数据的变更将变得非常简单。
这种封装不但简化了组件用户的开发工作,而且提升了产品的用户体验,组件状态不用做任何的刷新,各种操作对用户都是无感知的噢~
用法
只需要获取组件的引用调saveDirty
方法即可:
用法非常简单!!
dirtyData
中封装好了增删改的数据,注意!!这里我们暂时只考虑改节点标签名,真正将我们的JuanTree
投入项目使用,往往我们要维护更多的后台模型相关的元数据,可以在此基础上很方便的进行扩展噢~
用户可以从dirtyData
中分拣数据包装与后台接口交互的入参然后调取后台接口服务,后台提交成功后,再在ajax
回调中调用done(...)
以完成tree
脏数据的flush
完成同步。这个过程tree
组件无需做任何的刷新和重新初始化噢~。只需要把设置好后台id的新增数据列表作为参数传给done
进行调用即可。
看下面的示例演示:
提交的数据,组件内部实现了格式封装:
{
"insert": [
{
"name": "新节点12",
"parentId": "aaa"
},
{
"name": "新节点11",
"parentId": "bbb",
"children": [
{
"name": "新节点223"
}
]
}
],
"update": [
{
"id": "b11",
"name": "b11112"
},
{
"id": "b22",
"name": "b2233"
}
],
"delete": [
"55",
"66",
"77"
]
}
下面看具体的实现。
ts类型调整
export interface IFlatTreeNode extends ITreeNode {
...
// 内部append方法,内部实现用的,不提供给用户,这里会接收一个data
append: (child: SingleEditNode, data: IFlatTreeNode[]) => IFlatTreeNode
// 内部删除方法
remove: (flatData: IFlatTreeNode[], treeData: ITreeNode[]) => IFlatTreeNode[]
...
temporary?: boolean // 新增未提交后端则属于临时节点
backId?: IdType // 对应后端模型的id
}
这里调整了新增节点的类型为SingleEditNode
,因为用户每次执行append
操作只会新增一个节点,也就是叶子节点,只是叫法上进行了一下优化,有单节点就有嵌套节点,后续涉及。
remove
方法增加了被移除扁平化节点列表的返回值,这便于收集节点编辑后的变更项。
然后增加了一个temporary
的可选属性,来表明一个节点是否属于临时节点,注意,临时节点是临时新增的,只有前端id
,并没有同步到后台。
最后增加了一个backId
属性,它对应后台模型的id
。这里我们区分前端id
和后台id
。
同样,提供给用户操作的接口中append
方法的入参类型也调整下:
export interface ITreeNodeOperation {
append: (node: SingleEditNode) => void // 新增子节点
...
}
将原先的ILeafNode
改名为SingleEditNode
:
// 新增的节点类型(这里只考虑每次新增一级,也就是叶节点)
export interface SingleEditNode {
id?: IdType // 用户可传id,但要避免冲突
label: string // 节点名称
}
同时创建一个NestedEditNode
节点类型,用于变更数据进行同步插入时使用,用它作为入参。我们可以基于多个节点分支进行子节点新增,则会对应一个NestedEditNode
数组,每个分支则是一个嵌套结构,为此,做如下定义:
export interface NestedEditNode {
id?: IdType // 用户可传id,但要避免冲突
label: string
parentId?: IdType // 最外层节点会关联一个父节点
// 嵌套结构,满足在新增的临时节点上继续新增子节点的场景
children?: NestedEditNode[]
}
我们为从上一次保存点开始做的节点编辑操作的变更(也就是脏数据)创建一个类型:
export interface DirtyData {
insert?: NestedEditNode[]
update?: SingleEditNode[]
delete?: IdType[]
}
这里我们详细划分出了要插入、更新以及删除的数据,并指定了各自的类型,让用户一目了然。
最后,我们提供一个保存变更的接口:
export interface SaveChanged {
(dirtyData: DirtyData, done: (insertDataWithId?: NestedEditNode[]) => void): void
}
这里会接收组件内部处理好的脏数据,也允许用户执行一个done
回调来flush
脏数据,注意,回调时如果有新增的变更,要把后台模型生成的节点id
同步给前端。
utils工具调整
调整下节点移除方法的返回值:
节点append
方法调整:
新增一级节点方法调整:
节点后端id初始化:
提供一个对新增的节点列表由嵌套结构进行拍平处理得到扁平化的id列表结构的功能函数:
index.tsx调整
...
// 新增节点数组
const insertNodes = [] as IFlatTreeNode[]
// 更新节点数组
const updateNodes = [] as IFlatTreeNode[]
// 删除节点id
const deleteNodeIds = [] as IdType[]
...
const _handleNodeRemove = (node: IFlatTreeNode) => {
// 删除并获取移除的节点列表
const removedFlatNodes = node.remove(originalFlatData, data)
// 如果删除的节点原先是新增的,也要从新增列表中移除掉
removedFlatNodes.forEach((item) => {
const index = insertNodes.findIndex((n) => n.id === item.id)
if (index >= 0) {
insertNodes.splice(index, 1)
}
// 如果删除的是非临时节点,则添加到删除列表中
if (!item.temporary) {
deleteNodeIds.push(item.backId!)
}
})
}
// 返回节点操作方法的函数
const nodeOperation = (node: IFlatTreeNode): ITreeNodeOperation => {
// 注意,这里不应该直接给用户提供node,而是要包成对外公开的ITreeNodeOperation,遵循迪米特法则!!
return {
...,
append(newNode: SingleEditNode) {
// 返回新的扁平化节点
const newFlatNode = node.append(newNode, originalFlatData)
_startEdit(newFlatNode)
// 插入新节点到临时新增数组中
insertNodes.push(newFlatNode)
},
async remove(call: RemoveCall) {
// 如果是临时节点直接删除
if (node.temporary) {
_handleNodeRemove(node)
} else {
try {
await call({
id: node.backId, // 非临时节点一定有后端id
...
} as IFlatTreeNode)
_handleNodeRemove(node)
} catch (msg) {
console.warn(msg)
}
}
}
}
}
// 结束节点标签名编辑的函数
const finishEdit = (node: IFlatTreeNode) => {
// 如果输入不为空,则绑定节点的名称
// 同时更新关联的原始节点的name,
if (editLabel.value !== '') {
const name = labelName as 'label'
// 获取编辑前的节点名称
const oldLabel = node[name]
// 如果编辑已存在的节点,且名称发生变更则添加到更新列表中
if (!node.temporary && editLabel.value !== oldLabel) {
updateNodes.push(node)
}
...
}
...
}
...
const saveDirty = (call: SaveChanged) => {
// 整理dirty数据
const name = labelName as 'label'
// 结构化转换
const insertData = [] as NestedEditNode[]
if (insertNodes.length > 0) {
const nodeMap = {} as any
// 按照层级排序
insertNodes.sort((n1, n2) => n1.level - n2.level)
insertNodes.forEach((n) => {
const node = {} as NestedEditNode
node[name] = n[name]
nodeMap[n.id] = node
if (n.parent) {
// 如果父节点是临时的,获取父节点并添加到其children中
if (n.parent.temporary) {
const parent = nodeMap[n.parent.id] as NestedEditNode
parent.children = parent.children || []
parent.children.push(node)
} else {
// 父节点是已存在的,则绑定父节点id并添加到插入列表中
node.parentId = n.parent.backId
insertData.push(node)
}
} else {
insertData.push(node)
}
})
}
// 构造变更数据
const dirtyData = {
insert: insertData,
update: updateNodes.map((n) => {
const node = {} as SingleEditNode
node.id = n.backId
node[name] = n[name]
return node
}) as SingleEditNode[],
delete: [...deleteNodeIds]
} as DirtyData
// 检测无变更数据的情况
if (dirtyData.insert!.length === 0 && dirtyData.update!.length === 0 && dirtyData.delete!.length === 0) {
console.warn('没有要保存的数据')
return
}
// 回调处理
call(dirtyData, (insertDataWithId?: NestedEditNode[]) => {
// 这里是与后端对接后的回调处理
const insertLength = insertNodes!.length
let insertIds = [] as IdType[]
if (insertDataWithId && insertDataWithId.length > 0) {
// 扁平化处理后台同步过来的插入数据,得到后台的id列表
insertIds = flattenNestedNodeIds(insertDataWithId)
}
// 检查是否长度一致
if (insertLength !== insertIds.length) {
throw Error('要同步的新增数据id列表长度不一致')
}
if (insertLength > 0) {
// 后台id同步到前端
insertNodes.forEach((n, i) => {
n.backId = insertIds![i]
delete n.temporary
console.log('已同步新增节点id.')
})
}
// 重置临时列表
insertNodes.length = 0
updateNodes.length = 0
deleteNodeIds.length = 0
})
}
const exposeProps: ExposeProps = {
saveDirty,
appendTop(newNode: SingleEditNode) {
// 内部方法调用
const newFlatNode = appendTop(newNode, originalFlatData, data, optionProps)
_startEdit(newFlatNode)
// 将新插入的节点添加到插入列表
insertNodes.push(newFlatNode)
}
}
expose(exposeProps)
...