Smart Flow 013/014:编辑器页面与画布建模
013 / 014 前端学习笔记
本文整理两个提交中最值得前端开发学习的内容:
3924876 013: editor header ui5e049de 014: 建议editor页面,实现基本的workflow页面
重点不是逐行背代码,而是理解它们背后的实现逻辑:
- Next.js App Router 中服务端组件和客户端组件如何分工
- tRPC + React Query 如何组织数据获取、缓存和 mutation
- inline edit 这种交互组件如何处理本地状态和远端状态
- React Flow 画布如何用受控状态管理 nodes / edges
- 数据库里的 workflow / node / connection 如何映射成前端图结构
- 面试中可能如何被问,以及如何回答
一、两个提交分别做了什么
013: editor header ui
这个提交把 editor 页面从一个简单占位页面升级成有结构的编辑器页面。
主要变化:
- 新增
EditorHeader - 新增
Editor - 新增
EditorLoading/EditorError - 新增
useSuspenseWorkflow - 新增
useUpdateWorkflowName - 新增
prefetchWorkflow - 页面改成
HydrateClient + Suspense + ErrorBoundary - workflow 详情查询从
findUnique改成findUniqueOrThrow
核心学习点:
- 页面渲染结构
- Suspense 数据加载
- ErrorBoundary 错误兜底
- inline edit 交互
- mutation 后的 query invalidation
014: 基本 workflow editor 页面
这个提交开始引入 React Flow,真正把 workflow 做成可视化画布。
主要变化:
- 安装
@xyflow/react - Prisma 新增
Node/Connection/NodeType - 创建 workflow 时自动创建初始 node
getOne查询返回 React Flow 需要的nodes/edges- 新增 React Flow editor
- 新增
InitialNode - 新增
PlaceholderNode - 新增
BaseNode - 新增
nodeComponents - 引入 React Flow 全局样式
核心学习点:
- 图编辑器的数据建模
- 数据库模型到前端 UI 模型的转换
- React Flow 受控状态
- 自定义节点注册
- 节点组件抽象
- 前端临时状态和后端持久化的区别
二、013 值得学习的地方
1. Next.js 页面结构:Server Component 负责页面壳,Client Component 负责交互
提交前 editor 页面只是:
return <p>Workflow id: {workflowId}</p>;提交后变成:
return (
<HydrateClient>
<EditorHeader workflowId={workflowId} />
<ErrorBoundary fallback={<EditorError />}>
<Suspense fallback={<EditorLoading />}>
<Editor workflowId={workflowId} />
</Suspense>
</ErrorBoundary>
</HydrateClient>
);这个结构很典型。
实现逻辑:
page.tsx是 Server Component。- 它先调用
requireAuth()做服务端鉴权。 - 它从 URL params 里拿到
workflowId。 - 它不直接写复杂交互,而是把
workflowId传给客户端组件。 HydrateClient负责把服务端预取的数据交给 React Query 客户端。Suspense负责处理加载中状态。ErrorBoundary负责处理渲染或请求错误。
这体现了 App Router 的常见分层:
page.tsx
-> 鉴权
-> 解析参数
-> 服务端预取
-> 组合页面结构
client component
-> 用户交互
-> useState
-> useEffect
-> mutation
-> UI 状态注意点:
如果用了 HydrateClient,通常还应该配合 prefetchWorkflow(workflowId)。否则客户端组件里的 useSuspenseQuery 仍然可能在浏览器端再请求一次。
正确思路是:
await requireAuth();
const { workflowId } = await params;
prefetchWorkflow(workflowId);
return (
<HydrateClient>
...
</HydrateClient>
);2. Suspense + ErrorBoundary 的职责
代码:
<ErrorBoundary fallback={<EditorError />}>
<Suspense fallback={<EditorLoading />}>
<Editor workflowId={workflowId} />
</Suspense>
</ErrorBoundary>职责拆分:
Suspense处理“数据还没回来”ErrorBoundary处理“数据请求失败或组件渲染失败”
useSuspenseQuery 在数据未完成时会 suspend,所以外层必须有 Suspense。
如果请求失败,错误会被 ErrorBoundary 捕获。
面向用户的价值:
- 加载中有稳定的 loading UI
- 出错有 fallback UI
- 业务组件不用写一堆
if (isLoading)/if (error)
面向代码结构的价值:
Editor可以专注渲染成功状态- loading / error 状态被提升到页面结构层处理
3. useSuspenseWorkflow:把详情查询封装成 hook
代码:
export const useSuspenseWorkflow = (id: string) => {
const trpc = useTRPC();
return useSuspenseQuery(trpc.workflows.getOne.queryOptions({ id }));
};实现逻辑:
useTRPC()拿到类型安全的 tRPC client。trpc.workflows.getOne.queryOptions({ id })生成 React Query 配置。useSuspenseQuery执行查询。- 调用方拿到的
data是类型推导后的 workflow 数据。
封装的价值:
- 避免每个组件重复写 tRPC queryOptions
- 让调用方更语义化:
useSuspenseWorkflow(workflowId) - 后续修改缓存策略或 query 配置时,只改 hook
4. inline edit:远端数据和本地编辑状态的同步
EditorNameInput 是 013 里非常值得学习的组件。
核心代码:
const { data: workflow } = useSuspenseWorkflow(workflowId);
const updateWorkflow = useUpdateWorkflowName();
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(workflow.name);
const inputRef = useRef<HTMLInputElement>(null);这里有两份状态:
workflow.name -> 远端状态,来自接口和 React Query cache
name -> 本地编辑状态,用户输入时实时变化为什么不能只用 workflow.name?
因为用户输入时,每敲一个字都只是本地临时输入,还没有保存到服务器。
如果直接改远端数据,会导致:
- 取消编辑困难
- 保存失败回滚困难
- 和 React Query cache 混在一起
所以正确做法是:
进入编辑
-> 用 workflow.name 初始化 name
输入中
-> 只更新 name
保存成功
-> mutation 更新服务端
-> invalidate query
-> workflow.name 重新同步
保存失败
-> name 回滚成 workflow.name5. onChange:受控输入框
代码:
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>这是 React 的受控组件。
实现逻辑:
用户输入
-> 触发 onChange
-> e.target.value 是当前 input 内容
-> setName 更新 React state
-> React 重新渲染
-> value={name} 把最新状态显示到 input如果只有:
value={name}没有:
onChange={(e) => setName(e.target.value)}输入框会变成只读,因为它的显示值完全由 React state 控制,但用户输入没有更新 state 的路径。
6. 自动 focus / select 的实现
代码:
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);实现逻辑:
useRef保存 input 的 DOM 引用。- 用户点击名称,
setIsEditing(true)。 - React 重新渲染,文本变成 input。
useEffect在 DOM 更新后执行。- 调用
focus()聚焦。 - 调用
select()选中文本。
为什么用 useEffect?
因为 input 必须先渲染到 DOM 里,inputRef.current 才存在。
7. Enter 保存,Escape 取消
代码:
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSave();
} else if (e.key === "Escape") {
setName(workflow.name);
setIsEditing(false);
}
};实现逻辑:
Enter:提交当前本地状态nameEscape:放弃本地状态,回滚到workflow.name
这里体现的是交互完整性。
一个合格的 inline edit 组件通常应该支持:
- 点击进入编辑
- 自动 focus
- Enter 保存
- Escape 取消
- blur 保存或取消
- mutation pending 时禁用 input
- 保存失败回滚
8. mutation 成功后的缓存失效
代码:
export const useUpdateWorkflowName = () => {
const queryClient = useQueryClient();
const trpc = useTRPC();
return useMutation(
trpc.workflows.updateName.mutationOptions({
onSuccess: (data) => {
toast.success(`Workflow "${data.name}" updated`);
queryClient.invalidateQueries(trpc.workflows.getMany.queryOptions({}));
queryClient.invalidateQueries(
trpc.workflows.getOne.queryOptions({ id: data.id }),
);
},
onError: (error) => {
toast.error(`Failed to update workflow: ${error.message}`);
},
}),
);
};实现逻辑:
修改 workflow 名称后,受影响的数据不止一个地方:
- workflow 列表页:显示 workflow name
- editor header:显示当前 workflow name
所以成功后需要让相关 query 失效:
updateName mutation 成功
-> invalidate getMany
-> invalidate getOne(id)
-> React Query 自动重新请求
-> UI 使用最新数据这是 React Query 的核心思想:
mutation 负责改数据
query invalidation 负责让读取数据的地方更新9. findUniqueOrThrow 比 findUnique 更适合详情页
提交中:
return prisma.workflow.findUniqueOrThrow({
where: { id: input.id, userId: ctx.auth.user.id },
});区别:
findUnique()找不到时返回 null。
findUniqueOrThrow()找不到时直接抛异常。
详情页通常适合 findUniqueOrThrow,因为:
- 这个页面必须有一个存在的 workflow
- 不存在就是异常状态
- 可以交给 tRPC / ErrorBoundary 统一处理
三、014 值得学习的地方
1. 从普通 CRUD 升级成图编辑器的数据模型
Prisma schema 中新增:
model Workflow {
id String @id @default(cuid())
name String
nodes Node[]
connections Connection[]
}
enum NodeType {
INITIAL
}
model Node {
id String @id @default(cuid())
workflowId String
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
name String
type NodeType
position Json
data Json @default("{}")
outputConnections Connection[] @relation("FromNode")
inputConnections Connection[] @relation("ToNode")
}
model Connection {
id String @id @default(cuid())
workflowId String
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
fromNodeId String
fromNode Node @relation("FromNode", fields: [fromNodeId], references: [id], onDelete: Cascade)
toNodeId String
toNode Node @relation("ToNode", fields: [toNodeId], references: [id], onDelete: Cascade)
fromOutput String @default("main")
toInput String @default("main")
@@unique([fromNodeId, toNodeId, fromOutput, toInput])
}实现逻辑:
Workflow
-> 一张流程图
Node
-> 图里的节点
Connection
-> 节点之间的连线为什么 position 用 Json?
因为 React Flow 的 position 是:
{ x: number; y: number }直接存 JSON 比拆成 x Int / y Int 更灵活。
为什么 data 用 Json?
不同类型节点的数据结构可能不同。
例如以后可能有:
OpenAINode.data = { model: "gpt-4.1", prompt: "..." }
SlackNode.data = { channelId: "...", message: "..." }
ConditionNode.data = { expression: "..." }用 Json 可以让不同节点类型拥有不同配置。
为什么 Connection 有 fromOutput 和 toInput?
因为一个节点未来可能有多个输出口或输入口。
例如:
ConditionNode
true output
false output如果只存 fromNodeId / toNodeId,无法表达从哪个 handle 连出去。
为什么加唯一约束?
@@unique([fromNodeId, toNodeId, fromOutput, toInput])防止同一个输出口到同一个输入口重复连线。
2. 创建 workflow 时自动创建初始节点
代码:
return prisma.workflow.create({
data: {
name: generateSlug(3),
userId: ctx.auth.user.id,
nodes: {
create: {
type: NodeType.INITIAL,
position: { x: 0, y: 0 },
name: NodeType.INITIAL,
},
},
},
});实现逻辑:
用户点击新建 workflow
-> 创建 Workflow
-> 同时通过 nested create 创建一个 INITIAL node
-> 进入 editor 时画布至少有一个初始节点这个设计比“前端发现没有节点再创建”更稳定。
原因:
- 初始节点属于业务规则,应该由后端保证
- 前端只负责展示
- 避免多个客户端同时初始化造成重复节点
3. API 返回值面向前端组件,而不是直接暴露数据库模型
getOne 做了数据转换:
const workflow = await prisma.workflow.findUniqueOrThrow({
where: { id: input.id, userId: ctx.auth.user.id },
include: {
nodes: true,
connections: true,
},
});然后把 Prisma 的 Node 转成 React Flow 的 Node:
const nodes: Node[] = workflow.nodes.map((node) => ({
id: node.id,
type: node.type,
position: node.position as { x: number; y: number },
data: (node.data as Record<string, unknown>) || {},
}));把 Prisma 的 Connection 转成 React Flow 的 Edge:
const edges: Edge[] = workflow.connections.map((connection) => ({
id: connection.id,
source: connection.fromNodeId,
target: connection.toNodeId,
sourceHandle: connection.fromOutput,
targetHandle: connection.toInput,
}));最终返回:
return {
id: workflow.id,
name: workflow.name,
nodes,
edges,
};这是典型 adapter / mapper 思路。
数据库模型:
Connection.fromNodeId
Connection.toNodeId
Connection.fromOutput
Connection.toInputReact Flow 模型:
Edge.source
Edge.target
Edge.sourceHandle
Edge.targetHandle两者不一样,所以中间必须转换。
学习重点:
不要让前端组件库直接污染数据库设计。
数据库应该表达业务关系。
API 应该把业务关系转换成前端当前页面最容易使用的数据格式。
4. React Flow 样式必须全局引入
代码:
import "@xyflow/react/dist/style.css";这个必须放在全局入口附近,例如 src/app/layout.tsx。
原因:
React Flow 的节点、边、控制器、MiniMap 都依赖它的全局 CSS。
没有它会报:
[React Flow]: It seems that you haven't loaded the styles.5. 自定义节点注册:nodeComponents
代码:
export const nodeComponents = {
[NodeType.INITIAL]: InitialNode,
} as const satisfies NodeTypes;实现逻辑:
React Flow 的 node 数据里有:
{
id: "node-id",
type: "INITIAL",
position: { x: 0, y: 0 },
data: {}
}React Flow 渲染节点时,会根据 type 找对应组件。
node.type === "INITIAL"
-> nodeComponents["INITIAL"]
-> InitialNode为什么用:
[NodeType.INITIAL]而不是直接写:
INITIAL因为 NodeType.INITIAL 来自 Prisma enum,能避免手写字符串出错。
as const satisfies NodeTypes 的含义:
as const让对象类型保持精确。
satisfies NodeTypes让 TypeScript 检查这个对象是否符合 React Flow 的 NodeTypes 要求。
它比直接写下面这种更保留精确信息:
const nodeComponents: NodeTypes = { ... };6. InitialNode:具体节点组件
代码:
export const InitialNode = memo((props: NodeProps) => {
return (
<PlaceholderNode {...props} onClick={() => {}}>
<div className="cursor-pointer flex items-center justify-center">
<PlusIcon className="size-4" />
</div>
</PlaceholderNode>
);
});实现逻辑:
React Flow 渲染 INITIAL node
-> 调用 InitialNode
-> React Flow 传入 NodeProps
-> InitialNode 把 props 透传给 PlaceholderNode
-> PlaceholderNode 渲染节点外壳
-> 内部显示 PlusIconmemo 的作用:
React Flow 画布上可能有很多节点。
节点拖动、缩放、连线时,父组件可能频繁重新渲染。
memo 可以在 props 没变化时跳过重复渲染。
{...props} 的作用:
把 React Flow 传进来的节点参数继续传给 PlaceholderNode。
例如:
id
data
selected
dragging
isConnectable7. PlaceholderNode:节点外壳,不是 React Flow 核心 API
PlaceholderNode 是项目本地组件,参考的是 React Flow UI 官方示例。
它不是这样引入的:
import { PlaceholderNode } from "@xyflow/react";而是本地文件:
import { PlaceholderNode } from "./react-flow/placeholder-node";内部核心是:
<BaseNode onClick={onClick}>
{children}
<Handle type="target" position={Position.Top} />
<Handle type="source" position={Position.Bottom} />
</BaseNode>实现逻辑:
PlaceholderNode
-> 提供虚线边框、背景、hover 样式
-> 渲染 children
-> 放置 React Flow HandleHandle 才是 React Flow 官方核心组件。
PlaceholderNode 是你项目封装出来的节点外壳。
8. React Flow editor 的受控状态模式
代码:
const [nodes, setNodes] = useState<Node[]>(workflow.nodes);
const [edges, setEdges] = useState<Edge[]>(workflow.edges);然后传给 React Flow:
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeComponents}
fitView
>这叫受控状态。
含义:
React Flow 显示什么节点,由 nodes state 决定
React Flow 显示什么连线,由 edges state 决定
React Flow 不直接拥有最终数据9. 拖动节点的实现原理
代码:
const onNodesChange = useCallback(
(changes: NodeChange[]) =>
setNodes((nodesSnapshot) => applyNodeChanges(changes, nodesSnapshot)),
[],
);当用户拖动节点时,React Flow 会产生 changes。
例如:
[
{
id: "node-1",
type: "position",
position: { x: 100, y: 200 }
}
]React Flow 不会直接替你永久修改 nodes。
它会调用:
onNodesChange(changes)你再用:
applyNodeChanges(changes, nodesSnapshot)得到新的 nodes。
完整流程:
用户拖动节点
-> React Flow 计算 position change
-> 调用 onNodesChange(changes)
-> setNodes
-> applyNodeChanges 把 change 合并进旧 nodes
-> React 重新渲染
-> React Flow 看到新的 nodes
-> 节点位置更新如果不写 onNodesChange:
节点拖动产生了事件
但 nodes state 没有变
React Flow 下一次渲染仍然拿旧 nodes
节点可能回到旧位置或行为异常10. 连线的实现原理
代码:
const onConnect = useCallback(
(params: Connection) =>
setEdges((edgesSnapshot) => addEdge(params, edgesSnapshot)),
[],
);当用户从一个 handle 拖到另一个 handle,React Flow 会生成 connection params。
例如:
{
source: "node-1",
target: "node-2",
sourceHandle: "main",
targetHandle: "main"
}React Flow 调用:
onConnect(params)然后你用:
addEdge(params, edgesSnapshot)把 connection 转成 edge 并加入 edges state。
完整流程:
用户连线
-> React Flow 生成 Connection
-> 调用 onConnect
-> addEdge 生成新的 Edge
-> setEdges 更新 state
-> React 重新渲染
-> React Flow 显示新边如果不写 onConnect:
用户拖线时能看到临时线
松手后 edges state 没有新增
连线不会真正保留下来11. 删除或选择 edge 的实现原理
代码:
const onEdgesChange = useCallback(
(changes: EdgeChange[]) =>
setEdges((edgesSnapshot) => applyEdgeChanges(changes, edgesSnapshot)),
[],
);onEdgesChange 处理已有边的变化。
常见变化:
- 选中 edge
- 取消选中 edge
- 删除 edge
实现逻辑和 nodes 类似:
旧 edges + edge changes -> 新 edges12. 当前只是前端临时状态,还没有持久化
当前代码只做了:
setNodes(...)
setEdges(...)这只更新浏览器内存里的 React state。
它没有调用后端接口。
所以现在流程是:
页面加载
-> 从数据库读取 workflow.nodes / workflow.edges
-> 放进 useState
-> 用户拖动节点 / 新增连线
-> setNodes / setEdges
-> UI 看起来变化了
-> 刷新页面
-> 重新从数据库读取旧数据
-> 刚才的变化丢失要真正保存,需要新增 mutation。
例如:
workflows.updateGraph前端保存:
updateGraph.mutate({
workflowId,
nodes,
edges,
});后端保存:
nodes -> Node 表
edges -> Connection 表完整流程:
用户拖动 / 连线
-> 前端 state 更新
用户点击 Save
-> mutation 把 nodes / edges 发给服务端
服务端写入数据库
刷新页面
-> 读取保存后的 nodes / edges这也是 workflow editor 下一步最重要的功能。
四、两个提交里的代码细节提醒
1. 服务端文件里引用 React Flow 类型要用 import type
在 tRPC router 里如果只是用类型:
import type { Node, Edge } from "@xyflow/react";不要写:
import { Node, Edge } from "@xyflow/react";原因:
Node 和 Edge 是 TypeScript 类型,不是运行时变量。
用普通 import 可能让服务端 bundle 引入前端库,增加编译成本,也可能造成运行时问题。
2. node.position as { x: number; y: number } 是类型断言,不是运行时校验
代码:
position: node.position as { x: number; y: number },这只是告诉 TypeScript:
相信我,这个 Json 是 { x, y }但如果数据库里真的存了错误数据:
{ "left": 100 }TypeScript 不会阻止运行时错误。
更严谨的做法是用 Zod 或手写 guard 校验:
const positionSchema = z.object({
x: z.number(),
y: z.number(),
});3. useState(workflow.nodes) 只在首次渲染时初始化
代码:
const [nodes, setNodes] = useState<Node[]>(workflow.nodes);注意:
useState 的初始值只在组件第一次渲染时使用。
如果之后 workflow.nodes 因为重新请求发生变化,nodes 不会自动同步。
如果需要同步,应该加:
useEffect(() => {
setNodes(workflow.nodes);
setEdges(workflow.edges);
}, [workflow.nodes, workflow.edges]);但这也有风险:
如果用户正在编辑,远端数据突然同步过来,可能覆盖本地未保存修改。
所以真实编辑器里一般要设计:
- dirty 状态
- 保存状态
- 冲突处理
- 是否允许远端数据覆盖本地编辑
4. useCallback 的价值
代码:
const onNodesChange = useCallback(..., []);
const onEdgesChange = useCallback(..., []);
const onConnect = useCallback(..., []);作用:
保持回调函数引用稳定。
在 React Flow 这种复杂组件里,传入的 handler 如果每次 render 都变,会导致内部重新绑定或触发更多渲染。
useCallback 不是所有地方都必须用,但传给复杂第三方组件的 handler 通常值得稳定下来。
5. fitView 的作用
代码:
<ReactFlow fitView>作用:
首次渲染时自动调整视口,让所有节点尽量出现在可视范围内。
如果没有它,节点可能在画布外,用户会以为没有渲染。
6. Panel 是 React Flow 提供的画布内定位容器
代码:
<Panel position="top-right">
<AddNodeButton />
</Panel>作用:
把按钮固定在 React Flow 画布内部的右上角。
它比自己写 absolute 定位更适合 React Flow,因为它会跟画布容器规则配合。
五、前端面试可能怎么问,如何回答
Q1: Next.js App Router 里 Server Component 和 Client Component 怎么分工?
回答:
Server Component 适合处理不需要浏览器交互的逻辑,例如鉴权、读取 params、服务端预取数据、组合页面结构。Client Component 适合处理浏览器交互,例如 useState、useEffect、点击、输入、React Query hook、画布拖拽等。
在这个项目里,page.tsx 做 requireAuth() 和读取 workflowId,然后把 workflowId 传给 EditorHeader 和 Editor 这些 client component。
Q2: 为什么 useSuspenseQuery 外面要包 Suspense?
回答:
useSuspenseQuery 在数据还没返回时会 suspend,也就是把渲染暂停并交给最近的 Suspense fallback。所以外层必须有 Suspense 来显示 loading UI。请求失败则交给 ErrorBoundary 处理。
Q3: HydrateClient 是干什么的?
回答:
HydrateClient 用于把服务端预取的 React Query cache 脱水后传给客户端,客户端 hydrate 后可以直接使用这些缓存数据,避免首屏重复请求,并改善 SSR / RSC 下的数据加载体验。
但它需要配合服务端的 prefetch 使用。只包 HydrateClient,不预取数据,客户端还是会自己请求。
Q4: inline edit 为什么要单独维护一个 name state?
回答:
因为 workflow.name 是远端数据,来自 React Query cache;用户输入时是本地临时状态,还不能立刻认为服务器已经保存。单独维护 name 可以支持输入、取消、保存失败回滚等交互。
流程是:用 workflow.name 初始化 name,输入时更新 name,保存成功后 invalidate query,保存失败后把 name 恢复成 workflow.name。
Q5: mutation 成功后为什么要 invalidate 多个 query?
回答:
因为一次数据修改可能影响多个 UI 位置。修改 workflow name 后,详情页的 getOne 和列表页的 getMany 都可能显示这个 name,所以两个 query 都应该失效,确保界面最终一致。
Q6: findUnique 和 findUniqueOrThrow 怎么选?
回答:
如果找不到数据是正常业务分支,可以用 findUnique 并处理 null。如果这个接口语义上要求数据必须存在,例如详情页获取某个 workflow,找不到就是异常,则用 findUniqueOrThrow 更合适,可以交给统一错误处理。
Q7: React Flow 的 nodes / edges 为什么说是受控状态?
回答:
因为 React Flow 显示的节点和边完全来自传入的 nodes 和 edges props。用户拖动节点或新增连线时,React Flow 不会自动永久修改应用状态,而是通过 onNodesChange、onEdgesChange、onConnect 把变化通知给应用。应用再调用 setNodes / setEdges 更新 state。
Q8: applyNodeChanges 做了什么?
回答:
它把 React Flow 产生的节点变化列表应用到当前 nodes 数组上。例如拖动节点会产生 position change,删除节点会产生 remove change。applyNodeChanges(changes, nodes) 会返回新的 nodes 数组。
本质是:
旧 nodes + changes -> 新 nodesQ9: onConnect 和 addEdge 的关系是什么?
回答:
onConnect 是用户完成连线时 React Flow 触发的事件,它提供 connection 参数,例如 source、target、sourceHandle、targetHandle。addEdge 是 React Flow 提供的工具函数,用来把 connection 转成 edge 并加入当前 edges 数组。
流程:
用户连线
-> onConnect(params)
-> addEdge(params, edges)
-> setEdgesQ10: 为什么拖动节点后刷新页面位置会丢?
回答:
因为当前实现只更新了前端 React state,没有把 nodes / edges 保存到数据库。刷新页面后会重新从数据库读取旧数据,所以本地临时修改会丢失。
需要新增保存接口,例如 workflows.updateGraph,在用户点击 Save 时把 nodes / edges 持久化。
Q11: 数据库里的 Connection 为什么要转换成 React Flow 的 Edge?
回答:
数据库模型表达的是业务关系,例如 fromNodeId、toNodeId、fromOutput、toInput。React Flow 需要的是 UI 渲染模型,例如 source、target、sourceHandle、targetHandle。两者字段不同,所以 API 层要做转换。
这可以让数据库保持业务语义,同时让前端拿到最适合渲染的数据。
Q12: 为什么 Node 的 data 和 position 用 Json?
回答:
position 对应 React Flow 的 { x, y },用 Json 存储简单灵活。data 是不同类型节点的配置,不同节点结构可能完全不同,例如 Slack 节点、OpenAI 节点、条件节点,所以用 Json 可以支持扩展。
缺点是数据库层类型约束弱,需要在应用层用 Zod 或类似工具做校验。
Q13: nodeTypes 是干什么的?
回答:
nodeTypes 是 React Flow 的自定义节点注册表。React Flow 根据每个 node 的 type 字段找到对应 React 组件。例如 type: "INITIAL" 会映射到 InitialNode。
代码:
const nodeComponents = {
[NodeType.INITIAL]: InitialNode,
} satisfies NodeTypes;Q14: PlaceholderNode 是 React Flow 官方组件吗?
回答:
不是 @xyflow/react 核心包直接导出的组件。它是项目本地组件,参考 React Flow UI 官方示例实现。它内部使用了 React Flow 官方的 Handle 和 Position。
React Flow 核心组件是:
ReactFlow
Handle
Position
Background
Controls
MiniMap
PanelQ15: 为什么服务端代码里应该用 import type { Node, Edge }?
回答:
因为 Node 和 Edge 只是 TypeScript 类型,不需要运行时引入。如果在服务端 router 里用普通 import,可能会把前端库打进 server bundle,增加编译成本,甚至带来运行时问题。使用 import type 可以保证编译后移除该导入。
Q16: useState(workflow.nodes) 会不会随着 workflow 更新自动更新?
回答:
不会。useState 的初始值只在组件首次渲染时使用。后续 workflow.nodes 变了,nodes state 不会自动变。需要用 useEffect 手动同步,但在编辑器场景中要小心,避免覆盖用户未保存的本地修改。
Q17: React Flow 里为什么常用 memo 包节点组件?
回答:
图编辑器里节点数量可能很多,拖动、缩放、选择时渲染频繁。memo 可以在 props 没有变化时跳过节点组件重渲染,降低性能开销。
Q18: 如何把当前 editor 从临时状态升级到可保存?
回答:
需要新增一个保存 mutation,例如:
workflows.updateGraph前端保存:
saveWorkflow.mutate({
workflowId,
nodes,
edges,
});后端做三件事:
- 校验 workflow 属于当前用户。
- 把 React Flow nodes 转成数据库 Node。
- 把 React Flow edges 转成数据库 Connection。
保存成功后 invalidate workflows.getOne({ id })。
六、建议重点掌握的顺序
如果按学习收益排序,建议这样学:
HydrateClient + Suspense + ErrorBoundary页面结构useSuspenseWorkflow和 tRPC query 封装- inline edit 的本地状态和远端状态同步
- mutation 成功后的 query invalidation
- Prisma 的 Workflow / Node / Connection 图模型
- Prisma 数据到 React Flow nodes / edges 的转换
- React Flow 的受控状态模型
onNodesChange/onEdgesChange/onConnectnodeTypes自定义节点注册- 前端临时状态如何升级为数据库持久化
七、一句话总结
013 的核心是:
把 editor 页面从静态占位,升级成一个现代 Next.js 数据页面。
重点是页面结构、Suspense、ErrorBoundary、inline edit、mutation 和缓存失效。014 的核心是:
把 workflow 从普通 CRUD 数据,升级成 React Flow 可视化图编辑器。
重点是图数据建模、nodes/edges 映射、受控状态、自定义节点和未来持久化设计。