新闻详情

新闻详情

首页 / 资讯中心 / 详情

craft.js NodeTree 详解:以节点树结构表示 React 元素层级

发布时间:2026/9/25 6:01:34来源:尧图网络
craft.js NodeTree 详解:以节点树结构表示 React 元素层级
前端【免费下载链接】craft.js A React Framework for building extensible drag and drop page editors项目地址https://gitcode.com/gh_mirrors/cr/craft.js点击查看免费下载NodeTree 是 craft.js 中用于表示一棵 React 元素树Element Tree的轻量级数据结构它以根节点 ID 扁平化节点表的形式完整描述任意 JSX 片段在编辑器内部的层级关系。本文将以 NodeTree.md 文档为主线结合craftjs/core的源码实现讲解 NodeTree 的定义、创建流程、在编辑器状态中的增删用法以及它如何在拖放、初始渲染与序列化等核心流程中发挥作用。读完本文你将理解 NodeTree 与 Node 的关系并能在自己的 craft.js 应用中熟练生成、解析和注入 NodeTree。NodeTree 是什么NodeTree 是 craft.js 内部用于描述一组相互关联的节点的数据结构。官方文档将其定义为A useful data structure to represent the tree of a React Element as Nodes.即把一个 React 元素及其子树表示为一组 Node 的树形结构。与 DOM 树或 React 虚拟 DOM 不同NodeTree 不关心元素的渲染方式只关心编辑状态下的结构信息——每个元素是什么类型、携带什么 props、它的子节点是谁、父节点是谁。从源码看NodeTree 的接口定义非常精简见 interfaces/nodes.ts/** * A NodeTree is an internal data structure for CRUD operations that involve * more than a single node. * * For example, when we drop a component we use a tree because we * need to drop more than a single component. */ export interface NodeTree { rootNodeId: NodeId; nodes: Nodes; // Nodes RecordNodeId, Node }源码注释明确点出了 NodeTree 的定位它是涉及多个节点的 CRUD 操作所使用的内部数据结构。例如当用户从工具箱拖放一个组件进入画布时被拖入的可能是一个包含若干子节点的完整子树此时就必须用一棵 NodeTree 来一次性描述这组节点而不是逐个添加。属性一览属性类型说明rootNodeIdNodeIdstring树中根节点root Node的 idnodesRecordNodeId, Node以节点 id 为键、Node 对象为值的扁平映射表需要特别强调的是nodes是一个扁平的键值映射而不是嵌套结构。树的层级关系并不靠对象嵌套来表达而是通过每个 Node 的data.nodes子节点 id 数组与data.parent父节点 id这两个字段互相引用。因此遍历一棵 NodeTree 实际上是在遍历这张映射表并借助parent/nodes/linkedNodes字段在节点之间跳转。回顾Node 的完整形态要理解 NodeTree先要理解它的组成单元 Node。Node 在 interfaces/nodes.ts 中定义如下export type Node { id: NodeId; data: NodeData; info: Recordstring, any; events: RecordNodeEventTypes, boolean; dom: HTMLElement | null; related: Recordstring, React.ElementType; rules: NodeRules; _hydrationTimestamp: number; };其中与树结构直接相关的是data见 NodeDataexport type NodeData { props: Recordstring, any; // 用户元素的 props type: string | React.ElementType;// 元素类型HTML 标签字符串或 React 组件 name: string; // 元素名称 displayName: string; // 展示名默认与 name 相同 isCanvas: boolean; // 是否为 Canvas 节点 parent: NodeId | null; // 父节点 id根节点为 null linkedNodes: Recordstring, NodeId; // 用户组件内部通过 Element id... 声明的关联节点 nodes: NodeId[]; // 子节点 id 数组child nodes hidden: boolean; custom?: any; // 自定义属性 _childCanvas?: Recordstring, NodeId; // 已废弃请使用 linkedNodes };两个字段决定了树的形状data.nodes: NodeId[]当前节点的直接子节点 id 列表。渲染时这些子节点会作为父节点用户元素的childrenprop 传入可参考 Node.md 中 Child Nodes 一节。data.parent: NodeId | null当前节点的父节点 id没有父节点的节点即为顶层节点root 或 linked node。除此之外Node 还携带eventsselected / hovered / dragged 三种编辑事件标记、rulescanDrag / canDrop / canMoveIn / canMoveOut 四种拖放规则、related共享当前节点上下文的关联组件等编辑期信息完整字段说明可查阅 Node.md。文档示例从 JSX 到 NodeTree原文档给出了最直观的示例——一段简单的 JSX 会被转换成什么形态div h2Hello/h2 h2World/h2 /div // The NodeTree of the div is: { rootNodeId: node-a, nodes: { node-a : { data: { type: div, nodes: [node-b, node-c] } }, node-b : { data: { type: h2, props: { children: Hello } } }, node-c : { data: { type: h2, props: { children: World } } } } }这个示例清晰地展示了 NodeTree 的三个特征根节点由rootNodeId指出整棵树的入口是node-a对应最外层的div而不是靠嵌套对象表示根。层级关系编码在data.nodes中node-a的data.nodes是[node-b, node-c]说明h2Hello/h2与h2World/h2是它的两个子节点渲染时按数组顺序排列。叶子节点携带props.childrenh2Hello/h2的文本内容被保存在props.children中值为字符串Hello。需要指出的是示例为了聚焦结构做了精简。在实际由createNode生成的 Node 中每个节点还会带id、name、displayName、isCanvas: false、hidden: false、parent、custom: {}、linkedNodes: {}等字段parent也会被正确回填node-b与node-c的parent均为node-a。Node 的完整结构示例可对照 Node.md 中的 Examples 部分。源码视角NodeTree 是如何构建的理解了形态之后再看 craft.js 内部如何在运行时把一段 JSX 转换成一棵 NodeTree。核心入口是query.parseReactElement(...).toNodeTree()实现位于 query.tsxparseReactElement: (reactElement: React.ReactElementany) ({ toNodeTree( normalize?: (node: Node, jsx: React.ReactElementany) void ): NodeTree { let node parseNodeFromJSX(reactElement, (node, jsx) { const name resolveComponent(state.options.resolver, node.data.type); node.data.displayName node.data.displayName || name; node.data.name name; if (normalize) { normalize(node, jsx); } }); let childrenNodes: NodeTree[] []; if (reactElement.props reactElement.props.children) { childrenNodes React.Children.toArray( reactElement.props.children ).reduceNodeTree[]((accum, child: any) { if (React.isValidElement(child)) { accum.push(_().parseReactElement(child).toNodeTree(normalize)); } return accum; }, []); } return mergeTrees(node, childrenNodes); }, }),构建流程可以概括为三个步骤解析根元素parseNodeFromJSX调用createNode见 parseNodeFromJSX.tsx把根 JSX 元素变成 Node并通过resolveComponent结合编辑器 resolver 解析组件名称。递归解析子元素对根元素的children逐个调用parseReactElement(child).toNodeTree(normalize)得到若干子树。合并成树调用mergeTrees把根节点与所有子树合并为一棵完整的 NodeTree。合并逻辑位于 mergeTrees.tsxexport const mergeTrees ( rootNode: Node, childrenNodes: NodeTree[] ): NodeTree ({ rootNodeId: rootNode.id, nodes: mergeNodes(rootNode, childrenNodes), });mergeNodes内部做的事很关键它把各子树的rootNodeId收集起来写入根节点的data.nodes然后将所有子树的节点扁平化合并进同一张nodes映射并为每个子树根节点设置parent: rootNode.id从而把父子的双向引用补齐const mergeNodes (rootNode: Node, childrenNodes: NodeTree[]) { if (childrenNodes.length 1) { return { [rootNode.id]: rootNode }; } const nodes childrenNodes.map(({ rootNodeId }) rootNodeId); const nodeWithChildren { ...rootNode, data: { ...rootNode.data, nodes } }; const rootNodes { [rootNode.id]: nodeWithChildren }; return childrenNodes.reduce((accum, tree) { const currentNode tree.nodes[tree.rootNodeId]; return { ...accum, ...tree.nodes, [currentNode.id]: { ...currentNode, data: { ...currentNode.data, parent: rootNode.id }, }, }; }, rootNodes); };这也解释了为什么 NodeTree 中每个节点都有唯一的idcreateNode会通过getRandomId为每个新节点生成随机 id见 createNode.ts整张扁平表正是以这些 id 为键。将 NodeTree 注入编辑器addNodeTree构建好 NodeTree 之后通常需要把它挂到编辑器状态中对应的 action 是actions.addNodeTree(tree, parentId?, index?)定义于 actions.ts/** * Add a NodeTree to the editor * * param tree * param parentId * param index */ addNodeTree(tree: NodeTree, parentId?: NodeId, index?: number) { addNodeTreeToParent(tree, parentId, { type: child, index }); },其底层实现addNodeTreeToParentactions.ts做了以下工作递归写入节点从tree.rootNodeId出发沿data.nodes与data.linkedNodes深度遍历把每个节点连同更新后的parent写入state.nodes映射。校验 resolver对非字符串类型的节点即 React 组件校验其名称已注册在编辑器的 resolver 中否则抛出ERROR_NOT_IN_RESOLVER。挂接父节点若提供了parentId根据挂接方式把根节点 id 追加/插入到父节点的data.nodes数组子节点方式或写入父节点的data.linkedNodes关联节点方式由内部Element /使用。处理根节点特例当tree.rootNodeId为编辑器约定的ROOT_NODE时直接返回不重复挂接。首次渲染Frame 中的应用一个最常见的注入场景是Frame首次加载。在 Frame.tsx 中当未提供序列化数据时craft.js 会把 Frame 的子元素转换成一棵 NodeTree 并注入编辑器} else if (children) { const rootNode React.Children.only(children) as React.ReactElement; const node query.parseReactElement(rootNode).toNodeTree((node, jsx) { if (jsx rootNode) { node.id ROOT_NODE; // 顶层元素固定为 ROOT_NODE } return node; }); actions.history.ignore().addNodeTree(node); }这里可以看到两个实用细节通过toNodeTree的normalize回调可以把顶层元素强制设置为ROOT_NODEid这是整棵编辑树的根。通过actions.history.ignore()包裹addNodeTree可以让初始注入不进入历史记录避免用户撤销操作时把整个初始页面撤掉。拖放场景DefaultEventHandlers 中的应用NodeTree 的另一个核心消费方是拖放流程。DefaultEventHandlers.ts 在用户从工具箱拖入新组件时会执行类似下面的逻辑tree store.query.parseReactElement(userElement).toNodeTree(); // ... store.actions.addNodeTree(tree, target, index);先由parseReactElement(...).toNodeTree()把被拖入的 JSX 元素可能包含多层子元素构建成 NodeTree再连同放置目标与下标一起交给addNodeTree写入编辑器。这正是源码注释中所说的drop a component we use a tree because we need to drop more than a single component的典型体现——即使一次只拖入一个组件该组件内部可能还包含Element声明或嵌套子结构NodeTree 保证整个子树能够一次性、原子地挂入状态树。反向操作从编辑器状态导出 NodeTree除了JSX → NodeTreecraft.js 还支持反向的编辑器状态 → NodeTree即query.node(id).toNodeTree()。该方法定义于 NodeHelpers.tstoNodeTree(includeOnly?: linkedNodes | childNodes) { const nodes [id, ...this.descendants(true, includeOnly)].reduce( (accum, descendantId) { accum[descendantId] nodeHelpers(descendantId).get(); return accum; }, {} ); return { rootNodeId: id, nodes, }; },它的实现思路是以当前节点 id 为rootNodeId通过descendants(true, includeOnly)深度遍历得到当前节点及其所有后代节点 idincludeOnly可限定只包含linkedNodes或只包含childNodes将这些节点的完整对象收集进nodes映射返回 NodeTree。这在实际开发中很有用例如在实现自定义的组件复制、层级局部导出等功能时可以先定位子树根节点再用toNodeTree()取出一整棵子树随后通过addNodeTree把它挂到新的父节点下。该行为在测试中也有对应验证例如 NodeHelpers.test.ts 中通过testHelper(canvas-node-reject-dnd).toNodeTree(descendantType)构造并断言子树结构query.test.tsx 中则验证了parseReactElement(...).toNodeTree()的构建行为与异常路径。序列化与持久化NodeTree 是编辑器运行期的内部结构而持久化场景使用的是其序列化形态。需要区分两组概念NodeTree运行期节点类型直接是 React 组件引用或 HTML 标签字符串nodes是完整的 Node 对象包含events、dom、rules等仅编辑期有意义的信息。SerializedNodes持久化由query.serialize()/query.getSerializedNodes()产出见 query.tsx组件类型被降级为{ resolvedName: string }见 serializeNode.tsx从而可以安全地序列化为 JSON 字符串存储到数据库。加载时则走反向路径actions.deserialize(input)actions.ts通过parseSerializedNode(...).toNode()恢复 Node再replaceNodes整体替换状态或直接使用Frame data{...} /让 Frame 自动完成反序列化。NodeTree 的扁平映射形态让这类整体替换操作非常简单——本质上就是替换一张RecordNodeId, Node。小结关注点结论源码位置定义{ rootNodeId: NodeId, nodes: RecordNodeId, Node }的扁平树结构interfaces/nodes.ts层级表达通过data.nodes子 id 数组与data.parent双向引用而非嵌套对象interfaces/nodes.ts创建query.parseReactElement(jsx).toNodeTree(normalize?)内部递归解析子元素后mergeTrees合并query.tsx、mergeTrees.tsx注入actions.addNodeTree(tree, parentId?, index?)递归写入节点并挂接父节点actions.ts导出query.node(id).toNodeTree(includeOnly?)深度遍历后代生成子树NodeHelpers.ts典型场景Frame 初始渲染、拖放入画布、子树复制/移动Frame.tsx、DefaultEventHandlers.tsNodeTree 是理解 craft.js 内部状态模型的关键一环它用一个根节点 id 加上一张扁平节点表优雅地表达了任意复杂的 JSX 层级无论是初始渲染、拖放插入、子树导出还是状态替换编辑器的所有多节点 CRUD 操作最终都收敛到 NodeTree 这一统一载体上。掌握了它你就能更自信地在自定义组件、自定义拖放行为与状态持久化逻辑中与 craft.js 的节点系统打交道。赞分享前端【免费下载链接】craft.js A React Framework for building extensible drag and drop page editors项目地址https://gitcode.com/gh_mirrors/cr/craft.js点击查看免费下载相关推荐150 个 Claude Code 资源去哪找这份代码生成的精选库一次把工具选型讲透150 个 Claude Code 资源去哪找这份代码生成的精选库一次把工具选型讲透 在 GitHub 上搜 claude code工具多到没法挑文档知识库Recaf中的路径节点PathNode如何表示代码结构层次Recaf中的路径节点PathNode如何表示代码结构层次 引言为什么路径节点对代码分析至关重要 在Java字节码分析和反编译工具中理解代码结构的层次关系逆向工程开发工具桌面应用CANN/GE EsCreateVectorInt32函数API文档EsCreateVectorInt32a nameZH CN_TOPIC_0000002488105182 /a 产品支持情况a namesec人工智能深度学习模型编译模型优化编译器Ascend上一篇如何用PyQuery构建强大的Web爬虫从基础到实战完整教程下一篇Vue Vben Admin 2.0 快速上手指南打造现代化中后台应用的完整方案创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

