基于主子孙(无限嵌套)数据结构的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时间。方案的选定,其实是取舍的问题,是收益和损失的权衡。

再分析一些主要的更新场景

  1.  主更新,子和孙没有任何更新,则主记录更新对应字段,子和孙记录全部需要diff掉。
  2.  主更新,子更新,孙未更新,子标记为UPDATE,孙被diff掉。
  3.  主更新,子更新,孙更新,子,孙都标记为UPDATE,若部分子或者孙没有任何变化,依然需要diff掉。
  4.  主更新,子删除,子及其孙都需要级联标记为DELETE,没有变化的其他子diff掉。
  5.  主更新,子新增,子及其存在的孙,都需要级联标记为INSERT,没有变化的其他子diff掉。
  6.  主更新,子未更新,孙更新了,这里需要注意,孙的更新场景有很多种,孙更新,孙新增,孙删除,或者孙的更新,新增,删除的排列组合,孙的这些场景,子都需要反向级联修改标记状态为更新

三、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实现

 

posted @ 2021-04-08 21:33  lswtianliang  阅读(227)  评论(0编辑  收藏  举报