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

@@ -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%;