自研桌面CRM系统:基于C#与WPF的客户管理工具设计与实现 2026/9/25 6:29:24

自研桌面CRM系统:基于C#与WPF的客户管理工具设计与实现

1. 项目背景:为什么我决定自研一套桌面 CRM这两年我一直在折腾客户管理的工具化。市面上的 CRM 产品不算少,云端的、开源的、垂直行业的,我都用过一轮。但越用越觉得别扭:最核心的问题在于——数据在别人服务器上,流程…

阅读更多 →
Win11原生运行ISE 14.7完整指南:兼容性配置与常见问题排查 2026/9/25 6:29:24

Win11原生运行ISE 14.7完整指南:兼容性配置与常见问题排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Univer 开源表格引擎:Canvas 渲染与插件架构实战 2026/9/25 6:29:18

Univer 开源表格引擎:Canvas 渲染与插件架构实战

1. 从“univer”这个名字说起:它到底想解决什么问题第一次看到“univer”这个词,很多人会下意识联想到“universe”或者“universal”,觉得它大概是个大而全的东西。没错,它确实是一个野心不小的项目——Univer 是一套开源的、面向…

阅读更多 →
Windows堆内存释放后修改排查:从报错原理到PageHeap与WinDbg定位 2026/9/25 6:29:17

Windows堆内存释放后修改排查:从报错原理到PageHeap与WinDbg定位

如果你在 Windows 上调试原生 C/C 程序,多半见过这样一个弹窗:HEAP: Free Heap block 0x0123ABCD modified at 0x0123BC00 after it was freed,随后调试器停在某个看起来完全无辜的堆操作函数里。第一次遇到的人往往一脸懵——程序没有崩在访…

阅读更多 →
WS2812B灯环实战:Arduino流水灯与彩虹灯环完整指南 2026/9/25 6:29:11

WS2812B灯环实战:Arduino流水灯与彩虹灯环完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Allegro到立创EDA专业版:PCB导入转换的实操指南 2026/9/25 6:29:11

Allegro到立创EDA专业版:PCB导入转换的实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