perf: 减少工作流设计器重复计算与序列化
- 缓存节点汇聚模式拓扑分析结果 - 草稿保存复用内容序列化结果
This commit is contained in:
@@ -114,6 +114,23 @@ describe('workflowDraftCache', () => {
|
||||
).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('忽略结构异常的缓存内容', () => {
|
||||
sessionStorage.setItem(
|
||||
`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(
|
||||
workflowId: unknown,
|
||||
serverContent: unknown,
|
||||
@@ -124,15 +143,11 @@ export function writeWorkflowDraft(
|
||||
return true;
|
||||
}
|
||||
|
||||
const snapshot: WorkflowDraftSnapshot = {
|
||||
baseContentSignature: options.baseContentSignature,
|
||||
content: options.content,
|
||||
updatedAt: Date.now(),
|
||||
version: WORKFLOW_DRAFT_VERSION,
|
||||
workflowId: String(options.workflowId),
|
||||
};
|
||||
try {
|
||||
storage.setItem(storageKey, JSON.stringify(snapshot));
|
||||
storage.setItem(
|
||||
storageKey,
|
||||
serializeWorkflowDraft(options, contentSignature),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import {useTinyflowStore} from '#store/stores.svelte';
|
||||
import NodeJoinModeSetting from './NodeJoinModeSetting.svelte';
|
||||
import {
|
||||
analyzeJoinMode,
|
||||
getCachedJoinModeAnalysis,
|
||||
getJoinModeBadge,
|
||||
type JoinMode,
|
||||
} from '../utils/joinMode';
|
||||
@@ -64,11 +64,7 @@
|
||||
const store = useTinyflowStore();
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
|
||||
const joinModeAnalysis = $derived.by(() => analyzeJoinMode(
|
||||
store.getNodes(),
|
||||
store.getEdges(),
|
||||
id,
|
||||
));
|
||||
const joinModeAnalysis = $derived.by(() => getCachedJoinModeAnalysis(store, id));
|
||||
const joinModeBadge = $derived(getJoinModeBadge(data.joinMode));
|
||||
|
||||
const items = $derived.by(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
analyzeJoinMode,
|
||||
getCachedJoinModeAnalysis,
|
||||
getJoinModeBadge,
|
||||
parseJoinMode,
|
||||
} from './joinMode';
|
||||
@@ -154,4 +155,65 @@ describe('join mode graph analysis', () => {
|
||||
expect(analysis.invalidMode).toBe(true);
|
||||
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;
|
||||
};
|
||||
|
||||
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());
|
||||
|
||||
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[]) {
|
||||
const guaranteed = new Set(
|
||||
nodes
|
||||
|
||||
Reference in New Issue
Block a user