简介
VTable 将交互效果的实现拆到了三个模块中去处理,分别是:
-
状态模块
stateManager:状态模块负责维持表格当前各种交互的状态,状态的改变会导致场景树的重新渲染; -
事件模块为
eventManager:事件模块负责监听事件,并根据不同的事件来改变状态; -
场景树
scenegraph:场景树负责重新渲染表格,为实现交互的最后一步;

接下来将从六个常用的交互来看下事件到状态的更新流程。
交互实现
单元格 select
核心状态
在状态模块中,决定单元格选中的核心状态值为 select.ranges,VTable 通过该字段来判断当前单元格是否选中,改变 select.ranges 即可实现单元格选中状态的改变。
// packages\vtable\src\state\state.ts select: { ranges: (CellRange & { skipBodyMerge?: boolean })[]; //... }
我们来看下单元格选中是如何通过事件去影响状态的。
select 包含三种交互,分别是多选,拖拽多选和清空选择,三者所监听的事件各不相同。
单选
- pointerdown 单选单元格

在处理完单元格选中事件之后,更新 interactionState
// packages\vtable\src\event\listener\table-group.ts stateManager.updateInteractionState(InteractionState.grabing);
至于是否更新当前单元格选中状态的逻辑,则位于状态模块 stateManager.updateSelectPos中。
拖拽多选
- pointermove 多选单元格

清空选中
- 事件模块接收 pointertap 事件,点击空白区域取消选中,结束 select 交互。
// packages\vtable\src\event\listener\table-group.ts table.scenegraph.stage.addEventListener('pointertap', (e: FederatedPointerEvent) => { // ... if (table.options.select?.blankAreaClickDeselect ?? true) { eventManager.dealTableSelect(); } // ... }
状态更新
在状态模块关于 selct 单元格的流程中,单选单元格和框选单元格核心区别点在于 stateManger.interactionState 的不同:
-
stateManager.interactionState === 'grabing' 表示当前正在框选单元格的过程
-
stateManager.interactionState === 'default' 表示单选单元格
状态管理中关于选择状态的更新流程如下:
- updateSelectPos

滚动条滚动
滚动效果主要是监听的 wheel 事件,通过 wheel 事件改变当前的滚动条的状态,更新 scrollTop 和 scrollLeft ,调整表格的 x,y 坐标,实现滚动效果。
核心状态
// packages\vtable\src\state\state.ts scroll: { horizontalBarPos: number; verticalBarPos: number; };
更新流程

hover 单元格
核心状态
// packages\vtable\src\state\state.ts hover: { cellPos: CellPosition; *// 记录当前hover的位置* };
VTable 内部通过 hover.cellPos 判断当前单元格是否处于 hover 状态,从而实现 hover 单元格的功能。
处理流程
单元格 hover 效果是通过监听 pointermove 事件来完成的。
- 首先由事件模块处理 pointermove 事件
// packages\vtable\src\event\listener\table-group.ts table.scenegraph.tableGroup.addEventListener('pointermove', (e: FederatedPointerEvent) => { // ... const eventArgsSet = getCellEventArgsSet(e); eventManager.dealTableHover(eventArgsSet); // ... })
- 事件模块
eventManager.dealTableHover处理 hover 效果,通过 eventArgs 判断是清空还是更新 hover 状态。
// packages\vtable\src\event\event.ts dealTableHover(eventArgsSet?: SceneEvent) { if (!eventArgsSet) { this.table.stateManager.updateHoverPos(-1, -1); return; } const { eventArgs } = eventArgsSet; if (eventArgs) { this.table.stateManager.updateHoverPos(eventArgs.col, eventArgs.row); } else { this.table.stateManager.updateHoverPos(-1, -1); } }
- 状态模块更新 hover 位置
stateManager.updateHoverPos
- 整体流程图

