ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

brick-design 的 BrickTree 组件深度解析:与画布实时同步的组件树、拖拽排序与页面结构编辑

brick-design 的 BrickTree 组件深度解析:与画布实时同步的组件树、拖拽排序与页面结构编辑
  • 低代码
  • 前端

【免费下载链接】brick-design

低代码框架,支持流式布局与自由布局拖拽编排,可视化拖拽、随意嵌套组合、实时渲染、实时辅助线展示、自由布局支持辅助对齐、支持自动吸附、实时组件间距展示、实时拖拽排序、状态域管理,可视化属性配置、可视化样式配置、多设备适配展示,支持逻辑渲染、模板字符变量、表达式、自定义方法、自定义状态

项目地址:https://gitcode.com/gh_mirrors/br/brick-design
点击查看免费下载

BrickTree 是 brick-design 低代码编辑器中的组件树面板,它以树形结构实时呈现当前页面的pageConfig层级,并支持选中联动、复制、删除以及跨组件拖拽改序等操作。本文以官方文档 BrickTree.md 的 API 说明为主体,结合packages/components与packages/core中的真实源码,完整拆解它的渲染结构、与 BrickDesign 画布的同步机制,以及“增、删、改 + 拖拽排序”背后的 Action/Reducer 调用链。读完本文,你将掌握如何在BrickProvider体系内正确接入 BrickTree,并理解每一个树上操作如何最终改写pageConfig状态。

一、BrickTree 的定位:画布的“结构镜像”

官方文档对 BrickTree 的介绍可以概括为三点:

  1. 它是 brick design 编辑器的组件树,同时也是页面结构的可视化展示;
  2. 当直接编辑画布不方便时,可以通过修改组件树结构来实现想要的效果——即树面板是画布编辑之外的另一条操控入口;
  3. BrickTree 与 BrickDesign 实时同步,并且同样支持增、删、改,支持跨组件拖拽更改顺序。

在完整的编辑器外壳中,BrickTree 并不单独出现。从源码 Layers 面板 可以看到,它被放置在名为 "Layers" 的可拖拽调整宽度的侧栏(DragResizeBar)中,与页面列表Pages上下组合:

<DragResizeBar title={'Layers'} minWidth={212} icon={layersIcon} key={'Layers'}> <Pages onDelete={onDelete} /> <BrickTree /> <Tips icon={warnIcon} tip={'删除不可逆'} tipTitle={'注意'} ... /> </DragResizeBar>

其中Pages负责页面(Layer)的增删改名,BrickTree负责当前页面内组件树的展示与编辑;Tips组件则承载“删除不可逆”的确认弹窗。也就是说,BrickTree 的编辑对象是当前页面的组件结构,而页面级的切换由同级的Pages完成。

二、安装与使用方式

按 docs/react/getting-started.md 的说明,首先安装 React 集成包:

yarn add @brickd/react

或者

npm install @brickd/react

然后在BrickProvider内部与BrickDesign并列挂载BrickTree:

import { createElement } from 'react'; import { BrickDesign, BrickTree, BrickProvider, useSelector, createActions, } from '@brickd/react'; const plugins = [(vDom, componentConfig) => vDom]; const customReducer = (state, action) => { const { type, payload } = action; switch (type) { case 'customReducer': return { ...state }; default: return state; } }; const App = () => { const { componentConfigs } = useSelector(['componentConfigs']); return ( <BrickProvider initState={{}} customReducer={customReducer} config={{ /* 你的配置 */ }}> <div onClick={() => createActions({ type: 'customReducer', payload: { /* ... */ } })}> 触发 action </div> <BrickDesign /> <BrickTree /> </BrickProvider> ); };

这与 packages/components/README.md 中的用法示例一致:BrickTree必须处于BrickProvider的上下文中,与BrickDesign(画板/页面编辑组件)共享同一份状态。如果你使用完整编辑器外壳,示例工程 examples/react-example/src/App.tsx 直接使用了封装好的BrickEdifice,其内部(BrickEdifice/index.tsx)同样以BrickProvider+BrickDesignCanvas组合呈现各面板,Layers 面板(含 BrickTree)由Menus按需激活。

属性说明:className

文档明确 BrickTree 目前只有一个属性className,它作用在组件树的最外层 div 上。从当前源码结构看(BrickTree/index.tsx),组件树的根容器渲染为:

<div onMouseLeave={onMouseLeave} onWheel={(event) => event.stopPropagation()} className={styles['sort-container']} > <SortTree disabled childNodes={[ROOT]} specialProps={{ key: ROOT, domTreeKeys: [], parentKey: '' }} componentName={''} /> </div>

