!!!###!!!title=7.3 PivotTable 代码结构和细节分析——VisActor/VTable 社区贡献者文档!!!###!!!!!!###!!!description=---title: 7.3 PivotTable 代码结构和细节分析 key words: VisActor,VChart,VTable,VStrory,VMind,VGrammar,VRender,Visualization,Chart,Data,Table,Graph,Gis,LLM---!!!###!!!

树形展示

需求

PivotTable 的一大特点就是树形的 rowHeadercolumnHeader。用户能根据以下配置定义树的展示形式:

  • rowHierarchyType / columnHierarchyType :树展示模式

  • grid (同时支持 row 和 column)

  • tree (仅支持 row)
  • grid-tree (同时支持 row 和 column)
  • indicatorsAsCol:指标是否作为列表头展示,默认为 true

  • rowExpandLevel / columnExpandLevel:默认展开层数

  • 当自定义rowTree / columnTree,对每个节点可用 node.hierarchyState 设置节点的折叠状态

问题

由上面的需求,我们可能会有一些疑问❓:

  • 怎么渲染出树形rowHeader / columnHeadr,需要什么数据

  • 不同的 rowHierarchyType / columnHierarchyType,会有不同的合并单元格、展开逻辑,怎么处理比较优雅?

  • 布局算法是怎么处理这几种HierarchyType

源码

在 7.2 的「自动组织维度树」小节中,我们知道了 Dataset 模块的 setRecords方法中,会根据从原始数据中收集到的维度成员rowKeys,**调用 ****ArrToTree****组装好维度树,储存在 ****Dataset.rowHeaderTree**

后续 PivotTable 会根据rowHeaderTree继续做一些处理,渲染树形表头,我们一起来看下这整个链路的细节

Dataset.rowHeaderTree / colHeaderTree

  • 如果用户传自定义树customRowTree/colHeaderTree,就直接赋值给 dataset.rowHeaderTree / colHeaderTree

  • 否则就用 ArrToTreeArrToTree1将维度成员rowKeyscolKeys 转为树形结构,再进行赋值

// packages/vtable/src/dataset/dataset.ts
export class Dataset {
    ...
    
    setRecords(records: any[] | Record<string, any[]>) {
        ...
        
        if (this.customRowTree) {
            this.rowHeaderTree = this.customRowTree;
        } else {
            if (this.rowHierarchyType === 'tree') {
                this.rowHeaderTree = this.ArrToTree1(...)
            } else {
                this.rowHeaderTree = this.ArrToTree(...)
            }
        }
        
        if (this.customColTree) {
            this.colHeaderTree = this.customColTree;
        } else {
            this.colHeaderTree = this.ArrToTree(...)
        }
    }
}    

DimensionTree

  • 会从 dataset.rowHeaderTree / colHeaderTree 或用户自定义表头树,作为参数传给 DimensionTree 类,实例化生成rowDimensionTree / columnDimensionTree
// packages/vtable/src/PivotTable.ts
export class PivotTable extends BaseTable implements PivotTableAPI {
    constructor(...) {
        ...
        
        const keysResults = parseColKeyRowKeyForPivotTable(this, options);
        let { columnDimensionTree, rowDimensionTree } = keysResults;
        
        ...
        
        if (!options.columnTree) {
            
            **columnDimensionTree = new DimensionTree(**
                (this.dataset.colHeaderTree as ITreeLayoutHeadNode[]) ?? [],
                ...
            );
        }
        
        if (!options.rowTree) {
            
            **rowDimensionTree = new DimensionTree(**
                (this.dataset.rowHeaderTree as ITreeLayoutHeadNode[]) ?? [],
                ...
            )
            
        }
        
        
    }    
}    

  • DimensionTree 类的 constructor 函数中,核心逻辑在this.setTreeNode(this.tree, 0, this.tree)setTreeNode是一个递归函数,会遍历树,对每个节点都做setTreeNode处理

  • 生成节点id

  • 根据 **hierarchyType**配置和**node.hierarchyState**,更新节点的 **level** 属性(后续将用于布局),更新 DimensionTreetotalLevelsize属性

PivotHeaderLayoutMap

