基于主子孙(无限嵌套)数据结构的diff算法分析和尝试
一、 主子孙(无限嵌套)数据结构长啥样?
模拟一种场景,一个人是主实体(聚合根),此人有多张银行卡(子),每张银行卡有多次取款记录(孙),它的数据结构示意图大致如下
实际业务中,子节点和孙节点都是数组存储,数据量很大时可能还需要考虑分页场景,所以子和孙应该是对象或者二维数组来表达,他可能用类似下面的数据结构来存储。(注意:本文谈的diff算法,不能直接使用于下面的数据结构,需要先把页码层级拉平处理后才可适用)。
实际开发过程中,对于分页的信息,后端api一般是一次只会返回某一页的数据,前端拿到数据后,需要经过转换后才可挂载到对应的树结构中。
二、深入需求分析
前端数据拿到后,用户可能需要对这些数据修改(如用户可能修改子和孙,删除子和孙,新增子和孙,以及他们的各种组合),修改后需要diff出变化的部分,传递给后端做最少的更新。
上述两种场景,本质都是树结构,树的diff, 前端的同学不由自主地联想到目前主流的前端框架(React,Vue)都会采用类似的diff算法来计算变化的DOM,再去局部刷新,以提高浏览器操作,提高渲染性能。我们其实这里本质是一样,但是怎么做呢?
回到我们的场景,深入分析下,其实有两种方案。
方案一,初始时深拷贝一份原始数据,用户修改的数据实时存储到前端内存中,用户提交时,比较当前数据和(初始克隆下来)原始数据,diff出差异。
方案二,初始时不深拷贝数据,用户修改数据实时反映到内存的那唯一份数据中(可使用受控组件,计算出path,使用immer或者lodash/fp/set方法更新数据),修改数据时还需要当前数据条目做标记,比如新增了一项,这条数据标记为Insert, 删除了标记为Delete(注意这里的删除只是做标记,方案一是直接物理删除,而这里只是逻辑删除),修改了需要标记为Update,提交时再根据这些标记状态,diff出差异。
为了后端快速处理,减少数据库IO和再比较,实际上方案一和方案二都需要打标记的,后端根据标记(Insert,Delete, Update)傻瓜式地调用数据实体的CRUD既可。
只是方案一是最后提交比较时统一打标记,而方案二是在用户操作时就打好了大部分标记。这里为何是大部分呢?因为子和孙的任何改动,即使祖先节点非子节点没有任何修改,祖先节点也需要打标记为修改。
注意:方案二中祖先节点级联修改在最后diff算法时更适合做,想想为什么?
因为每一步修改,都需要递归修改他的祖先节点,并且很多中间状态的修改是临时的(update->delete),这样下来会做无用功,且总的操作数变多了,还不太符合对象immutable的原则。
但一个方案往往也有可取的地方,它的一个好处是分散了最后diff部分是对祖先节点打标记的cpu时间。方案的选定,其实是取舍的问题,是收益和损失的权衡。
再分析一些主要的更新场景
- 主更新,子和孙没有任何更新,则主记录更新对应字段,子和孙记录全部需要diff掉。
- 主更新,子更新,孙未更新,子标记为UPDATE,孙被diff掉。
- 主更新,子更新,孙更新,子,孙都标记为UPDATE,若部分子或者孙没有任何变化,依然需要diff掉。
- 主更新,子删除,子及其孙都需要级联标记为DELETE,没有变化的其他子diff掉。
- 主更新,子新增,子及其存在的孙,都需要级联标记为INSERT,没有变化的其他子diff掉。
- 主更新,子未更新,孙更新了,这里需要注意,孙的更新场景有很多种,孙更新,孙新增,孙删除,或者孙的更新,新增,删除的排列组合,孙的这些场景,子都需要反向级联修改标记状态为更新
三、diff算法
方案一的实现,采用深度优先递归遍历树算法,对同级节点做diff,并级联修改祖辈节点的标记。
注释:ACTION_TYPE为枚举,有INSERT、UPDATE和DELETE三个值。
示意代码:
1 //diff比较原始树和当前树结构 2 const diffNode = (original: any, current: any) => { 3 const allChildNodes = Object.keys(current).filter((key) => Array.isArray(current[key])); 4 let changed = false; 5 allChildNodes.forEach((childNode) => { 6 //这里必须判空,因为两颗树结构经过变化好,可能新增了子,也可能删除了整个子。 7 const currNode = current ? current[childNode] : []; 8 const oriNode = original ? original[childNode] : []; 9 current[childNode] = diffChildNodes(oriNode, currNode); 10 if (current[childNode].length) { 11 //如果子节点有变化,则认为当前父节点也有变化 12 changed = true; 13 } 14 }); 15 16 return changed; 17 }; 18 19 const diffChildNodes = (originalArray: any[] = [], currentArray: any[] = []) => { 20 if (!originalArray) { 21 originalArray = []; 22 } 23 const diffArray = []; 24 for (const oriItem of originalArray) { 25 const id = oriItem.id; 26 const currentItem = currentArray.find((curr) => curr.id === id); 27 //存在,则说明是更新节点 28 if (currentItem) { 29 const changed = diffNode(oriItem, currentItem); 30 //如果子节点有更新,则父节点也认定为更新 31 if (changed) { 32 diffArray.push({ ...currentItem, actionType: ACTION_TYPE.UPDATE }); 33 } else { 34 //如果子节点没有变化,则比较普通非数组节点的值是否有变化 35 const allNormalKeys = Object.keys(currentItem).filter( 36 (key) => !Array.isArray(currentItem[key]), 37 ); 38 39 //原始对象和当前对象对应key进行比较 40 for (const normalKey of allNormalKeys) { 41 if (!isEqual(oriItem[normalKey], currentItem[normalKey])) { 42 diffArray.push({ ...currentItem, actionType: ACTION_TYPE.UPDATE }); 43 break; 44 } 45 } 46 } 47 } else { 48 //删除节点,删除节点只需要指定父级节点为DELETE 49 oriItem.actionType = ACTION_TYPE.DELETE; 50 diffArray.push(oriItem); 51 } 52 } 53 54 //新增节点 55 const allNewItems = currentArray.filter((curr) => !curr.id); 56 for (const newItem of allNewItems) { 57 //新增节点子节点也需要全部设置为INSERT 58 diffNode(null, newItem); 59 diffArray.push({ ...newItem, actionType: ACTION_TYPE.INSERT }); 60 } 61 return diffArray; 62 };
方案二的diff算法, 深度优先的递归实现,自身打标记,只级联修改祖辈的标记。
注释:MODIFY_MARK_FIELD为字符串常量 “actionType”,MODIFY_TYPE为枚举,含有INSERT、UPDATE和DELETE 三个枚举值。ID_PREFIX:新增节点由于没有id值,新建时使用标识前缀+number(自增),diff时需要去掉前端赋予的id值。
示意代码:
1 const diffNode = (data: any, isRoot?: boolean) => { 2 const res = {} as any; 3 4 let isCheckChildren = true; 5 //如果自身有actionType时,且为删除标记时则不check孩子节点的状态,且不追加到diff结果中。 6 if (data.hasOwnProperty(MODIFY_MARK_FIELD) && data[MODIFY_MARK_FIELD] === MODIFY_TYPE.DELETE) { 7 isCheckChildren = false; 8 } 9 10 for (const key in data) { 11 if (Array.isArray(data[key])) { 12 const childNodes = diffChildNodes(data[key]); 13 if (isCheckChildren) { 14 for (const item of childNodes) { 15 if (item.hasOwnProperty(MODIFY_MARK_FIELD)) { 16 if (!res[key]) { 17 res[key] = []; 18 } 19 20 if (!item.id && item[MODIFY_MARK_FIELD] === MODIFY_TYPE.DELETE) { 21 // 如果是insert节点且被标记为删除状态需要diff掉 22 continue; 23 } else { 24 res[key].push(item); 25 } 26 } 27 } 28 29 // 有子节点修改变动,且自身没有修改标记, 且不是聚合根节点,需要修改父节点的状态为update 30 if (res[key]?.length && !data[MODIFY_MARK_FIELD] && !isRoot) { 31 res[MODIFY_MARK_FIELD] = MODIFY_TYPE.UPDATE; 32 } 33 } 34 } else { 35 if (key === 'id' && data[key].startsWith(ID_PREFIX)) { 36 continue; 37 } 38 39 res[key] = data[key]; 40 } 41 } 42 43 return res; 44 }; 45 46 const diffChildNodes = (childNodes: any[]) => { 47 const res = []; 48 for (const item of childNodes) { 49 res.push(diffNode(item)); 50 } 51 52 return res; 53 };
四、主子孙前端主要呈现形式
1. 重复节嵌套重复表
重复节:容器组件,它可以嵌套其他组件,还可以不断添加。
重复表:table组件,行可以不断新增。
2. 重复节嵌套重复节(支持无线嵌套)
3. 重复表嵌套,参照antd的table实现