perf: 减少工作流设计器重复计算与序列化
- 缓存节点汇聚模式拓扑分析结果 - 草稿保存复用内容序列化结果
This commit is contained in:
@@ -114,6 +114,23 @@ describe('workflowDraftCache', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('写入草稿时只序列化一次工作流内容', () => {
|
||||||
|
const toJSON = vi.fn(() => draftContent);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
writeWorkflowDraft({
|
||||||
|
baseContentSignature: createWorkflowContentSignature(serverContent),
|
||||||
|
content: {toJSON},
|
||||||
|
workflowId,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(toJSON).toHaveBeenCalledTimes(1);
|
||||||
|
expect(readWorkflowDraft(workflowId, serverContent)?.content).toEqual(
|
||||||
|
draftContent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('忽略结构异常的缓存内容', () => {
|
it('忽略结构异常的缓存内容', () => {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
`easyflow:workflow-draft:${workflowId}`,
|
`easyflow:workflow-draft:${workflowId}`,
|
||||||
|
|||||||
@@ -63,6 +63,25 @@ export function createWorkflowContentSignature(content: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeWorkflowDraft(
|
||||||
|
options: WriteWorkflowDraftOptions,
|
||||||
|
contentSignature: string,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
'{"baseContentSignature":',
|
||||||
|
JSON.stringify(options.baseContentSignature),
|
||||||
|
',"content":',
|
||||||
|
contentSignature,
|
||||||
|
',"updatedAt":',
|
||||||
|
String(Date.now()),
|
||||||
|
',"version":',
|
||||||
|
String(WORKFLOW_DRAFT_VERSION),
|
||||||
|
',"workflowId":',
|
||||||
|
JSON.stringify(String(options.workflowId)),
|
||||||
|
'}',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
export function readWorkflowDraft(
|
export function readWorkflowDraft(
|
||||||
workflowId: unknown,
|
workflowId: unknown,
|
||||||
serverContent: unknown,
|
serverContent: unknown,
|
||||||
@@ -124,15 +143,11 @@ export function writeWorkflowDraft(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot: WorkflowDraftSnapshot = {
|
|
||||||
baseContentSignature: options.baseContentSignature,
|
|
||||||
content: options.content,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
version: WORKFLOW_DRAFT_VERSION,
|
|
||||||
workflowId: String(options.workflowId),
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
storage.setItem(storageKey, JSON.stringify(snapshot));
|
storage.setItem(
|
||||||
|
storageKey,
|
||||||
|
serializeWorkflowDraft(options, contentSignature),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import {useTinyflowStore} from '#store/stores.svelte';
|
import {useTinyflowStore} from '#store/stores.svelte';
|
||||||
import NodeJoinModeSetting from './NodeJoinModeSetting.svelte';
|
import NodeJoinModeSetting from './NodeJoinModeSetting.svelte';
|
||||||
import {
|
import {
|
||||||
analyzeJoinMode,
|
getCachedJoinModeAnalysis,
|
||||||
getJoinModeBadge,
|
getJoinModeBadge,
|
||||||
type JoinMode,
|
type JoinMode,
|
||||||
} from '../utils/joinMode';
|
} from '../utils/joinMode';
|
||||||
@@ -64,11 +64,7 @@
|
|||||||
const store = useTinyflowStore();
|
const store = useTinyflowStore();
|
||||||
const updateNodeInternals = useUpdateNodeInternals();
|
const updateNodeInternals = useUpdateNodeInternals();
|
||||||
|
|
||||||
const joinModeAnalysis = $derived.by(() => analyzeJoinMode(
|
const joinModeAnalysis = $derived.by(() => getCachedJoinModeAnalysis(store, id));
|
||||||
store.getNodes(),
|
|
||||||
store.getEdges(),
|
|
||||||
id,
|
|
||||||
));
|
|
||||||
const joinModeBadge = $derived(getJoinModeBadge(data.joinMode));
|
const joinModeBadge = $derived(getJoinModeBadge(data.joinMode));
|
||||||
|
|
||||||
const items = $derived.by(() => {
|
const items = $derived.by(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
analyzeJoinMode,
|
analyzeJoinMode,
|
||||||
|
getCachedJoinModeAnalysis,
|
||||||
getJoinModeBadge,
|
getJoinModeBadge,
|
||||||
parseJoinMode,
|
parseJoinMode,
|
||||||
} from './joinMode';
|
} from './joinMode';
|
||||||
@@ -154,4 +155,65 @@ describe('join mode graph analysis', () => {
|
|||||||
expect(analysis.invalidMode).toBe(true);
|
expect(analysis.invalidMode).toBe(true);
|
||||||
expect(analysis.mode).toBeNull();
|
expect(analysis.mode).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses analysis when ordinary node content changes', () => {
|
||||||
|
let nodes = [
|
||||||
|
node('start', 'startNode'),
|
||||||
|
node('join', 'codeNode', {joinMode: 'any', title: 'before'}),
|
||||||
|
];
|
||||||
|
const edges = [edge('start-join', 'start', 'join')];
|
||||||
|
const store = {
|
||||||
|
getNodes: () => nodes,
|
||||||
|
getEdges: () => edges,
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = getCachedJoinModeAnalysis(store, 'join');
|
||||||
|
nodes = [
|
||||||
|
node('start', 'startNode'),
|
||||||
|
node('join', 'codeNode', {joinMode: 'any', title: 'after'}),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getCachedJoinModeAnalysis(store, 'join')).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates cached analysis when topology rules change', () => {
|
||||||
|
let nodes = [
|
||||||
|
node('start', 'startNode'),
|
||||||
|
node('join', 'codeNode', {joinMode: 'any'}),
|
||||||
|
];
|
||||||
|
const edges = [edge('start-join', 'start', 'join')];
|
||||||
|
const store = {
|
||||||
|
getNodes: () => nodes,
|
||||||
|
getEdges: () => edges,
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = getCachedJoinModeAnalysis(store, 'join');
|
||||||
|
nodes = [
|
||||||
|
node('start', 'startNode'),
|
||||||
|
node('join', 'codeNode', {joinMode: 'all'}),
|
||||||
|
];
|
||||||
|
const second = getCachedJoinModeAnalysis(store, 'join');
|
||||||
|
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(second.mode).toBe('all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, ''])(
|
||||||
|
'invalidates the default cache when joinMode becomes %j',
|
||||||
|
(joinMode) => {
|
||||||
|
let nodes = [node('join')];
|
||||||
|
const store = {
|
||||||
|
getNodes: () => nodes,
|
||||||
|
getEdges: () => [] as Edge[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = getCachedJoinModeAnalysis(store, 'join');
|
||||||
|
nodes = [node('join', 'codeNode', {joinMode})];
|
||||||
|
const second = getCachedJoinModeAnalysis(store, 'join');
|
||||||
|
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(second.mode).toBeNull();
|
||||||
|
expect(second.invalidMode).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ export type JoinModeAnalysis = {
|
|||||||
allDisabledReason: string;
|
allDisabledReason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type JoinModeStore = {
|
||||||
|
getEdges: () => Edge[];
|
||||||
|
getNodes: () => Node[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type JoinModeCacheEntry = {
|
||||||
|
analyses: Map<string, JoinModeAnalysis>;
|
||||||
|
edges: Edge[];
|
||||||
|
nodes: Node[];
|
||||||
|
topologySignature: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinModeAnalysisCache = new WeakMap<object, JoinModeCacheEntry>();
|
||||||
|
|
||||||
const text = (value: unknown) => (value == null ? '' : String(value).trim());
|
const text = (value: unknown) => (value == null ? '' : String(value).trim());
|
||||||
|
|
||||||
export function parseJoinMode(value: unknown): JoinMode | null {
|
export function parseJoinMode(value: unknown): JoinMode | null {
|
||||||
@@ -109,6 +123,63 @@ export function analyzeJoinMode(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reuses join-mode analysis while node edits leave the graph topology intact.
|
||||||
|
* XYFlow replaces the nodes/edges arrays on graph changes, so the signature is
|
||||||
|
* calculated once per store update instead of once per rendered node.
|
||||||
|
*/
|
||||||
|
export function getCachedJoinModeAnalysis(
|
||||||
|
store: JoinModeStore,
|
||||||
|
nodeId: string,
|
||||||
|
): JoinModeAnalysis {
|
||||||
|
const nodes = store.getNodes();
|
||||||
|
const edges = store.getEdges();
|
||||||
|
let entry = joinModeAnalysisCache.get(store as object);
|
||||||
|
|
||||||
|
if (entry?.nodes !== nodes || entry.edges !== edges) {
|
||||||
|
const topologySignature = createTopologySignature(nodes, edges);
|
||||||
|
entry = entry?.topologySignature === topologySignature
|
||||||
|
? {...entry, nodes, edges}
|
||||||
|
: {nodes, edges, topologySignature, analyses: new Map()};
|
||||||
|
joinModeAnalysisCache.set(store as object, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = entry.analyses.get(nodeId);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const analysis = analyzeJoinMode(nodes, edges, nodeId);
|
||||||
|
entry.analyses.set(nodeId, analysis);
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTopologySignature(nodes: Node[], edges: Edge[]) {
|
||||||
|
return JSON.stringify([
|
||||||
|
nodes.map((node) => [
|
||||||
|
node.id,
|
||||||
|
node.type,
|
||||||
|
node.parentId,
|
||||||
|
joinModeSignature(node),
|
||||||
|
text(node.data?.condition),
|
||||||
|
]),
|
||||||
|
edges.map((edge) => [
|
||||||
|
edge.source,
|
||||||
|
edge.target,
|
||||||
|
text(edge.data?.condition),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinModeSignature(node: Node) {
|
||||||
|
const hasJoinMode = Boolean(node.data)
|
||||||
|
&& Object.prototype.hasOwnProperty.call(node.data, 'joinMode');
|
||||||
|
const value = node.data?.joinMode;
|
||||||
|
return [
|
||||||
|
hasJoinMode,
|
||||||
|
value === undefined ? 'undefined' : value === null ? 'null' : typeof value,
|
||||||
|
text(value).toLowerCase(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function findGuaranteedNodes(nodes: Node[], edges: Edge[]) {
|
function findGuaranteedNodes(nodes: Node[], edges: Edge[]) {
|
||||||
const guaranteed = new Set(
|
const guaranteed = new Set(
|
||||||
nodes
|
nodes
|
||||||
|
|||||||
Reference in New Issue
Block a user