perf(vector-node): batch resize sync and cut redundant rerenders

This commit is contained in:
2026-02-27 22:24:04 +08:00
parent 271b722c97
commit e344c2272e
5 changed files with 329 additions and 116 deletions

View File

@@ -0,0 +1,104 @@
import { describe, expect, it, vi } from 'vitest';
import {
DEFAULT_VECTOR_CONFIG,
buildNextVectorConfig,
createRafLatestScheduler,
type VectorConfig
} from '@/components/flow/nodes/common/vectorNodeSync';
function createFakeRaf() {
let id = 0;
const callbacks = new Map<number, FrameRequestCallback>();
const requestFrame = vi.fn((cb: FrameRequestCallback) => {
id += 1;
callbacks.set(id, cb);
return id;
});
const cancelFrame = vi.fn((handle: number) => {
callbacks.delete(handle);
});
const runFrame = () => {
const pending = Array.from(callbacks.values());
callbacks.clear();
pending.forEach((cb) => cb(Date.now()));
};
return {
requestFrame,
cancelFrame,
runFrame
};
}
describe('vectorNodeSync', () => {
it('只在 vector 配置变化时生成下一次提交配置', () => {
const current: VectorConfig = { ...DEFAULT_VECTOR_CONFIG };
expect(buildNextVectorConfig(current, { ...current })).toBeNull();
const next = buildNextVectorConfig(current, {
fill: '#000000',
tileWidth: 64
});
expect(next).toMatchObject({
fill: '#000000',
tileWidth: 64
});
expect(next?.tileHeight).toBe(current.tileHeight);
});
it('同一帧连续缩放事件只提交最后一次更新,避免重复抖动', () => {
const fakeRaf = createFakeRaf();
const commits: VectorConfig[] = [];
const scheduler = createRafLatestScheduler<VectorConfig>(
(payload) => {
commits.push(payload);
},
{
requestFrame: fakeRaf.requestFrame,
cancelFrame: fakeRaf.cancelFrame
}
);
scheduler.enqueue({ ...DEFAULT_VECTOR_CONFIG, strokeWidth: 1 });
scheduler.enqueue({ ...DEFAULT_VECTOR_CONFIG, strokeWidth: 2 });
scheduler.enqueue({ ...DEFAULT_VECTOR_CONFIG, strokeWidth: 3 });
expect(fakeRaf.requestFrame).toHaveBeenCalledTimes(1);
expect(commits).toHaveLength(0);
fakeRaf.runFrame();
expect(commits).toHaveLength(1);
expect(commits[0].strokeWidth).toBe(3);
});
it('连续缩放仅变化尺寸时不会触发矢量配置重复提交', () => {
const fakeRaf = createFakeRaf();
const commits: VectorConfig[] = [];
const scheduler = createRafLatestScheduler<VectorConfig>(
(payload) => {
commits.push(payload);
},
{
requestFrame: fakeRaf.requestFrame,
cancelFrame: fakeRaf.cancelFrame
}
);
const current = { ...DEFAULT_VECTOR_CONFIG };
for (let i = 0; i < 40; i += 1) {
const next = buildNextVectorConfig(current, { ...current });
if (next) {
scheduler.enqueue(next);
}
}
expect(fakeRaf.requestFrame).not.toHaveBeenCalled();
expect(commits).toHaveLength(0);
});
});

View File

