Skip to content

Commit b477541

Browse files
author
李杨枚
committed
perf: vue custom rendering: control memory usage, increase node reuse, optimize style updates
1 parent 1a5d690 commit b477541

1 file changed

Lines changed: 201 additions & 37 deletions

File tree

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

Lines changed: 201 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @Author: lym
33
* @Date: 2025-02-24 09:32:53
44
* @LastEditors: lym
5-
* @LastEditTime: 2025-03-21 19:38:26
5+
* @LastEditTime: 2025-04-01 17:33:53
66
* @Description:
77
*/
88
import type {
@@ -23,8 +23,7 @@ import {
2323
isNil,
2424
isObject,
2525
isString,
26-
styleStringToObject,
27-
uniqArray
26+
styleStringToObject
2827
} from '@visactor/vutils';
2928
import type { VNode } from 'vue';
3029
import { render } from 'vue';
@@ -53,12 +52,28 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
5352
lastPosition?: { x: number; y: number } | null;
5453
/** 上次样式 */
5554
lastStyle?: Record<string, any>;
55+
// 最后访问的时间戳
56+
lastAccessed?: number;
5657
}
5758
>;
5859
/** 渲染队列 */
5960
private renderQueue = new Set<IGraphic>();
6061
/** 是否正在渲染 */
6162
private isRendering = false;
63+
/** 最大缓存节点数(兜底值) */
64+
private MAX_CACHE_COUNT = 100;
65+
/** 记录节点访问顺序(LRU用) */
66+
private accessQueue: string[] = [];
67+
/** 目标可视区区(可视区外一定范围内的节点,保留) */
68+
private VIEWPORT_BUFFER = 100;
69+
/** 缓冲区(非可视区且非缓冲区的节点需清理) */
70+
private BUFFER_ZONE = 500;
71+
// 新增批量更新队列
72+
private styleUpdateQueue = new Map<string, Partial<CSSStyleDeclaration>>();
73+
/** 样式更新中 */
74+
private styleUpdateRequested = false;
75+
/** 事件集 */
76+
private eventHandlers = new WeakMap<HTMLElement, (e: WheelEvent) => void>();
6277
/** 当前上下文 */
6378
currentContext?: any;
6479

@@ -129,12 +144,19 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
129144
this.checkToPassAppContext(element, graphic);
130145
// 渲染或更新 Vue 组件
131146
if (!targetMap || !this.checkDom(targetMap.wrapContainer)) {
132-
const { wrapContainer, nativeContainer } = this.getWrapContainer(stage, actualContainer, { id, options });
147+
// 缓存节点检查
148+
this.checkAndClearCache(graphic);
149+
const { wrapContainer, nativeContainer, reuse } = this.getWrapContainer(stage, actualContainer, { id, options });
133150
if (wrapContainer) {
134151
const dataRenderId = `${this.renderId}`;
135152
wrapContainer.id = id;
136153
wrapContainer.setAttribute('data-vue-renderId', dataRenderId);
137-
render(element, wrapContainer);
154+
// 先隐藏
155+
wrapContainer.style.display = 'none';
156+
if (!reuse) {
157+
// 仅在非复用时需要重新渲染
158+
render(element, wrapContainer);
159+
}
138160
targetMap = {
139161
wrapContainer,
140162
nativeContainer,
@@ -147,14 +169,14 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
147169
};
148170
this.htmlMap[id] = targetMap;
149171
}
150-
} else {
151-
render(element, targetMap.wrapContainer);
152172
}
153173

154174
// 更新样式并记录渲染 ID
155175
if (targetMap) {
156-
this.updateStyleOfWrapContainer(graphic, stage, targetMap.wrapContainer, targetMap.nativeContainer);
157176
targetMap.renderId = this.renderId;
177+
targetMap.lastAccessed = Date.now();
178+
this.updateAccessQueue(id);
179+
this.updateStyleOfWrapContainer(graphic, stage, targetMap.wrapContainer, targetMap.nativeContainer);
158180
}
159181
}
160182

