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),仅供参考

相关新闻

中兴B860AV3.2-M线刷全攻略:S905L3刷机与EmotnUI桌面实战

中兴B860AV3.2-M线刷全攻略:S905L3刷机与EmotnUI桌面实战

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

2026/9/25 6:01:45 阅读更多 →
海思HI3516EV300移植到国科GK7205V300实战:硬件改板到ISP调试全记录

海思HI3516EV300移植到国科GK7205V300实战:硬件改板到ISP调试全记录

做嵌入式安防这块的工程师,这两年应该都有同一个感受:海思HI3516EV300这颗芯片在项目里“焊死”太久了,供货周期、价格、方案支持这些因素一旦波动,整条产品线都被动。我去年在一个IPC项目里就碰到了类似的情况,后面评…

2026/9/25 6:01:45 阅读更多 →
Autosar CanSm Busoff恢复机制实战配置与功能安全落地

Autosar CanSm Busoff恢复机制实战配置与功能安全落地

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

2026/9/25 6:01:45 阅读更多 →

最新新闻

x86汇编核心指令与栈帧实战:从寻址到调试

x86汇编核心指令与栈帧实战:从寻址到调试

1. 为什么还要啃x86汇编这块硬骨头很多人一听“汇编”两个字,脑子里蹦出来的第一反应就是“这玩意儿不是早就被淘汰了吗”。我刚开始带新人的时候也经常被问:现在都是Java、Python、Go满天飞,学x86汇编到底图什么。这个问题我认真想过&#x…

2026/9/25 10:10:03 阅读更多 →
Substrate底层承载层:概念解析、选型逻辑与工程实践指南

Substrate底层承载层:概念解析、选型逻辑与工程实践指南

1. 从“substrate”这个词说起:它到底指什么第一次看到“substrate”这个词,很多人会愣一下。它在不同圈子里含义差别很大:做区块链的人第一反应是 Parity 那套区块链框架,做材料化学的人想到的是“基底、衬底”,做半导…

2026/9/25 10:10:03 阅读更多 →
狗头军师7大主策略全解析:从承接到收线,每轮聊天该走哪一步

狗头军师7大主策略全解析:从承接到收线,每轮聊天该走哪一步

狗头军师7大主策略全解析:从承接到收线,每轮聊天该走哪一步 【免费下载链接】goutoujunshi 一个先接住情绪、再分析关系并给出可执行策略的 Codex 恋爱军师,内置心理、法律、社会、人文、哲学、婚姻家庭与性学知识库,支持多元关系…

2026/9/25 10:10:03 阅读更多 →
油猴脚本自动答题实战:从DOM操作到浏览器自动化

油猴脚本自动答题实战:从DOM操作到浏览器自动化

1. 从“一键答完整个练习页”说起:油猴脚本到底做了什么说实话,看到“油猴自动答题”这个标题,我的第一反应不是“又来一个作弊脚本”,而是“终于有人开始认真研究浏览器自动化了”。我自己写这类脚本,最初的动机其实特…

2026/9/25 10:10:03 阅读更多 →
人工智能基础概念全景解析:从 AI 到 Transformer、LLM、Prompt、Token、RAG、Agent、对齐与安全——用 TaoToken 统一 Key 串起概念验证

人工智能基础概念全景解析:从 AI 到 Transformer、LLM、Prompt、Token、RAG、Agent、对齐与安全——用 TaoToken 统一 Key 串起概念验证

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

2026/9/25 10:10:03 阅读更多 →
Anbox 的进程控制基石:process-cpp-minimal 库源码深度解析与实战指南

Anbox 的进程控制基石:process-cpp-minimal 库源码深度解析与实战指南

虚拟化容器运行时 【免费下载链接】anbox Anbox is a container-based approach to boot a full Android system on a regular GNU/Linux system 项目地址: https://gitcode.com/gh_mirrors/an/anbox 点击查看 免费下载 process-cpp-minimal 是 Anbox 项目引入的轻…