这里有两个实现细节值得注意:

  • onWheel中调用event.stopPropagation(),阻止页面滚动事件冒泡,避免树面板内部滚动时干扰外层画布交互;
  • onMouseLeave中调用setOperateState({ operateHoverKey: null, hoverNode: null }),当鼠标离开整个树面板时,清除画布上残留的 hover 高亮。

样式层面,.sort-container使用 flex 布局并允许自身滚动(index.less)。如果需要对树面板做布局调整(例如固定高度、宽度),按文档说明将className作用在最外层 div 上即可。

三、树的数据来源与递归渲染结构

3.1 数据源:pageConfig[ROOT]

BrickTree 入口 通过useSelector订阅STATE_PROPS域中的pageConfig,其比较函数只在根节点从无到有(或从有到无)时才触发刷新:

const { pageConfig } = useSelector( ['pageConfig'], (prevState, nextState) => { const { pageConfig } = nextState; return !!(!prevState.pageConfig[ROOT] && pageConfig[ROOT]); } ); if (!pageConfig[ROOT]) return null;

这解释了 BrickTree 的渲染前提:页面必须存在根节点ROOT,树面板才会渲染;一旦整页被清空(见后文deleteComponent对 ROOT 的处理),树面板即返回null。树从childNodes={[ROOT]}开始递归展开,specialProps携带key、domTreeKeys、parentKey等定位信息,供深层节点识别自己的位置。

3.2 递归渲染:SortTree → SortItem

树的主体由 SortTree.tsx 与 SortItem.tsx 两个组件递归构成:

  • SortTree把一组childNodes渲染为可排序容器(ReactSortable+SortItem列表);
  • SortItem表示一个节点,其内部通过renderSortTree决定如何展开子级(SortItem.tsx L33-L70):
    • 若childNodes是数组(普通容器),继续渲染一个SortTree;
    • 若childNodes不是数组且存在nodePropsConfig,则按nodePropsConfig的每个属性名渲染“属性子树”——也就是说,组件上配置了nodeProps的字段(如 Form 的 items、List 的数据源等)也会作为可嵌套子树展示,propChildNodes取get(childNodes, propName, [])。

每个SortItem通过useSelector订阅selectedInfo与pageConfig(SortItem.tsx L103-L112),从pageConfig[key]读出节点的componentName与childNodes。此外,每个节点 DOM 上写入了三类数据属性(SortItem.tsx L159-L165):

<div className={styles['sort-item']} id={key} >onMouseOver={() => { !isSelected && setOperateState({ hoverNode: getSelectedNode(key), operateHoverKey: key, }); }}

鼠标悬停树上节点时,通过getSelectedNode(key)拿到画布中对应的真实 DOM 节点,写入operateHoverKey与hoverNode,画布侧组件据此显示 hover 轮廓;onMouseLeave时再清空。Header还通过useOperate()的setSubscribe(changeStatus)订阅操作状态变化,从而响应画布侧发起的 hover(即画布 → 树方向的反向高亮)。

(2)树 → 画布(点击选中 + 滚动定位)

onClick={(event) => { event.stopPropagation(); if (isSelected) { clearSelectedStatus(); setOperateState({ selectedNode: null, operateSelectedKey: null }); } else { const selectedNode = getSelectedNode(key); selectComponent({ ...specialProps, propName }); nodeScrollIntoView(selectedNode); setOperateState({ selectedNode, operateSelectedKey: key }); } }}

点击节点会派发selectComponentaction(定义见 produceActions/selectedComponent.ts),更新全局selectedInfo;nodeScrollIntoView负责把画布中对应节点滚动进可视区域;再次点击已选中节点则取消选中。由于 BrickDesign 画布同样订阅selectedInfo来渲染选中边框,树与画布的选中态因此始终保持一致。

(3)画布占位预览:placeholderBridgeStore桥

这是“实时同步”中最有特色的一环。当你在树上拖拽节点时,画布侧会实时渲染一个占位框(GuidePlaceholder),预览组件将被放入的位置。其实现链路是:

  • dragSort.ts L14-L23 定义了全局桥对象:
export const placeholderBridgeStore = { prevRects: null, renderPlaceholder: null, changePosition(rects: PlaceholderPositionType) { if (!isEqual(this.prevRects, rects)) { this.prevRects = rects; this.renderPlaceholder && this.renderPlaceholder(rects); } }, };
  • 画布侧 GuidePlaceholder 组件 把placeholderBridgeStore.renderPlaceholder注册为占位框渲染函数;
  • 树上拖拽过程中,SortTree的onChange调用 onDragBrickTree(下文详述),实时计算node1/node2/node3三段占位矩形并changePosition推给画布;
  • 拖拽结束或无变化时,changePosition({})清空占位框(参见 Container.ts 中对placeholderBridgeStore的清空调用)。