**layoutMap 是 PivotTable 的核心参数之一,**将直接决定单元格的布局、宽高等
```Typescript // packages/vtable/src/PivotTable.ts export class PivotTable extends BaseTable implements PivotTableAPI { constructor(...) { ...
    **this.internalProps.layoutMap = new PivotHeaderLayoutMap**(
        this,
        this.dataset,
        columnDimensionTree,
        rowDimensionTree
    );
}    

}



我们来看下 `PivotHeaderLayoutMap` 类做了哪些事情:    

1. **确定合并单元格、节点折叠状态的逻辑**。下面这个4个属性会决定 `cornerHear`, `columnHear`, `rowHeader`的展示内容、合并单元格逻辑    

```Typescript
// packages/vtable/src/layout/pivot-header-layout.ts
export class PivotHeaderLayoutMap implements LayoutMapAPI {
    /**下面四份代表实际展示的 如果隐藏了某部分表头 那这里就会相比上面的数组少了隐藏掉的id 例如收hideIndicatorName影响*/
    _cornerHeaderCellIds: number[][] = [];
    private _columnHeaderCellIds: number[][] = [];
    private _rowHeaderCellIds: number[][] = [];
    private _rowHeaderCellIds_FULL: number[][] = []; //分页需求新增  为了保存全量的id  当页的是_rowHeaderCellIds
    
    // 记录单元格 HeaderData 对象
    cornerHeaderObjs: HeaderData[];
    columnHeaderObjs: HeaderData[] = [];
    rowHeaderObjs: HeaderData[] = [];
    ...  
}    

  • rowHierarchyTypegrid的时候,_rowHeaderCellIds 这个二维数组分别指定单元格对应的唯一Id,Id相同则为合并单元格情况。如下左图中Id:23为合并情况,Id:27为不合并的情况
  • rowHierarchyTypetree的时候,所有的维度会归到同一列展示,_rowHeaderCellIds会如下图:
  • 并且node.hierarchyState 会记录节点的折叠状态
  • 可以看到具体生成行头、列头单元格数据的逻辑在this._addHeaders()this._addHeadersForGridTreeMode()this._addHeadersForTreeMode()
// packages/vtable/src/layout/pivot-header-layout.ts
export class PivotHeaderLayoutMap implements LayoutMapAPI {
    ...
    
    constructor(...) {
        
        // 生成列表头单元格
        this._generateColHeaderIds();
        // 生成行表头单元格
        this._generateRowHeaderIds();
    }
    
    _generateRowHeaderIds() {
        if (this.rowDimensionTree.tree.children?.length >= 1) {
            if (this.rowHierarchyType === 'tree') {
                **this._addHeadersForTreeMode(...)**
            } else if (this.rowHierarchyType === 'grid-tree') {
                const startRow = 0;
                **this._addHeadersForGridTreeMode(...)**
            } else {
                **this._addHeaders(...)**
            }
    }
}    

  • 三个 this._addHeadersXX()方法逻辑类似,都会和 dealHeaderXX() 方法组合成递归逻辑,遍历树,生成**HeaderData**类型的单元格数据,并做适当的存储
  • 生成cornerHeadr单元格数据;设置列宽
// packages/vtable/src/layout/pivot-header-layout.ts
export class PivotHeaderLayoutMap implements LayoutMapAPI {
    ...
    
    constructor(...) {
        
        this.cornerHeaderObjs = this._addCornerHeaders(
          colDimensionKeys,
          rowDimensionKeys,
          this.columnsDefine.concat(...this.rowsDefine, ...extensionRowDimensions)
        );
        
        ...
        
        this.setColumnWidths();
    }
}    

创建场景树 & 渲染

创建场景树,发布事件,完结撒花!

scenegraph.createSceneGraph() 实际属于**渲染引擎 **(packages/vtable/src/scenegraph/scenegraph.ts),不属于本章讨论范围,这里不做过多分析

// packages/vtable/src/PivotTable.ts
export class PivotTable extends BaseTable implements PivotTableAPI {
    constructor(...) {
        ...
        
        // 生成单元格场景树
        this.scenegraph.createSceneGraph();
        
        // 为了确保用户监听得到这个事件 这里做了异步 确保vtable实例已经初始化完成
        setTimeout(() => {
            this.fireListeners(TABLE_EVENT_TYPE.INITIALIZED, null);
        }, 0);
    }    
}    

流程总结

自定义表头

VTable 自定义表头章节 中,除了下面介绍了两种功能,还兼容了多种自定义维度树的 edge case,eg. 补全指标节点、自定义树不规则情况等。我们选取下面两种功能进行源码分析。

自定义表头维度树

需求

在某些业务场景中,业务方可能期望行列维度树是完全按照自己指定的方式展示,那么可以自行传入维度树 rowTreecolumnTree 的方式来实现

源码

