s14 - 序列化
你搭了一个能拖拽编排的工作流引擎,画了几个节点,连了几条线,跑通了。然后你关掉浏览器——全没了。 这一章要解决的问题很简单:把你画的东西存下来,下次能打开。
这一章要解决什么问题
s13 的工作流编排功能是完整的——拖拽、连线、执行、查看结果,全都能用。但它有一个致命缺陷:一切都是内存里的。
浏览器一刷新,你精心设计的工作流就消失了。你没办法把一个调试好的工作流分享给同事,也没办法在不同项目之间复用。
这一章做三件事:
- 导出:把画布上的节点和连线打包成 JSON 文件,下载到本地
- 导入:读取一个 JSON 文件,恢复到画布上
- 本地库:用 localStorage 存多个工作流,随时切换
为什么要序列化
序列化不只是"保存"。一个干净的 JSON 格式能让你:
- 版本控制:工作流 JSON 可以放进 git,跟代码一起管理
- 分享协作:把 .json 文件发给同事,他导入就能用
- 批量生成:用脚本批量创建工作流,不用一个个拖
- 迁移升级:schema 加版本号,未来升级格式时能兼容旧数据
React Flow 的内部状态(Node[] + Edge[])可以直接 JSON.stringify,但那不是好的持久化格式——它包含大量 UI 状态(选中、拖拽中、折叠),这些不应该被保存。
我们要做的是:提取业务数据,丢弃 UI 状态,定义一个干净的 schema。
JSON Schema 设计
{
"meta": {
"id": "k7x2m9abc",
"name": "客服助手工作流",
"description": "接收用户问题,分类后路由到不同 LLM",
"version": 1,
"createdAt": "2026-05-28T10:00:00Z",
"updatedAt": "2026-05-28T10:00:00Z"
},
"nodes": [
{
"id": "input-1",
"type": "input",
"data": { "label": "用户输入", "value": "" },
"position": { "x": 100, "y": 50 }
}
],
"edges": [
{
"id": "e1",
"source": "input-1",
"target": "llm-1"
}
]
}三个顶层字段:
- meta:工作流的元信息。
version是关键——未来如果 schema 变了(比如加了节点分组、变量池),旧版本的文件可以通过版本号做迁移。 - nodes:只保留
id、type、data、position。data是业务数据(提示词、模型名),position是画布坐标。 - edges:只保留
id、source、target和可选的sourceHandle/targetHandle。
width、height、selected、dragging 这些 React Flow 内部状态全部丢弃——它们是 UI 层的事,不该污染数据层。
核心代码:workflowIO.ts
整个序列化逻辑集中在 app/utils/workflowIO.ts 里,四组函数:
// 业务数据 → JSON
serialize(name, description, nodes, edges) → WorkflowJSON
// JSON → React Flow 可用的 nodes + edges
deserialize(json) → { nodes, edges }
// 校验 JSON 结构是否合法
validate(json) → { valid, error? }
// localStorage 读写
saveToLibrary(workflow) // 存
loadLibrary() → WorkflowJSON[] // 取
deleteFromLibrary(id) // 删序列化:只取需要的
export function serialize(
name: string,
description: string,
nodes: Node[],
edges: Edge[],
existingId?: string
): WorkflowJSON {
return {
meta: {
id: existingId || generateId(),
name,
description,
version: SCHEMA_VERSION,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
nodes: nodes.map((n) => ({
id: n.id,
type: n.type || "default",
data: n.data ?? {},
position: n.position,
})),
edges: edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? undefined,
targetHandle: e.targetHandle ?? undefined,
})),
};
}核心思路:对每个 node,只取 id、type、data、position。其他字段(width、height、selected、dragging)全部丢弃。
校验:先验再用
导入一个 JSON 文件之前,必须校验结构。否则 React Flow 会因为缺少字段直接崩溃。
export function validate(json: unknown): ValidationResult {
// 检查 meta 字段
// 检查 meta.name 是非空字符串
// 检查 meta.version 是数字,且不大于当前版本
// 检查 nodes 是数组,每个节点有 id、type、position
// 检查 edges 是数组,source 和 target 都指向存在的节点
}校验失败时返回明确的错误信息,比如"节点 xxx 缺少 type"或"边的 source yyy 不存在"。用户拿到这个信息就知道该怎么修。
版本检查是重点:meta.version > SCHEMA_VERSION 时拒绝导入。这防止了新版本的文件被旧版本的代码错误解析。
反序列化:补回 UI 必需的字段
export function deserialize(json: WorkflowJSON): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = json.nodes.map((n) => ({
id: n.id,
type: n.type,
data: n.data,
position: n.position,
}));
// edges 同理
}反序列化很简单——JSON 里的字段直接映射回 React Flow 的 Node 和 Edge 类型。width、height 这些字段不设置也没关系,React Flow 会自动计算。
文件下载与上传
浏览器里做文件 I/O 用的是 Blob + URL.createObjectURL + <a> 标签的组合:
export function downloadJSON(workflow: WorkflowJSON): void {
const blob = new Blob([JSON.stringify(workflow, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${workflow.meta.name || "workflow"}.json`;
a.click();
URL.revokeObjectURL(url);
}上传用 FileReader:
export function readUploadedFile(file: File): Promise<WorkflowJSON> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
try {
resolve(JSON.parse(reader.result as string));
} catch {
reject(new Error("文件不是合法的 JSON"));
}
};
reader.readAsText(file);
});
}UI:ImportExport 组件
app/components/ImportExport.tsx 把文件操作包装成三个按钮:
[导出 JSON] [导入 JSON] [存到本地库]导入流程:用户选文件 → readUploadedFile 读取 → validate 校验 → 失败显示错误,成功调 onLoad 恢复画布。
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const json = await readUploadedFile(file);
const result = validate(json);
if (!result.valid) {
setError(result.error);
return;
}
const { nodes, edges } = deserialize(json);
onLoad(nodes, edges);
};注意最后重置了 input 的 value——浏览器的 onChange 对同一个文件不会触发两次,不重置的话用户就没法重复导入同一个文件了。
UI:WorkflowLibrary 组件
app/components/WorkflowLibrary.tsx 是一个可折叠的列表,显示 localStorage 里保存的所有工作流。
每个工作流显示名称、节点数、边数、版本号。hover 时出现"加载"和"删除"按钮。
// 加载一个工作流到画布
const handleLoad = (workflow: WorkflowJSON) => {
const { nodes, edges } = deserialize(workflow);
onLoad(nodes, edges, workflow.meta);
};库列表是懒加载的——默认折叠,用户点击才展开。这样不影响主界面的布局。
主页面整合
app/page.tsx 把所有组件串在一起。关键变化是引入了受控状态:
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);useNodesState 是 React Flow 提供的 hook,内部管理 nodes 数组和 onNodesChange 回调。导入工作流时,直接 setNodes(newNodes) 就能更新画布——React Flow 会自动重渲染。
侧边栏增加了一个"展开本地库"按钮,点击后在右侧面板顶部显示 WorkflowLibrary。
试着改改
1. 给导出加压缩
JSON 文件可能很大(节点多的时候)。试试用 CompressionStream API 压缩后再下载,导入时解压。观察文件大小的变化。
2. 加自动保存
每隔 30 秒自动把当前画布存到 localStorage。用户刷新页面后能恢复上次的工作状态。注意要防抖——用户正在拖拽节点的时候不应该触发保存。
3. 支持拖拽导入
用户可以直接把 .json 文件拖到画布上导入,不用点按钮。监听 drop 事件,读取 e.dataTransfer.files。
4. 加工作流对比
导入一个工作流时,如果当前画布有内容,弹窗提示"当前工作流未保存,是否覆盖?"。可以进一步做 diff——高亮显示新增/删除的节点。
教学边界
这一章只做一件事:把工作流变成可保存、可分享的 JSON 文件。
不涉及:
- 云端存储(只用 localStorage 和本地文件)
- 多人协作(没有冲突合并)
- 工作流版本历史(没有撤销/重做,没有 diff)
- 二进制格式(纯 JSON,没有 protobuf 或 msgpack)
这些都在后面的章节里。
一句话记住
序列化是产品的分水岭——不可保存的东西只是 demo,可保存的东西才是工具。