@@ -1,120 +1,95 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { useNodeAppearance } from '@/ts/useNodeAppearance';
import {
DEFAULT_VECTOR_CONFIG,
buildNextVectorConfig,
createRafLatestScheduler,
type VectorConfig
} from './vectorNodeSync';
const vectorConfig = ref({
kind: 'rect',
svgContent: '',
path: '',
tileWidth: 50,
tileHeight: 50,
fill: '#409EFF',
stroke: '#303133',
strokeWidth: 1
const vectorConfig = ref<VectorConfig>({ ...DEFAULT_VECTOR_CONFIG });
const patternId = `vector-pattern-${Math.random().toString(36).slice(2, 11)}`;
const syncVectorConfig = createRafLatestScheduler<VectorConfig>((nextConfig) => {
vectorConfig.value = nextConfig;
});
const nodeSize = ref({ width: 200, height: 200 });
let syncRafId: number | null = null;
let pendingVectorConfig: Record<string, any> | null = null;
let pendingNodeSize: { width: number; height: number } | null = null;
const flushPendingSync = () => {
if (pendingVectorConfig) {
Object.assign(vectorConfig.value, pendingVectorConfig);
pendingVectorConfig = null;
}
if (pendingNodeSize) {
nodeSize.value = pendingNodeSize;
pendingNodeSize = null;
}
syncRafId = null;
};
const scheduleSync = () => {
if (typeof requestAnimationFrame === 'undefined') {
flushPendingSync();
return;
}
if (syncRafId !== null) {
cancelAnimationFrame(syncRafId);
}
syncRafId = requestAnimationFrame(flushPendingSync);
};
const { containerStyle } = useNodeAppearance({
onPropsChange(props, node) {
if (props.vector) {
pendingVectorConfig = { ...props.vector };
onPropsChange(props) {
const nextConfig = buildNextVectorConfig(vectorConfig.value, props?.vector);
if (nextConfig) {
syncVectorConfig.enqueue(nextConfig);
}
if (node) {
pendingNodeSize = {
width: node.width,
height: node.height
};
}
// 使用 requestAnimationFrame 防抖,减少快速缩放时的重复重绘
scheduleSync();
}
});
onBeforeUnmount(() => {
if (syncRafId !== null && typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(syncRafId);
syncRafId = null;
}
syncVectorConfig.cancel();
});
const patternId = `vector-pattern-${Math.random().toString(36).slice(2, 11)}`;
// 生成 SVG 内容
const svgContent = computed(() => {
const { kind, path, tileWidth, tileHeight, fill, stroke, strokeWidth } = vectorConfig.value;
let shapeElement = '';
switch (kind) {
case 'rect':
shapeElement = `<rect x="0" y="0" width="${tileWidth}" height="${tileHeight}"
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`;
break;
case 'ellipse':
shapeElement = `<ellipse cx="${tileWidth/2}" cy="${tileHeight/2}"
rx="${tileWidth/2 - strokeWidth}" ry="${tileHeight/2 - strokeWidth}"
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`;
break;
case 'path':
shapeElement = `<path d="${path || 'M 0 0'}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`;
break;
case 'svg':
shapeElement = vectorConfig.value.svgContent || '';
break;
case 'polygon':
// 默认三角形
const points = `0,${tileHeight} ${tileWidth/2},0 ${tileWidth},${tileHeight}`;
shapeElement = `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`;
break;
}
return `
<svg width="${nodeSize.value.width}" height="${nodeSize.value.height}"
xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="${patternId}"
x="0" y="0"
width="${tileWidth}"
height="${tileHeight}"
patternUnits="userSpaceOnUse">
${shapeElement}
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#${patternId})" />
</svg>
`;
});
const ellipseCx = computed(() => vectorConfig.value.tileWidth / 2);
const ellipseCy = computed(() => vectorConfig.value.tileHeight / 2);
const ellipseRx = computed(() => Math.max(0, vectorConfig.value.tileWidth / 2 - vectorConfig.value.strokeWidth));
const ellipseRy = computed(() => Math.max(0, vectorConfig.value.tileHeight / 2 - vectorConfig.value.strokeWidth));
const polygonPoints = computed(
() => `0,${vectorConfig.value.tileHeight} ${vectorConfig.value.tileWidth / 2},0 ${vectorConfig.value.tileWidth},${vectorConfig.value.tileHeight}`
);
const safePath = computed(() => vectorConfig.value.path || 'M 0 0');
const patternFill = computed(() => `url(#${patternId})`);
</script>
<template>
<div class="vector-node" :style="containerStyle">
<div class="vector-content" v-html="svgContent"></div>
<svg class="vector-content" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs>
<pattern
:id="patternId"
x="0"
y="0"
:width="vectorConfig.tileWidth"
:height="vectorConfig.tileHeight"
patternUnits="userSpaceOnUse"
>
<rect
v-if="vectorConfig.kind === 'rect'"
x="0"
y="0"
:width="vectorConfig.tileWidth"
:height="vectorConfig.tileHeight"
:fill="vectorConfig.fill"
:stroke="vectorConfig.stroke"
:stroke-width="vectorConfig.strokeWidth"
/>
<ellipse
v-else-if="vectorConfig.kind === 'ellipse'"
:cx="ellipseCx"
:cy="ellipseCy"
:rx="ellipseRx"
:ry="ellipseRy"
:fill="vectorConfig.fill"
:stroke="vectorConfig.stroke"
:stroke-width="vectorConfig.strokeWidth"
/>
<path
v-else-if="vectorConfig.kind === 'path'"
:d="safePath"
:fill="vectorConfig.fill"
:stroke="vectorConfig.stroke"
:stroke-width="vectorConfig.strokeWidth"
/>
<g v-else-if="vectorConfig.kind === 'svg'" v-html="vectorConfig.svgContent || ''"></g>
<polygon
v-else
:points="polygonPoints"
:fill="vectorConfig.fill"
:stroke="vectorConfig.stroke"
:stroke-width="vectorConfig.strokeWidth"
/>
</pattern>
</defs>
<rect width="100%" height="100%" :fill="patternFill" />
</svg>
</div>
</template>
@@ -128,11 +103,6 @@ const svgContent = computed(() => {
}
.vector-content {
width: 100%;
height: 100%;
}
.vector-content :deep(svg) {
display: block;
width: 100%;
height: 100%;

View File

@@ -33,9 +33,25 @@ class VectorNodeModel extends HtmlNodeModel {
resize(deltaX: number, deltaY: number) {
const result = super.resize?.(deltaX, deltaY);
// 持久化宽高到 properties
this.setProperty('width', this.width);
this.setProperty('height', this.height);
const nextWidth = this.width;
const nextHeight = this.height;
// 宽高无变化时跳过,避免高频缩放中的无效属性变更事件。
if (this.properties?.width === nextWidth && this.properties?.height === nextHeight) {
return result;
}
// 持久化宽高到 properties单次提交减少事件抖动
const setProperties = (this as any).setProperties as ((props: Record<string, any>) => void) | undefined;
if (setProperties) {
setProperties.call(this, {
width: nextWidth,
height: nextHeight
});
} else {
this.setProperty('width', nextWidth);
this.setProperty('height', nextHeight);
}
return result;
}

View File

@@ -0,0 +1,123 @@
export interface VectorConfig {
kind: string;
svgContent: string;
path: string;
tileWidth: number;
tileHeight: number;
fill: string;
stroke: string;
strokeWidth: number;
}
export const DEFAULT_VECTOR_CONFIG: VectorConfig = {
kind: 'rect',
svgContent: '',
path: '',
tileWidth: 50,
tileHeight: 50,
fill: '#409EFF',
stroke: '#303133',
strokeWidth: 1
};
const VECTOR_CONFIG_KEYS: Array<keyof VectorConfig> = [
'kind',
'svgContent',
'path',
'tileWidth',
'tileHeight',
'fill',
'stroke',
'strokeWidth'
];
type FrameRequest = (callback: FrameRequestCallback) => number;
type FrameCancel = (handle: number) => void;
const getDefaultRequestFrame = (): FrameRequest | undefined =>
typeof globalThis.requestAnimationFrame === 'function'
? globalThis.requestAnimationFrame.bind(globalThis)
: undefined;
const getDefaultCancelFrame = (): FrameCancel | undefined =>
typeof globalThis.cancelAnimationFrame === 'function'
? globalThis.cancelAnimationFrame.bind(globalThis)
: undefined;
export function buildNextVectorConfig(
current: VectorConfig,
incoming?: Record<string, any> | null
): VectorConfig | null {
if (!incoming || typeof incoming !== 'object') {
return null;
}
let changed = false;
const next = { ...current };
for (const key of VECTOR_CONFIG_KEYS) {
if (!(key in incoming)) {
continue;
}
const incomingValue = incoming[key];
if (incomingValue === undefined || incomingValue === current[key]) {
continue;
}
(next as Record<string, any>)[key] = incomingValue;
changed = true;
}
return changed ? next : null;
}
export function createRafLatestScheduler<T>(
apply: (payload: T) => void,
options?: {
requestFrame?: FrameRequest;
cancelFrame?: FrameCancel;
}
) {
const requestFrame = options?.requestFrame ?? getDefaultRequestFrame();
const cancelFrame = options?.cancelFrame ?? getDefaultCancelFrame();
let rafId: number | null = null;
let pendingPayload: T | null = null;
const flush = () => {
if (pendingPayload === null) {
return;
}
const latestPayload = pendingPayload;
pendingPayload = null;
apply(latestPayload);
};
const schedule = () => {
if (!requestFrame) {
flush();
return;
}
if (rafId !== null) {
return;
}
rafId = requestFrame(() => {
rafId = null;
flush();
});
};
return {
enqueue(payload: T) {
pendingPayload = payload;
schedule();
},
flush,
cancel() {
if (rafId !== null && cancelFrame) {
cancelFrame(rafId);
}
rafId = null;
pendingPayload = null;
}
};
}