简介
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)