  • Dataset

  • 可以看到如果用户传自定义行头树列头树,就直接赋值给 dataset.rowHeaderTree / colHeaderTree

export class Dataset {
    ...
    
    setRecords(records: any[] | Record<string, any[]>) {
        ...
        
        if (this.customRowTree) {
            this.rowHeaderTree = this.customRowTree;
        }
        if (this.customColTree) {
            this.colHeaderTree = this.customColTree;
        }
      }
    }
}    

  • DimensionTree

  • 可以看到如果用户传自定义行头树列头树,会直接用用户传的树去 new DimensionTree

  • 实际就不是用 dataset.rowHeaderTree / colHeaderTree 去生成 DimensionTree

// packages/vtable/src/PivotTable.ts
export class PivotTable extends BaseTable implements PivotTableAPI {
    constructor(...) {
        ...
        
        const keysResults = parseColKeyRowKeyForPivotTable(this, options);
        let { columnDimensionTree, rowDimensionTree } = keysResults;
    }    
}

// packages/vtable/src/layout/layout-helper.ts
export function parseColKeyRowKeyForPivotTable(table: PivotTable, options: PivotTableConstructorOptions) {
    
    if (options.columnTree) {
        columnDimensionTree = new DimensionTree(
            **(table.internalProps.columnTree as ITreeLayoutHeadNode[]) ?? [],**
            ...
        );
    }
    if (options.rowTree) {
        rowDimensionTree = new DimensionTree(
            **(table.internalProps.rowTree as ITreeLayoutHeadNode[]) ?? [],**
            ...
        );
    }
    
    return {
        ...
        columnDimensionTree,
        rowDimensionTree
    };
}    

  • 后面的流程就跟「树形展示」的流程一致了,即生成layoutMap、创建场景树

自定义表头跨列合并

需求

在自定义rowTreecolumnTree的节点配置中,有一个levelSpan字段,可以用来指定表头单元格合并的范围,默认为1。

  • case1:对“淘宝旗舰店”设置 levelSpan: 2
  • case2:对“淘宝”设置 levelSpan: 2

从上面两个case可以看出,设置了levelSpan的节点,会向下合并对应层级的单元格;其后代节点会正常渲染,但表头的总深度不变,超过深度的节点会被隐藏。业务方可以根据需要设置levelSpan,渲染灵活度更高的自定义表头树

源码

  • DimensionTree

如果传了 columnTree 并给某个节点设置了 levelSpan,会影响DimensionTree.setTreeNode的逻辑。

  • 可以看到 node.afterSpanLevel = node.afterSpanLevel + node.levelSpan

  • level: 节点所在真正的 level

  • **afterSpanLevel**: 计算节点跨占(+spanLevel)情况下的level

  • PivotHeaderLayoutMap

  • 会影响 this._columnHeaderCellIds 的生成。经过 this._addHeadersdealHeader 遍历列头树之后,最终如下图

典型交互的实现

展开 & 折叠维度树

交互效果

源码

我们以从折叠维度树节点HierarchyStateexpand -> collapse)为例展开分析

PivotTable.toggleHierarchyState

该方法为入口方法。可以看到:

  • 实际是调用 this._refreshHierarchyState()

  • 完成后对外发布PIVOT_TABLE_EVENT_TYPE.TREE_HIERARCHY_STATE_CHANGE事件

toggleHierarchyState(col: number, row: number, recalculateColWidths: boolean = true) {
    const hierarchyState = this.getHierarchyState(col, row);
    if (hierarchyState === HierarchyState.expand) {
        **this._refreshHierarchyState(col, row, recalculateColWidths);**
        **this.fireListeners(PIVOT_TABLE_EVENT_TYPE.TREE_HIERARCHY_STATE_CHANGE,** {
            col: col,
            row: row,
            hierarchyState: HierarchyState.collapse
        });
    } 
    ...
}    

PivotTable._refreshHierarchyState

可以看到核心逻辑是:

  • 调用 layoutMap.toggleHierarchyState()获取 增、删、改 的行的信息

  • 最后调用this.scenegraph.updateRow()触发场景树对行重新绘制