@@ -179,23 +201,31 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
179201
* @param {IGraphic} graphic
180202
* @return {*}
181203
*/
182-
checkToPassAppContext(vnode: VNode, graphic: IGraphic) {
204+
private checkToPassAppContext(vnode: VNode, graphic: IGraphic) {
183205
try {
184-
const { stage } = getTargetGroup(graphic);
185-
const { table } = stage || {};
186-
const userAppContext = table?.options?.customConfig?.getVueUserAppContext?.() ?? this.currentContext;
206+
const customConfig = this.getCustomConfig(graphic);
207+
const userAppContext = customConfig?.getVueUserAppContext?.() ?? this.currentContext;
187208
// 简单校验合法性
188209
if (!!userAppContext?.components && !!userAppContext?.directives) {
189210
vnode.appContext = userAppContext;
190211
}
191212
} catch (error) {}
192213
}
214+
/**
215+
* @description: 获取自定义配置
216+
* @param {IGraphic} graphic
217+
* @return {*}
218+
*/
219+
private getCustomConfig(graphic: IGraphic) {
220+
const target = getTargetGroup(graphic);
221+
return target?.stage?.table?.options?.customConfig;
222+
}
193223
/**
194224
* @description: 检查是否需要渲染
195225
* @param {IGraphic} graphic
196226
* @return {*}
197227
*/
198-
checkNeedRender(graphic: IGraphic) {
228+
private checkNeedRender(graphic: IGraphic) {
199229
const { id, options } = this.getGraphicOptions(graphic) || {};
200230
if (!id) {
201231
return false;
@@ -215,40 +245,133 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
215245
// 不在可视区内暂时不需要移除,因为在 clearCacheContainer 方法中提前被移除了
216246
return isInViewport;
217247
}
248+
249+
/**
250+
* @description: 判断是否在可视范围内
251+
* @param {IGraphic} graphic
252+
* @return {*}
253+
*/
254+
private checkInViewport(graphic: IGraphic) {
255+
return this.checkInViewportByZone(graphic, this.VIEWPORT_BUFFER);
256+
}
257+
/**
258+
* @description: 判断是否在缓冲区内
259+
* @param {IGraphic} graphic
260+
* @return {*}
261+
*/
262+
private checkInBuffer(graphic: IGraphic) {
263+
return this.checkInViewportByZone(graphic, this.BUFFER_ZONE);
264+
}
218265
/**
219-
* @description: 判断当前是否在可视区内
266+
* @description: 判断当前是否在指定视口范围内
220267
* @param {IGraphic} graphic
268+
* @param {number} buffer
221269
* @return {*}
222270
*/
223-
checkInViewport(graphic: IGraphic) {
271+
private checkInViewportByZone(graphic: IGraphic, buffer: number = 0) {
224272
const { stage, globalAABBBounds: cBounds } = graphic;
225273
if (!stage) {
226274
return false;
227275
}
228-
// 设立缓冲区 100px,提前加载
229-
const BUFFER = 100;
230276
// 获取视口的AABB边界
231277
//@ts-ignore
232278
const { AABBBounds: vBounds } = stage;
233279
// 扩展视口判断范围
234280
const eBounds = {
235-
x1: vBounds.x1 - BUFFER,
236-
x2: vBounds.x2 + BUFFER,
237-
y1: vBounds.y1 - BUFFER,
238-
y2: vBounds.y2 + BUFFER
281+
x1: vBounds.x1 - buffer,
282+
x2: vBounds.x2 + buffer,
283+
y1: vBounds.y1 - buffer,
284+
y2: vBounds.y2 + buffer
239285
};
240286
// 判断两个区域是否相交
241287
const isIntersecting =
242288
cBounds.x1 < eBounds.x2 && cBounds.x2 > eBounds.x1 && cBounds.y1 < eBounds.y2 && cBounds.y2 > eBounds.y1;
243289

244290
return isIntersecting;
245291
}
292+
293+
/**
294+
* @description: 节点访问顺序队列
295+
* @param {string} id
296+
* @return {*}
297+
*/
298+
private updateAccessQueue(id: string) {
299+
// 移除旧记录
300+
const index = this.accessQueue.indexOf(id);
301+
if (index > -1) {
302+
this.accessQueue.splice(index, 1);
303+
}
304+
// 添加到队列头部
305+
this.accessQueue.unshift(id);
306+
}
307+
308+
/**
309+
* @description: 在添加新节点前检查缓存大小
310+
* @param {IGraphic} graphic
311+
* @return {*}
312+
*/
313+
private checkAndClearCache(graphic: IGraphic) {
314+
const { viewportNodes, bufferNodes, cacheNodes } = this.classifyNodes();
315+
const total = viewportNodes.length + bufferNodes.length + cacheNodes.length;
316+
const customConfig = this.getCustomConfig(graphic);
317+
const maxTotal = customConfig?.maxDomCacheCount ?? this.MAX_CACHE_COUNT;
318+
319+
// 仅当总数超过阈值时清理
320+
if (total <= maxTotal) {
321+
return;
322+
}
323+
const exceedingCount = total - maxTotal;
324+
325+
// 优先清理缓存区节点: 移除缓存区的前 exceedingCount 个节点
326+
let toRemove = cacheNodes.slice(0, exceedingCount);
327+
328+
// 若缓存区节点不满足阈值,为了控制内存占用率,按最后访问时间清除最早访问的缓冲区节点
329+
if (toRemove.length < exceedingCount) {
330+
const bufferCandidates = bufferNodes
331+
.sort((a, b) => this.htmlMap[a].lastAccessed - this.htmlMap[b].lastAccessed)
332+
.slice(0, exceedingCount - toRemove.length);
333+
toRemove = toRemove.concat(bufferCandidates);
334+
}
335+
336+
// 执行清理
337+
toRemove.forEach(id => this.removeElement(id, true));
338+
}
339+
340+
/**
341+
* @description: 节点按可视区/缓存区/缓冲区分类
342+
* @return {*}
343+
*/
344+
private classifyNodes() {
345+
/** 可视区节点 */
346+
const viewportNodes: string[] = [];
347+
/** 缓冲区节点 */
348+
const bufferNodes: string[] = [];
349+
/** 既不在可视区也不在缓冲区的节点 */
350+
const cacheNodes: string[] = [];
351+
352+
Object.keys(this.htmlMap).forEach(id => {
353+
const node = this.htmlMap[id];
354+
if (node.isInViewport) {
355+
viewportNodes.push(id);
356+
} else if (this.checkInBuffer(node.graphic)) {
357+
bufferNodes.push(id);
358+
} else {
359+
cacheNodes.push(id);
360+
}
361+
});
362+
363+
return {
364+
viewportNodes,
365+
bufferNodes,
366+
cacheNodes
367+
};
368+
}
246369
/**
247370
* @description: 检查 dom 是否存在
248371
* @param {HTMLElement} dom
249372
* @return {*}
250373
*/
251-
checkDom(dom: HTMLElement) {
374+
private checkDom(dom: HTMLElement) {
252375
if (!dom) {
253376
return false;
254377
}
@@ -285,14 +408,19 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
285408
if (!wrapContainer) {
286409
return;
287410
}
288-
// 卸载子组件
289-
render(null, wrapContainer);
290411
if (!clear) {
291412
// 移除 dom 但保留在 htmlMap 中,供下次进入可视区时快速复用
292413
wrapContainer.remove();
293414
// 标记不在视口
294415
record.isInViewport = false;
416+
// 清理访问队列
417+
const index = this.accessQueue.indexOf(id);
418+
if (index > -1) {
419+
this.accessQueue.splice(index, 1);
420+
}
295421
} else {
422+
// 卸载子组件
423+
render(null, wrapContainer);
296424
this.checkDom(wrapContainer) && super.removeElement(id);
297425
// 清理引用
298426
delete this.htmlMap[id];
@@ -327,6 +455,7 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
327455
nativeContainer.appendChild(wrapContainer);
328456
}
329457
return {
458+
reuse: true,
330459
wrapContainer,
331460
nativeContainer
332461
};
@@ -373,11 +502,13 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
373502
}
374503

375504
// 默认自定义区域内也可带动表格画布滚动
376-
const { pointerEvents, penetrateEventList = ['wheel'] } = options;
505+
const { pointerEvents } = options;
377506
const calculateStyle = this.parseDefaultStyleFromGraphic(graphic);
378507
// 单元格样式
379508
const style = this.convertCellStyle(graphic);
380509
Object.assign(calculateStyle, {
510+
width: `${width}px`,
511+
height: `${height}px`,
381512
overflow: 'hidden',
382513
...(style || {}),
383514
...(rest || {}),
@@ -389,13 +520,7 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
389520
});
390521

391522
if (calculateStyle.pointerEvents !== 'none') {
392-
this.removeWrapContainerEventListener(wrapContainer);
393-
uniqArray(penetrateEventList).forEach((event: any) => {
394-
if (event === 'wheel') {
395-
wrapContainer.addEventListener('wheel', this.onWheel);
396-
wrapContainer.addEventListener('wheel', e => e.preventDefault(), true);
397-
}
398-
});
523+
this.checkToAddEventListener(wrapContainer);
399524
}
400525

401526
if (type === 'text' && options.anchorType === 'position') {
@@ -406,17 +531,56 @@ export class VTableVueAttributePlugin extends HtmlAttributePlugin implements IPl
406531
// 样式变化检查
407532
const styleChanged = !isEqual(record.lastStyle, calculateStyle);
408533
if (styleChanged) {
534+
this.styleUpdateQueue.set(wrapContainer.id, calculateStyle);
535+
// 请求批量更新
536+
this.requestStyleUpdate();
409537
// TODO 确认是否需要对接 VTableBrowserEnvContribution
410-
application.global.updateDom(wrapContainer, {
411-
width,
412-
height,
413-
style: calculateStyle
414-
});
538+
// application.global.updateDom(wrapContainer, {
539+
// width,
540+
// height,
541+
// style: calculateStyle
542+
// });
415543

416544
record.lastStyle = calculateStyle;
417545
}
418546
}
419547

548+
/**
549+
* @description: 事件监听器管理
550+
* @param {HTMLElement} wrapContainer
551+
* @return {*}
552+
*/
553+
private checkToAddEventListener(wrapContainer: HTMLElement) {
554+
if (!this.eventHandlers.has(wrapContainer)) {
555+
const handler = (e: WheelEvent) => {
556+
e.preventDefault();
557+
this.onWheel(e);
558+
};
559+
wrapContainer.addEventListener('wheel', handler, { passive: false });
560+
this.eventHandlers.set(wrapContainer, handler);
561+
}
562+
}
563+
564+
/**
565+
* @description: 样式更新
566+
* @return {*}
567+
*/
568+
private requestStyleUpdate() {
569+
if (!this.styleUpdateRequested) {
570+
this.styleUpdateRequested = true;
571+
requestAnimationFrame(() => {
572+
this.styleUpdateQueue.forEach((changes, id) => {
573+
const container = this.htmlMap?.[id]?.wrapContainer;
574+
if (container) {
575+
Object.assign(container.style, changes);
576+
}
577+
});
578+
this.styleUpdateQueue.clear();
579+
this.styleUpdateRequested = false;
580+
});
581+
}
582+
}
583+
420584
/**
421585
* @description: 转换单元格样式
422586
* @param {IGraphic} graphic

0 commit comments

Comments
 (0)