因此,即使落点在画布深处,你也能在拖到一半时就直观看到“松手后会插在哪里”。

五、增、删、改:树上操作的 Action/Reducer 实现

文档所说的“支持增、删、改,支持跨组件拖拽更改顺序”,在 Header.tsx 中落为三个按钮行为,对应的 action 与 reducer 均在@brickd/core包内。

5.1 复制(copyComponent)

树节点 hover 时显示复制图标(非属性节点、非 ROOT 节点才显示),点击后执行copyComponent({ key, parentKey, parentPropName })。其 reducer 实现于 handleComponentSchema.ts L104-L136:

  • 未选中节点且未传key时,warn('Please select the node you want to copy')并返回原 state;
  • 禁止复制根节点:selectedKey或key为ROOT时给出Prohibit copying root node警告;
  • 合法复制时,先undo.push({ pageConfig })、清空redo,再通过getNewKey(pageConfig)生成新键,用 immer 的produce + update把newKey追加到父节点childNodes,并用copyConfig深拷贝原节点配置——这就是文档所述“增”的来源,复制出的节点会即时出现在树与画布中(配合 3.3 节的自动展开)。

5.2 删除(deleteComponent)

同样在 handleComponentSchema.ts L184-L224:

  • 未选中且无key时警告Please select the components you want to delete;
  • 若删除的是ROOT,直接返回空pageConfig(即删除整个页面,树面板随之整体不渲染);
  • 否则从父节点childNodes中移除引用(deleteChildNodesKey),若该节点自身还有childNodes,则递归deleteChildNodes清掉所有子节点,最后delete oldState[key]并重置selectedInfo: null。

删除会先压入undo,支持撤销;而页面级的删除(Layers 面板的deleteLayers)则在 Layers/index.tsx 中通过Tips弹窗做“删除不可逆”的二次确认。

5.3 排序与跨组件移动(onLayoutSortChange)

这是 BrickTree 的核心能力。SortTree的onSortEnd回调(SortTree.tsx L81-L99)拿到排序后的sortKeys与拖拽事件后:

const layoutSortChange = useCallback(function (sortKeys: string[], evt) { const dragInfo = JSON.parse(evt.clone.dataset.special); // {key, parentPropName, parentKey} onLayoutSortChange({ sortKeys, parentKey: key, parentPropName: propName!, dragInfo }); setOperateState({ dropNode: null, isDropAble: false }); setNewDragKey(null); }, []);

对应的 reducer(handleComponentSchema.ts L144-L177)做了两件事:

  1. 本层重排:把父节点位置的childNodes直接替换为sortKeys;
  2. 跨组件移动清理:若dragInfo存在且其parentKey/parentPropName与当前落点不同,说明节点从别的父容器中被拖出,于是再对原父节点执行deleteChildNodesKey(childNodes, key, parentPropName),删除旧引用。

源码注释也明确写道:“dragInfo 有值说明为跨组件排序,需要删除拖拽组件原先父组件中的引用”。两次update在同一个produce内完成,保证pageConfig原子更新,随后画布按新结构重新渲染——这就是文档中“支持跨组件拖拽更改顺序”的完整闭环。

六、拖拽排序的实现细节:ReactSortable 与落点规则

6.1 拖拽引擎封装

树的可排序容器基于 ReactSortable,它是对sortablejs的forwardRef封装:useEffect中为onChoose/onAdd/onUpdate/onChange/onEnd五个事件统一注入包装逻辑——

  • onChoose记录nextSibling与当前容器;
  • onAdd/onUpdate触发时先把evt.item插回nextSibling之前(回滚 DOM 乐观更新),再取sortable.toArray()得到完整键序并回调onSortEnd(items, evt),由状态层真正改写pageConfig;
  • onChange把实时键序回调给onChange(items, evt),用于拖拽过程中的画布占位预览。

6.2 Sortable 配置与落点校验

SortTree.tsx L117-L137 给出了关键的options:

<ReactSortable className={styles['sort-able']} options={{ group: { name: 'nested', put: (a, b, c) => putItem(c, props) }, animation: 200, disabled, dataIdAttr: 'id', ghostClass: styles['item-background'], swapThreshold: 0.5, }} onSortEnd={layoutSortChange} onChange={onChange} onEnd={onEnd} >
  • group.name: 'nested'+put: putItem:所有层级的 SortTree 同属nested组,因此节点天然可以跨层拖动;而putItem(SortTree.tsx L50-L64)是落点守门人:
const putItem = (c: any, props: SortTreePropsType) => { const { childNodes = [], nodeProps, componentName } = props; const fatherNodesRule = c.dataset.farules; const dragName = c.dataset.name; if (fatherNodesRule && !JSON.parse(fatherNodesRule).include(componentName)) return false; if (nodeProps) { if (nodeProps.isOnlyNode && childNodes.length === 1) return false; if (nodeProps.childNodesRule) { return nodeProps.childNodesRule.includes(dragName); } } return true; };

落点合法性由组件 Schema 决定:fatherNodesRule声明“哪些父级可以容纳我”,childNodesRule声明“我可放入哪些子级”,isOnlyNode约束“容器内只能有一个节点”时禁止再插入第二个。这使砖块设计器可以把组件的嵌套约束完全配置化。

  • dataIdAttr: 'id'与SortItem的id={key}配合,保证toArray()返回的就是pageConfig键序列;
  • animation: 200、ghostClass(灰色幽灵占位)、swapThreshold: 0.5控制拖拽手感;
  • disabled由上层传入,根层SortTree传disabled是因为 ROOT 本身不可被拖走(复制/删除也禁止操作 ROOT,见 5.1、5.2)。

6.3 拖拽过程:画布占位框的实时计算

拖拽过程中onChange会持续触发 onDragBrickTree:

export const onDragBrickTree = (dragKey, sortKeys, parentKey, setOperateState, propName) => { let parentNode = getSelectedNode(parentKey); let childNodes = get(getVNode(parentKey), 'childNodes', []); if (!Array.isArray(childNodes) && propName) { childNodes = get(childNodes, 'propName', []); // 属性子树 } if (isEqual(childNodes, sortKeys)) return placeholderBridgeStore.changePosition(placeholderPosition); // ...计算 node1/node2/node3 三段占位矩形 placeholderBridgeStore.changePosition(placeholderPosition); };

它会:解析属性子树(propName)情形、把落点 DOM 写入dropNode并置isDropAble: true(供画布侧渲染投放提示)、根据拖拽键在排序序列中的位置(首/中/尾)分别调用getPlaceholder1Rect/getPlaceholder3Rect计算三段式占位矩形,最后推送给placeholderBridgeStore。若排序结果与原childNodes完全一致(isEqual),直接清空占位——这解释了为什么“原地微动”不会在画布上留下多余占位框。

七、使用注意事项小结

结合文档与源码,使用 BrickTree 时需记住:

  1. 必须在BrickProvider内使用,且与BrickDesign共享状态;页面pageConfig中没有ROOT时树面板不渲染(index.tsx L27);
  2. 当前对外属性只有className,作用于组件树最外层 div,用于布局与样式覆盖;
  3. ROOT 节点是保护对象:不可复制、不可拖拽,删除 ROOT 等于清空整页(会触发undo可撤销,但页面级deleteLayers不可逆);
  4. 嵌套约束由组件配置驱动:fatherNodesRule、childNodesRule、isOnlyNode决定拖拽能否落点,配置不当会出现“拖不进去”的现象,排查时优先检查对应组件的NodeProps配置;
  5. 树操作全部走 action(copyComponent/deleteComponent/onLayoutSortChange/selectComponent,见 actionTypes.ts 与 reducers/index.ts),并自动维护undo/redo,因此撤销/重做按钮(MidBottomButtons)对树操作同样生效;
  6. 性能上做了针对性收敛:BrickTree、SortTree、SortItem、Header均为memo组件,useSelector的自定义比较函数限制了根级重渲染,滚动/悬停事件也做了stopPropagation隔离,这些是树与画布能“实时同步”而不互相拖累的前提。

至此,BrickTree 从文档描述的“组件树 + 实时同步 + 增删改 + 跨组件拖拽”,到pageConfig数据流、placeholderBridgeStore占位桥、nested组拖拽规则与produce原子更新的完整实现路径均已闭环。若需进一步阅读,建议依次查看 BrickTree 面板源码、handleComponentSchema.ts 与 dragSort.ts,它们分别对应树渲染、状态改写与拖拽预览三个核心环节。

  • 低代码
  • 前端

【免费下载链接】brick-design

低代码框架,支持流式布局与自由布局拖拽编排,可视化拖拽、随意嵌套组合、实时渲染、实时辅助线展示、自由布局支持辅助对齐、支持自动吸附、实时组件间距展示、实时拖拽排序、状态域管理,可视化属性配置、可视化样式配置、多设备适配展示,支持逻辑渲染、模板字符变量、表达式、自定义方法、自定义状态

项目地址:https://gitcode.com/gh_mirrors/br/brick-design
点击查看免费下载
上一篇:猫抓cat-catch:5分钟快速上手的浏览器视频下载终极指南
下一篇:Quartz.NET 裁剪发布与 Native AOT 实战指南:从 IsAotCompatible 声明到可运行的持久化 JobStore

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进