行高列宽调整
核心状态
// packages\vtable\src\state\state.ts columnResize: { col: number; */** x坐标是相对table内坐标 */* x: number; resizing: boolean; }; rowResize: { row: number; */** y坐标是相对table内坐标 */* y: number; resizing: boolean; };
该状态中记录了当前拖拽行列的索引以及坐标,后续在实际的拖拽中,仅会去调整 columnResize.col 或 rowResize.row 对应的行或列。
调整流程
- 接收 pointerdown 事件,由事件模块检查是否进入拖拽调整列宽,如果确认进入调整行高列宽,则更新
state.interactionState为grabing;
// packages\vtable\src\event\listener\table-group.ts table.scenegraph.tableGroup.addEventListener('pointerdown', (e: FederatedPointerEvent) => { // ... *// 处理列宽调整* if ( !eventManager.checkCellFillhandle(eventArgsSet) && (eventManager.checkColumnResize(eventArgsSet, true) || eventManager.checkRowResize(eventArgsSet, true)) ) { table.scenegraph.updateChartState(null); stateManager.updateInteractionState(InteractionState.grabing); return; } // ... }
-
首 先根据
pointerdown提供的点击坐标,计算是否命中拖拽热区,如果命中的话,返回对应的行列索引。 -
拖拽列宽检查
// packages\vtable\src\event\event.ts checkColumnResize(eventArgsSet: SceneEvent, update?: boolean): boolean { const { eventArgs } = eventArgsSet; // ... *// 如果是鼠标处理表格外部如最后一列的后面 也期望可以拖拽列宽* // 获取当前点击的单元格行列号 const resizeCol = this.table.scenegraph.getResizeColAt( eventArgsSet.abstractPos.x, eventArgsSet.abstractPos.y, eventArgs?.targetCell ); if (this.table._canResizeColumn(resizeCol.col, resizeCol.row) && resizeCol.col >= 0) { if (update) { this.table.stateManager.startResizeCol( resizeCol.col, eventArgsSet.abstractPos.x, eventArgsSet.abstractPos.y, resizeCol.rightFrozen ); } return true; } // ... }
- 拖拽行高检查
// packages\vtable\src\event\event.ts checkRowResize(eventArgsSet: SceneEvent, update?: boolean): boolean { // ... const { eventArgs } = eventArgsSet; if (eventArgs) { const resizeRow = this.table.scenegraph.getResizeRowAt( eventArgsSet.abstractPos.x, eventArgsSet.abstractPos.y, eventArgs.targetCell ); if (this.table._canResizeRow(resizeRow.col, resizeRow.row) && resizeRow.row >= 0) { if (update) { this.table.stateManager.startResizeRow( resizeRow.row, eventArgsSet.abstractPos.x, eventArgsSet.abstractPos.y, resizeRow.bottomFrozen ); } return true; } } }
- 根据行列索引,通过状态模块初始化
columnResize和rowResize的状态,触发下一帧渲染;
// packages\vtable\src\state\state.ts startResizeCol(col: number, x: number, y: number, isRightFrozen?: boolean) { this.columnResize.resizing = true; this.columnResize.col = col; this.columnResize.x = x; this.columnResize.isRightFrozen = isRightFrozen; this.table.scenegraph.component.showResizeCol(col, y, isRightFrozen); this.table.scenegraph.updateNextFrame(); }
-
处理
pointermove事件,由状态模块判断当前是拖拽行还是列; -
如果 interactionState === 'grabing' 代表当前处于拖拽行高列宽的交互中;
-
由
columnResize.resizing和rowResize.resizing判断当前是拖拽行高还是列宽; -
通过事件模块做中转,处理拖拽事件
eventManager.dealColumnResize(x, y); -
触发
RESIEZE_COLUMN和RESIZE_ROW事件;
const globalPointermoveCallback = (e: MouseEvent) => { // ... const { x, y } = table._getMouseAbstractPoint(e, false); if (stateManager.interactionState === InteractionState.grabing) { if (stateManager.isResizeCol()) { eventManager.dealColumnResize(x, y); if ((table as any).hasListeners(TABLE_EVENT_TYPE.RESIZE_COLUMN)) { table.fireListeners(TABLE_EVENT_TYPE.RESIZE_COLUMN, { col: table.stateManager.columnResize.col, colWidth: table.getColWidth(table.stateManager.columnResize.col) }); } } else if (stateManager.isResizeRow()) { eventManager.dealRowResize(x, y); if ((table as any).hasListeners(TABLE_EVENT_TYPE.RESIZE_ROW)) { table.fireListeners(TABLE_EVENT_TYPE.RESIZE_ROW, { row: table.stateManager.rowResize.row, rowHeight: table.getRowHeight(table.stateManager.rowResize.row) }); } } } // ... } document.body.addEventListener('pointermove', globalPointermoveCallback);
- 通过状态模块处理
pointermove事件,通过当前指针的坐标,更新columnResize.col和rowResize.row对应索引处的列宽/行高。
// packages\vtable\src\event\event.ts dealColumnResize(xInTable: number, yInTable: number) { this.table.stateManager.updateResizeCol(xInTable, yInTable); } dealRowResize(xInTable: number, yInTable: number) { this.table.stateManager.updateResizeRow(xInTable, yInTable); }
- 处理
pointerup事件,将state.interactionState还原为default;
// packages\vtable\src\event\listener\table-group.ts table.scenegraph.stage.addEventListener('pointerup', (e: FederatedPointerEvent) => { *// 处理列宽调整 这里和tableGroup.addEventListener('pointerup' 逻辑一样* if (stateManager.interactionState === 'grabing') { stateManager.updateInteractionState(InteractionState.default); if (stateManager.isResizeCol()) { endResizeCol(table); } else if (stateManager.isResizeRow()) { endResizeRow(table); } } });
- 交由状态模块去重置
stateManager.columnResize和stateManager.rowResize,随后触发RESIZE_COLUMN_END或RESIZE_ROW_END事件
// packages\vtable\src\event\listener\table-group.ts export function endResizeCol(table: BaseTableAPI) { table.stateManager.endResizeCol(); const columns = []; *// 返回所有列宽信息* for (let col = 0; col < table.colCount; col++) { columns.push(table.getColWidth(col)); } table.fireListeners(TABLE_EVENT_TYPE.RESIZE_COLUMN_END, { col: table.stateManager.columnResize.col, colWidths: columns }); } export function endResizeRow(table: BaseTableAPI) { table.stateManager.endResizeRow(); table.fireListeners(TABLE_EVENT_TYPE.RESIZE_ROW_END, { row: table.stateManager.rowResize.row, rowHeight: table.getRowHeight(table.stateManager.rowResize.row) });
- 重置
columnResize.resizing和rowResize.resizing为 false,隐藏拖拽基准线,进入下一帧渲染。
// packages\vtable\src\state\state.ts endResizeCol() { setTimeout(() => { this.columnResize.resizing = false; }, 0); // ... this.table.scenegraph.component.hideResizeCol(); this.table.scenegraph.updateNextFrame(); } endResizeRow() { setTimeout(() => { this.rowResize.resizing = false; }, 0); // ... this.table.scenegraph.component.hideResizeRow(); this.table.scenegraph.updateNextFrame(); }
- 流程图

拖拽换行换列
核心状态
// packages\vtable\src\state\state.ts columnMove: { colSource: number; colTarget: number; rowSource: number; rowTarget: number; x: number; y: number; moving: boolean; };
columnRemove 中存储了拖拽行列的原始索引以及目标索引,还有是否处于移动中的标识,通过改变 colTarget 和 rowTarget 即可实现将选中的行/列替换到目标位置的功能。
处理流程
拖拽换行换列也是依靠了三个事件来完成的:pointerdown、pointermove 、pointerup
- 流程图

固定列
VTable 提供了内置的冻结列操作,可以通过配置 allowFrozenColCount 开启。

核心状态
VTable 通过tableInstance.internalProps.frozenColCount状态维护了当前实际冻结的列数,内部会根据该字段调整左侧冻结列数,采取特殊样式。
处理流程
冻结列的操作主要由 pointertap 和 自定义事件 ICON_CLICK 协同实现。
- 首先处理
pointertap事件;
// packages\vtable\src\event\listener\table-group.ts table.scenegraph.tableGroup.addEventListener('pointertap', (e: FederatedPointerEvent) => { // ... if ( !eventManager.touchMove && e.button === 0 && eventArgsSet.eventArgs && (table as any).hasListeners(TABLE_EVENT_TYPE.CLICK_CELL) ) { // ... eventManager.dealIconClick(e, eventArgsSet); });
- 事件模块中通 过 eventArgsSet 判断是否点击中图标图元,如果点击的为图标图元,则触发自定义事件
ICON_CLICK;
// packages\vtable\src\event\event.ts dealIconClick(e: FederatedPointerEvent, eventArgsSet: SceneEvent): boolean { const { eventArgs } = eventArgsSet; const { target, event, col, row } = eventArgs || { target: e.target, event: e, col: -1, row: -1 }; const icon = target as unknown as Icon; if (icon.role && icon.role.startsWith('icon-')) { this.table.fireListeners(TABLE_EVENT_TYPE.ICON_CLICK, { name: icon.name, *// 默认位置:icon中部正下方* x: (icon.globalAABBBounds.x1 + icon.globalAABBBounds.x2) / 2, y: icon.globalAABBBounds.y2, col, row, funcType: icon.attribute.funcType, icon, event }); }
ICON_CLICK事件早在事件模块初始化时就已注册,ICON_CLICK事件中会判断当前点击图标类型是否为 frozen;
// packages\vtable\src\event\event.ts *// 图标点击* this.table.on(TABLE_EVENT_TYPE.ICON_CLICK, iconInfo => { const { col, row, x, y, funcType, icon, event } = iconInfo; // ... if (funcType === IconFuncTypeEnum.frozen) { stateManager.triggerFreeze(col, row, icon); } // ... });
- 状态模块处理点击
fronzen事件,根据当前点击的列索引,更新this.internalProps.frozenColCount,如果当前点击的列跟状态中维持的frozenColCount相同,则重置frozenColCount为 0,如果不同则更新frozenColCount为 col;
// packages\vtable\src\state\frozen\index.ts export function dealFreeze(col: number, row: number, table: BaseTableAPI) { if (table.frozenColCount > 0) { if (col !== table.frozenColCount - 1) { table.setFrozenColCount(col + 1); } else { table.setFrozenColCount(0); } } else { table.setFrozenColCount(col + 1); } }
- 触发
FREEZE_CLICK事件
triggerFreeze(col: number, row: number, iconMark: Icon) { // ... if ((this.table as any).hasListeners(PIVOT_TABLE_EVENT_TYPE.FREEZE_CLICK)) { const fields: ColumnData[] = (this.table as ListTable).internalProps.layoutMap.columnObjects.slice(0, col + 1); this.table.fireListeners(PIVOT_TABLE_EVENT_TYPE.FREEZE_CLICK, { col: col, row: row, fields: fields.reduce((pre: any, cur: any) => pre.concat(cur.field), []), colCount: this.table.frozenColCount }); } // ... }
结语
本文从常用的六种交互效果出发,详细讲解了从事件到状态的更新流程。
VTable 将交互效果拆为事件模块和状态模块,使得处理交互事件时,在流程处理方面能够更加清晰。
本文档由以下人员提供
taiiiyang( https://github.com/taiiiyang)