_refreshHierarchyState(col: number, row: number, recalculateColWidths: boolean = true, beforeUpdateCell?: Function) {

    ...
    // 更新hover图标
    this.stateManager.updateHoverIcon(col, row, undefined, undefined);
    
    const isChangeRowTree = this.internalProps.layoutMap.isRowHeader(col, row);
    // 获取 增、删、改 的行的信息
    const **result**: {
      addCellPositionsRowDirection?: CellAddress[];
      removeCellPositionsRowDirection?: CellAddress[];
      updateCellPositionsRowDirection?: CellAddress[];
      addCellPositionsColumnDirection?: CellAddress[];
      removeCellPositionsColumnDirection?: CellAddress[];
      updateCellPositionsColumnDirection?: CellAddress[];
    } = isChangeRowTree
      ? **(this.internalProps.layoutMap as PivotHeaderLayoutMap).toggleHierarchyState(col, row)**
      : (this.internalProps.layoutMap as PivotHeaderLayoutMap).toggleHierarchyStateForColumnTree(col, row);
      
    // 更新折叠图标
    this.scenegraph.updateHierarchyIcon(col, row);
    
    // 触发场景树更新行绘制
    **this.scenegraph.updateRow**(
        result.removeCellPositionsRowDirection,
        result.addCellPositionsRowDirection,
        result.updateCellPositionsRowDirection,
        recalculateColWidths
     );
}    

PivotHeaderLayoutMap.toggleHierarchyState

可以看到这个函数的逻辑跟 PivotHeaderLayoutMap 构造函数的逻辑有点类似

  • 重置 rowDimensionTree

  • _addHeadersForTreeMode** **遍历树,重新收集_rowHeaderCellFullPathIds

  • diffCellAddress收集增、删、改 行的信息,并最后 return

export class PivotHeaderLayoutMap implements LayoutMapAPI {
     // 点击某个单元格的展开折叠按钮 改变该节点的状态 维度树重置
     toggleHierarchyState(col: number, row: number) {
     
         this.rowDimensionTree.reset(this.rowDimensionTree.tree.children);
         this._rowHeaderCellFullPathIds_FULL = [];
         if (this.rowHierarchyType === 'tree') {
             // 递归树重新生成
             **this._addHeadersForTreeMode**(
                this._rowHeaderCellFullPathIds_FULL,
                0,
                this.rowDimensionTree.tree.children,
                [],
                this.rowDimensionTree.totalLevel,
                true,
                this.rowsDefine,
                this.rowHeaderObjs
             );
         }
         
         ...
         this._rowHeaderCellFullPathIds_FULL = transpose(this._rowHeaderCellFullPathIds_FULL);
         
         let diffCell: {
             addCellPositionsRowDirection?: CellAddress[];
             removeCellPositionsRowDirection?: CellAddress[];
             updateCellPositionsRowDirection?: CellAddress[];
             addCellPositionsColumnDirection?: CellAddress[];
             removeCellPositionsColumnDirection?: CellAddress[];
             updateCellPositionsColumnDirection?: CellAddress[];
         };
         if (this.rowHierarchyType === 'tree') {
             diffCell = diffCellAddress(
               col,
               row,
               oldRowHeaderCellIds.map(oldCellId => oldCellId[col - this.leftRowSeriesNumberColumnCount]),
               this._rowHeaderCellFullPathIds_FULL.map(newCellId => newCellId[col - this.leftRowSeriesNumberColumnCount]),
               oldRowHeaderCellPositons,
               this
             );
         }
         
         ...
         this.generateCellIdsConsiderHideHeader();
         
         ...
         return diffCell;
     }
}    

Scenegraph.updateRow

大概逻辑落如下:

  • updateRow()方法,对行做增、删

  • this.recalculateColWidths()重新计算列宽

  • this.component.updateScrollBar()更新滚动条

  • 最后调this.updateNextFrame()重新渲染

流程总结

  • 核心逻辑在 PivotHeaderLayoutMap.toggleHierarchyState中:会重新递归树生成新的表头树单元格信息(_columnHeaderCellIds_rowHeaderCellIds);并收集增、删、改 行的信息

  • 最后由生成的变更信息重新渲染表格

本文档由以下人员修正整理

玄魂