Skip to content

s14 - 序列化

你搭了一个能拖拽编排的工作流引擎,画了几个节点,连了几条线,跑通了。然后你关掉浏览器——全没了。 这一章要解决的问题很简单:把你画的东西存下来,下次能打开。

这一章要解决什么问题

s13 的工作流编排功能是完整的——拖拽、连线、执行、查看结果,全都能用。但它有一个致命缺陷:一切都是内存里的。

浏览器一刷新,你精心设计的工作流就消失了。你没办法把一个调试好的工作流分享给同事,也没办法在不同项目之间复用。

这一章做三件事:

  1. 导出:把画布上的节点和连线打包成 JSON 文件,下载到本地
  2. 导入:读取一个 JSON 文件,恢复到画布上
  3. 本地库:用 localStorage 存多个工作流,随时切换

为什么要序列化

序列化不只是"保存"。一个干净的 JSON 格式能让你:

  • 版本控制:工作流 JSON 可以放进 git,跟代码一起管理
  • 分享协作:把 .json 文件发给同事,他导入就能用
  • 批量生成:用脚本批量创建工作流,不用一个个拖
  • 迁移升级:schema 加版本号,未来升级格式时能兼容旧数据

React Flow 的内部状态(Node[] + Edge[])可以直接 JSON.stringify,但那不是好的持久化格式——它包含大量 UI 状态(选中、拖拽中、折叠),这些不应该被保存。

我们要做的是:提取业务数据,丢弃 UI 状态,定义一个干净的 schema。

JSON Schema 设计

json
{
  "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:只保留 idtypedatapositiondata 是业务数据(提示词、模型名),position 是画布坐标。
  • edges:只保留 idsourcetarget 和可选的 sourceHandle/targetHandle

widthheightselecteddragging 这些 React Flow 内部状态全部丢弃——它们是 UI 层的事,不该污染数据层。

核心代码:workflowIO.ts

整个序列化逻辑集中在 app/utils/workflowIO.ts 里,四组函数:

typescript
// 业务数据 → 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)       // 删

序列化:只取需要的

typescript
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,只取 idtypedataposition。其他字段(widthheightselecteddragging)全部丢弃。

校验:先验再用

导入一个 JSON 文件之前,必须校验结构。否则 React Flow 会因为缺少字段直接崩溃。

typescript
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 必需的字段

typescript
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 的 NodeEdge 类型。widthheight 这些字段不设置也没关系,React Flow 会自动计算。

文件下载与上传

浏览器里做文件 I/O 用的是 Blob + URL.createObjectURL + <a> 标签的组合:

typescript
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

typescript
export function readUploadedFile(file: File): Promise&lt;WorkflowJSON&gt; {
  return new Promise((resolve, reject) =&gt; {
    const reader = new FileReader();
    reader.onload = () =&gt; {
      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 恢复画布。

typescript
const handleImport = async (e: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
  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 时出现"加载"和"删除"按钮。

typescript
// 加载一个工作流到画布
const handleLoad = (workflow: WorkflowJSON) =&gt; {
  const { nodes, edges } = deserialize(workflow);
  onLoad(nodes, edges, workflow.meta);
};

库列表是懒加载的——默认折叠,用户点击才展开。这样不影响主界面的布局。

主页面整合

app/page.tsx 把所有组件串在一起。关键变化是引入了受控状态:

typescript
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,可保存的东西才是工具。