Skip to content

Commit 29785a0

Browse files
authored
Merge pull request #5100 from Erica-cod/fix/vue-dom-flicker-resize-5038
fix(vue-vtable): resolve DOM component flickering during column resize (#5038)
2 parents addbdd0 + 853c1a5 commit 29785a0

File tree

6 files changed

+288
-4
lines changed

6 files changed

+288
-4
lines changed
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/* eslint-env jest */
2+
/* eslint-disable no-undef */
3+
// @ts-nocheck
4+
/**
5+
* 测试 Vue DOM 组件稳定 ID 注入
6+
* 对应 issue: https://github.com/VisActor/VTable/issues/5038
7+
*
8+
* 验证 createCustomLayout 在处理带 vue 属性的 Group 时:
9+
* 1. 自动注入 cell_col_row_index 格式的稳定 ID
10+
* 2. 多次调用(模拟场景图重建)产生相同 ID
11+
* 3. 用户自定义 ID 不被覆盖
12+
* 4. 同一 cell 内多个 vue Group 的 ID 唯一
13+
*/
14+
15+
import { createCustomLayout } from '../src/utils/customLayoutUtils';
16+
17+
// Mock VTable CustomLayout 组件
18+
jest.mock('@visactor/vtable', () => {
19+
class MockGroup {
20+
attribute: any;
21+
children: any[] = [];
22+
constructor(attrs: any) {
23+
this.attribute = attrs;
24+
}
25+
add(child: any) {
26+
this.children.push(child);
27+
}
28+
addEventListener() {}
29+
}
30+
return {
31+
CustomLayout: {
32+
Group: MockGroup,
33+
Image: MockGroup,
34+
Text: MockGroup,
35+
Tag: MockGroup,
36+
Radio: MockGroup,
37+
CheckBox: MockGroup
38+
}
39+
};
40+
});
41+
42+
// Mock vue 的 isVNode / cloneVNode
43+
jest.mock('vue', () => ({
44+
isVNode: (val: any) => val && val.__v_isVNode === true,
45+
cloneVNode: (vnode: any, extra: any) => ({ ...vnode, ...extra })
46+
}));
47+
48+
function makeGroupChild(vueProps: any = {}, typeName = 'Group') {
49+
return {
50+
type: { name: typeName },
51+
props: {
52+
width: 200,
53+
height: 40,
54+
vue: { ...vueProps }
55+
},
56+
children: null
57+
};
58+
}
59+
60+
function makeArgs(col: number, row: number) {
61+
return {
62+
col,
63+
row,
64+
table: {
65+
headerDomContainer: document.createElement('div'),
66+
bodyDomContainer: document.createElement('div')
67+
}
68+
};
69+
}
70+
71+
describe('Vue 稳定 ID 注入', () => {
72+
test('应为 vue Group 注入 cell_col_row_index 格式的稳定 ID', () => {
73+
const child = makeGroupChild({});
74+
const args = makeArgs(3, 5);
75+
76+
createCustomLayout(child, false, args);
77+
78+
expect(child.props.vue.id).toBe('cell_3_5_0');
79+
});
80+
81+
test('多次调用应产生相同的 ID(模拟场景图重建)', () => {
82+
const args = makeArgs(2, 7);
83+
84+
const child1 = makeGroupChild({});
85+
createCustomLayout(child1, false, args);
86+
87+
const child2 = makeGroupChild({});
88+
createCustomLayout(child2, false, args);
89+
90+
expect(child1.props.vue.id).toBe(child2.props.vue.id);
91+
expect(child1.props.vue.id).toBe('cell_2_7_0');
92+
});
93+
94+
test('用户指定的 id 不应被覆盖', () => {
95+
const child = makeGroupChild({ id: 'my-custom-id' });
96+
const args = makeArgs(1, 1);
97+
98+
createCustomLayout(child, false, args);
99+
100+
expect(child.props.vue.id).toBe('my-custom-id');
101+
});
102+
103+
test('同一 cell 内多个 vue Group 应有不同的稳定 ID', () => {
104+
// 构造一个包含两个 vue Group 子节点的父 Group
105+
const vueChild1 = makeGroupChild({});
106+
const vueChild2 = makeGroupChild({});
107+
108+
const parentChild = {
109+
type: { name: 'Group' },
110+
props: { width: 400, height: 80 },
111+
children: {
112+
default: () => [vueChild1, vueChild2]
113+
}
114+
};
115+
116+
const args = makeArgs(4, 10);
117+
createCustomLayout(parentChild, false, args);
118+
119+
// 两个子节点都应被注入稳定 ID
120+
expect(vueChild1.props.vue.id).toBe('cell_4_10_0');
121+
expect(vueChild2.props.vue.id).toBe('cell_4_10_1');
122+
});
123+
124+
test('isHeader=true 时应使用 headerDomContainer', () => {
125+
const args = makeArgs(0, 0);
126+
const child = makeGroupChild({});
127+
128+
createCustomLayout(child, true, args);
129+
130+
expect(child.props.vue.container).toBe(args.table.headerDomContainer);
131+
expect(child.props.vue.id).toBe('cell_0_0_0');
132+
});
133+
134+
test('isHeader=false 时应使用 bodyDomContainer', () => {
135+
const args = makeArgs(1, 3);
136+
const child = makeGroupChild({});
137+
138+
createCustomLayout(child, false, args);
139+
140+
expect(child.props.vue.container).toBe(args.table.bodyDomContainer);
141+
});
142+
});
143+
144+
describe('VTableVueAttributePlugin 缓存命中同步渲染', () => {
145+
test('renderGraphicHTML 缓存命中时应同步调用 doRenderGraphic', () => {
146+
// 直接引入插件类进行行为验证
147+
const { VTableVueAttributePlugin } = require('../src/components/custom/vtable-vue-attribute-plugin');
148+
149+
const plugin = new VTableVueAttributePlugin();
150+
plugin.htmlMap = {};
151+
plugin.renderId = 1;
152+
153+
const mockWrapContainer = document.createElement('div');
154+
document.body.appendChild(mockWrapContainer);
155+
156+
const stableId = 'vue_cell_3_5_0';
157+
158+
// 预填充缓存条目
159+
plugin.htmlMap[stableId] = {
160+
wrapContainer: mockWrapContainer,
161+
nativeContainer: document.body,
162+
container: document.body,
163+
renderId: 0,
164+
graphic: null,
165+
isInViewport: true,
166+
lastPosition: null,
167+
lastStyle: {}
168+
};
169+
170+
// 追踪 doRenderGraphic 是否被同步调用
171+
let doRenderCalled = false;
172+
const originalDoRender = plugin.doRenderGraphic;
173+
plugin.doRenderGraphic = function (graphic: any) {
174+
doRenderCalled = true;
175+
};
176+
177+
const mockGraphic = {
178+
attribute: {
179+
vue: {
180+
id: 'cell_3_5_0',
181+
element: { __v_isVNode: true },
182+
container: document.body
183+
}
184+
},
185+
stage: {
186+
window: { getContainer: () => document.body },
187+
AABBBounds: { x1: 0, x2: 100, y1: 0, y2: 100 }
188+
},
189+
globalAABBBounds: { x1: 10, x2: 50, y1: 10, y2: 50 },
190+
id: null,
191+
_uid: 999
192+
};
193+
194+
plugin.renderGraphicHTML(mockGraphic);
195+
196+
// 缓存命中 → 同步调用 doRenderGraphic,不走 rAF
197+
expect(doRenderCalled).toBe(true);
198+
expect(plugin.renderQueue.size).toBe(0);
199+
200+
document.body.removeChild(mockWrapContainer);
201+
});
202+
203+
test('renderGraphicHTML 无缓存时应走异步队列', () => {
204+
const { VTableVueAttributePlugin } = require('../src/components/custom/vtable-vue-attribute-plugin');
205+
206+
const plugin = new VTableVueAttributePlugin();
207+
plugin.htmlMap = {};
208+
plugin.renderId = 1;
209+
210+
// mock scheduleRender 避免 vglobal 在 jsdom 中不可用
211+
const originalSchedule = plugin.scheduleRender;
212+
plugin.scheduleRender = jest.fn();
213+
214+
const mockGraphic = {
215+
attribute: {
216+
vue: {
217+
id: 'cell_0_0_0',
218+
element: { __v_isVNode: true },
219+
container: document.body
220+
}
221+
},
222+
stage: {
223+
window: { getContainer: () => document.body },
224+
AABBBounds: { x1: 0, x2: 100, y1: 0, y2: 100 }
225+
},
226+
globalAABBBounds: { x1: 10, x2: 50, y1: 10, y2: 50 },
227+
id: null,
228+
_uid: 123
229+
};
230+
231+
plugin.renderGraphicHTML(mockGraphic);
232+
233+
// 无缓存 → 加入异步队列并调用 scheduleRender
234+
expect(plugin.renderQueue.size).toBe(1);
235+
expect(plugin.scheduleRender).toHaveBeenCalled();
236+
});
237+
});

packages/vue-vtable/jest.config.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const path = require('path');
2+
3+
module.exports = {
4+
preset: 'ts-jest',
5+
testEnvironment: 'jsdom',
6+
testRegex: '/__tests__(/.*)+\\.test\\.(js|ts)$',
7+
silent: false,
8+
verbose: true,
9+
globals: {
10+
'ts-jest': {
11+
diagnostics: {
12+
exclude: ['**']
13+
},
14+
tsconfig: {
15+
resolveJsonModule: true,
16+
esModuleInterop: true
17+
}
18+
},
19+
__DEV__: true
20+
},
21+
moduleNameMapper: {
22+
'@visactor/vtable$': '<rootDir>/../vtable/src/index',
23+
'@visactor/vtable/es/(.*)': '<rootDir>/../vtable/src/$1'
24+
},
25+
setupFiles: ['./setup-mock.js']
26+
};

packages/vue-vtable/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"start": "vite ./demo",
4747
"build": "npm run fix-memory-limit && cross-env USE_TYPESCRIPT2=true bundle --clean",
4848
"compile": "tsc --noEmit",
49+
"test": "jest --config jest.config.js",
4950
"eslint": "eslint --debug --fix src/",
5051
"fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit"
5152
},