2026/9/25 10:09:02 阅读更多 →

日新闻

AI元人文:从工具使用到思维重构的深度探索

AI元人文:从工具使用到思维重构的深度探索

最近半年我一直在琢磨一件事:AI元人文到底是什么?说白了,就是“用元视角重新审视人与AI的关系”,也在“探索AI如何反向逼着我们发现自己的思考边界”。标题里的“元探索”,在我看就是一层套一层的追问——当你用AI解决…

2026/9/25 0:00:41 阅读更多 →
Python+CNN车牌识别实战:从数据预处理到模型训练与部署

Python+CNN车牌识别实战:从数据预处理到模型训练与部署

简介:基于Python与卷积神经网络的车牌识别项目,面向计算机视觉初学者及智能交通开发者,目标是帮助用户掌握从数据预处理、模型构建到实际部署的完整流程。压缩包共25个文件,包含jpg/png图像样本、py训练脚本、md说明文档、dat数据…

2026/9/25 0:00:41 阅读更多 →
Vim基础操作全攻略:保存退出、模式切换与高频命令实战

Vim基础操作全攻略:保存退出、模式切换与高频命令实战

1. 项目概述1.1 核心需求解析今天聊聊Vim。写这个题目的原因是:几乎每个后端开发者、运维人员、数据工程师某天都会遇到一个场景——深夜加班,服务器登录界面只有黑底白字,编辑器只有vi/vim,你必须在五分钟内完成一次配置修改并保…

2026/9/25 0:00:41 阅读更多 →

周新闻

Flutter for OpenHarmony游戏卡片渐变背景实战:从原理到性能优化

Flutter for OpenHarmony游戏卡片渐变背景实战:从原理到性能优化

直接铺开项目本身吧。这几个月我一直在折腾一件事:用Flutter给OpenHarmony做一款游戏集合类的App,说白了就是把若干小游戏塞进一个壳里,用统一入口分发。这个方向本身不算新鲜,真正让我花了不少心思的,是首页那堆游戏卡…

2026/9/24 14:34:13 阅读更多 →
Word表格编号全攻略:从列表编号到题注交叉引用

Word表格编号全攻略:从列表编号到题注交叉引用

写Word文档,最让人头疼的往往是那些“看起来不起眼”的小问题。比如表格编号这事:今天在表后面多加了两个空白行,明天给客户交稿前发现整个章节的编号全部错位,光是挨个改序号就能耗掉大半个下午。我前阵子帮人整理一份上百页的技…

2026/9/24 9:10:42 阅读更多 →
从第一个站到第二个站:独立开发者的静态网站选型与落地实践

从第一个站到第二个站:独立开发者的静态网站选型与落地实践

1. 项目概述1.1 核心需求解析做独立开发者这几年,说实话,第一个网站上线的那天晚上我兴奋得没睡着。但等它跑了半年,流量惨淡、功能臃肿、代码自己都懒得看第二遍之后,我才慢慢琢磨明白一个道理:第一个网站是练手&…

2026/9/24 14:33:56 阅读更多 →

月新闻

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能分类:[AI/大模型]细分主题:AI 增强型 CI/CD 流水线自动化与 GitOps 实践:Agent 工作流、工具调用与任务拆解:从原型到生产的验收清单很多团队在尝试用大…

2026/9/24 12:50:34 阅读更多 →
容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场分类:[工程技术]细分主题:Kubernetes 生产环境运维与排障实战:可复制的项目复盘模板与决策记录大部分团队的事故复盘报告,最后都变成了躺在 Confluence 或钉…

2026/9/24 14:33:48 阅读更多 →
容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步

容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步

容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步分类:[工程技术]细分主题:Docker 容器化技术与镜像安全管理:核心链路的逐步实现与关键代码取舍面对一个积累了五六年历史包袱的单体架构应用(包含 Web 接口、后台…

2026/9/24 12:49:17 阅读更多 →