packages/vue-vtable/src/components/custom/vtable-vue-attribute-plugin.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,18 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
8585
if (!this.checkNeedRender(graphic)) {
8686
return;
8787
}
88-
// 加入异步渲染队列
88+
89+
// 缓存命中时同步渲染:立即更新 renderId,防止 clearCacheContainer 误清
90+
const opts = this.getGraphicOptions(graphic);
91+
if (opts) {
92+
const targetMap = this.htmlMap?.[opts.id];
93+
if (targetMap && this.checkDom(targetMap.wrapContainer)) {
94+
this.doRenderGraphic(graphic);
95+
return;
96+
}
97+
}
98+
99+
// 无缓存或 DOM 不在文档中,走异步渲染队列
89100
this.renderQueue.add(graphic);
90101
this.scheduleRender();
91102
}
@@ -168,6 +179,7 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
168179
// 更新样式并记录渲染 ID
169180
if (targetMap) {
170181
targetMap.renderId = this.renderId;
182+
targetMap.graphic = graphic;
171183
targetMap.lastAccessed = Date.now();
172184
this.updateAccessQueue(id);
173185
this.updateStyleOfWrapContainer(graphic, stage, targetMap.wrapContainer, targetMap.nativeContainer);

packages/vue-vtable/src/utils/customLayoutUtils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as VTable from '@visactor/vtable';
22
import { convertPropsToCamelCase, toCamelCase } from './stringUtils';
3-
import { isFunction, isObject } from '@visactor/vutils';
3+
import { isFunction, isNil, isObject } from '@visactor/vutils';
44
import { cloneVNode, isVNode } from 'vue';
55

66
// 检查属性是否为事件
@@ -10,6 +10,9 @@ function isEventProp(key: string, props: any) {
1010

1111
// 创建自定义布局
1212
export function createCustomLayout(children: any, isHeader?: boolean, args?: any): any {
13+
// 同一 cell 内多个 vue Group 的计数器,每次 createCustomLayout 调用时归零
14+
let vueIndex = 0;
15+
1316
// 组件映射
1417
const componentMap: Record<string, any> = {
1518
Group: VTable.CustomLayout.Group,
@@ -56,10 +59,14 @@ export function createCustomLayout(children: any, isHeader?: boolean, args?: any
5659
targetVNode = null;
5760
}
5861

62+
// 注入稳定 ID:基于 cell 坐标 + 递增索引,确保场景图重建后缓存 key 不变
63+
const stableId = isNil((props.vue as any).id) ? `cell_${args.col}_${args.row}_${vueIndex++}` : undefined;
64+
5965
Object.assign(child.props.vue, {
6066
element: targetVNode,
6167
// 不接入外部指定
62-
container: isHeader ? args?.table?.headerDomContainer : args?.table?.bodyDomContainer
68+
container: isHeader ? args?.table?.headerDomContainer : args?.table?.bodyDomContainer,
69+
...(stableId ? { id: stableId } : {})
6370
});
6471
return component;
6572
}

packages/vue-vtable/tscofig.eslint.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"include": [
1717
"src",
18-
"demo"
18+
"demo",
19+
"__tests__"
1920
]
2021
}

0 commit comments

Comments
 (0)