React可视化搭建器:组件注册与画布渲染核心实现

作者:状态漫游时间:2026-10-26 10:02:25浏览:591

本文深入解析React可视化搭建器的核心实现,从组件元信息与组件树的定义出发,详细阐述如何通过ComponentMeta和ComponentTree实现画布渲染。重点讲解ComponentTreeLike属性对组件实例的自动转换机制,以及通过propsType和runtimeProps解决复杂类型与函数注入的方案,为构建低代码平台提供基础技术参考。

引言

在可视化搭建的理论抽象基础上,接下来我们将具体构建一个基于 React 的可视化搭建器示例。

若将整个可视化搭建过程抽象为一个 <Designer> 组件,其 API 设计可能如下所示:

<Designer componentMetas={[]} componentTree={} />
  • componentMetas:用于存储定义组件元信息的数组。
  • componentTree:用于定义组件之间的树状结构关系。

一旦完成了组件元信息与组件树的注册,可视化搭建的画布即可进行渲染,这一逻辑直观且易于理解。

首先,我们来探讨组件树的具体定义方式:

组件树

组件树内部包含各组件的实例,最佳的设计方案是让组件树的结构与组件实例的结构保持同构,这种设计被称为 ComponentInstance - 组件实例:

{
  "componentName": "container",
  "children": [
    {
      "componentName": "text",
      "props": {
        "name": "我是一个文本组件"
      }
    }
  ]
}

上述结构既可作为单个组件的实例信息,也可视为一个完整的组件树。这意味着组件树中的任意节点都可以独立提取出来成为新的组件树,这正是同构概念的核心含义。

我们定义了最基础的组件树结构,后续所有功能的拓展都将基于以下三个核心要素:

  • componentName:表示组件名称,用于描述组件类型,例如文本、图片或表格等。
  • props:包含该组件实例的所有配置信息,这些配置将直接透传给组件 props。
  • children:表示子组件,其类型为 ComponentInstance[]。

这三个概念缺一不可,让我们从必要性的角度进一步分析这三个属性:

  • componentName:这是必须拥有的属性,否则无法确定如何渲染该节点。因此,我们需要通过组件元信息来定义每个组件名称对应的渲染方式。
  • props:即使组件名称相同,不同实例也可能拥有不同的配置。将这些配置统一放在 props 中已足够,无需额外开辟属性来存储各种业务配置。
  • children:理论上可以合并到 props.children 中,但由于子组件的概念非常常见,建议同时支持 children 与 props.children 两种形式。当两者同时定义时,前者的优先级更高。

此外,还有一个可选属性 componentId,即组件的唯一 ID。我们从可选性与必要性两个维度来分析这个属性:

  • 关于 componentId 的可选性:组件实例在组件树中的路径本身就是天然的唯一 ID,例如上述文本组件的唯一 ID 可视为 children.0。
  • 关于 componentId 的必要性:使用组件树路径作为唯一 ID 的弊端在于,当组件在树上移动后,其唯一性便会消失,此时必须依赖 componentId。

一个优秀的可视化搭建实现应当支持 componentId 的可选性。

组件元信息

承接上文,至少需要定义一个组件名称对应的渲染方式,因此组件元信息(ComponentMeta)的必要结构如下:

const textMeta = {
  componentName: "text",
  element: ({ name }) => <span>{name}</span>,
};
  • componentName:用于定义哪个组件名称的元信息。
  • element:该组件的具体渲染函数。

实现这些基础功能后,尽管该可视化搭建器尚不具备实质性的高级功能,但它至少完成了一项核心基础工作:将组件树结构的描述与具体实现分离。即使未来不再增加其他功能,这种开发模式也已发生永久性改变:我们需要先定义组件元信息,再将其放置到组件树上。

对于画板工具软件而言,如果不考虑布局等复杂的画布功能,该结构描述足以完成大部分工作的技术抽象:配置面板可以修改组件实例的 props 属性,甚至布局位置也可以存储在 props 上。

关于 element 的命名可能会产生分歧,例如还存在其他命名风格如 render、renderer、reactNode 等。但无论名称如何,只要基于 React 响应式定义,最终结果应当殊途同归,最多在各类 Key 的名称定义上有所不同,这部分可以保留个人偏好。

我们继续聚焦组件元信息的 element 属性,查看以下 element 代码:

const divMeta = {
  componentName: "div",
  element: ({ children, header }) => (
    <div>
      {children}
      {header}
    </div>
  ),
};

在上述示例中,我们可以识别出 children 与 header 类型吗?可以识别部分:

  • children:一定是 React 实例,可以是一个或多个组件实例。
  • header:可能是数字、字符串,也可能是 React 实例。

props.children 对应了 componentInstance.children 的描述,那么如何识别 header 是一个普通对象还是 React 实例呢?

Props 上的 ComponentTreeLike 属性

ComponentTreeLike 指的是:在组件 props 属性中,识别出“像组件实例的属性”,并将其转换为真正的组件实例后传给组件。

假设一个正常的 props.header 值为 "some text",那么组件 props 实际拿到的 props.header 值也是字符串 "some text":

{
  "componentName": "div",
  "props": {
    "header": "some text"
  }
}
const divMeta = {
  componentName: "div",
  element: ({ header }) => (
    <div>
      {header} {/** 字符串 "some text" */}
    </div>
  ),
};

如果将 props.header 写成类 children 结构,可视化搭建框架就会将其识别为组件实例,转化为真正的 React 实例后再传给组件:

{
  "componentName": "div",
  "props": {
    "header": [
      {
        "componentName": "text"
      }
    ]
  }
}
const divMeta = {
  componentName: "div",
  element: ({ header }) => (
    <div>
      {header} {/** React 组件实例,此时会渲染出组件实例 */}
    </div>
  ),
};

这种设计基于一个原则:组件树应当能够描述出任何组件所需的 props 属性。反过来站在 element 的角度来看,假设你注入了 Antd 等框架组件,若希望在不修改一行源码的情况下接入平台,平台必须具备配置出任何 props 的能力。除了基础变量外,更复杂的还有 React 组件实例与函数。目前我们解决了传递组件实例的问题,至于如何传递函数,将在下一小节讨论。

这种设计存在两个缺陷:

  • 由于 ComponentTreeLike 会自动转换为实例,因此无法让组件获取 ComponentTreeLike 的原始值。
  • 由于 ComponentTreeLike 的位置不确定,为了避免深层解析产生的性能损耗,仅解析 props 的第一级节点会导致嵌套层级较深的 ComponentTreeLike 无法被解析。

若要解决这两个缺陷,就需要在组件元信息上定义 Props 的类型,例如:

const divMeta = {
  componentName: "div",
  propsType: {
    header: "element",
    content: ["element"],
    tabs: [
      {
        panel: "element",
      },
    ],
  },
};

解释一下上述示例代表的含义:

  • header:表示单个 React Element。
  • content:表示 React Element 数组。
  • tabs:表示一个数组结构,每一项是对象,其中 panel 是 React Element。

配合以下组件树的描述,即可精确地将对应 element 类型转化为组件实例,而对于基本类型 primitive 则保持原样传给组件:

{
  "componentName": "div",
  "props": {
    "header": {
      "componentName": "text"
    },
    "names": ["a", "b", "c"],
    "content": [
      {
        "componentName": "text"
      },
      {
        "componentName": "text"
      }
    ],
    "tabs": [
      {
        "title": "tab1",
        "panel": {
          "componentName": "text"
        }
      }
    ]
  }
}

如此一来,未定义为 Element 的属性不会被处理成 React 实例,第一个问题便自然解决。通过配置更深层嵌套的结构,第二个问题也随之解决。

componentMeta.propsType 之所以不采用 JSONSchema 结构,是因为框架无需内置对 props 类型校验的能力,该能力可交由业务层处理。因此,此处采用简化版结构,既方便书写,也易于阅读。

注意:propsType 中 {} 表示 value 是对象,而 [] 表示 value 是数组。当为数组时,仅支持单个子元素,因为单项即是对数组每一项类型的定义。

给组件注入函数

目前我们已经能为 componentMeta.element 传入任意基础类型、React 实例的 props,但仍缺少函数类型或者 Set、Map 等复杂类型的解决方案。

由于组件树结构需要序列化入库,因此必须是一个可序列化的 JSON 结构,且该结构需暴露给开发者,故不适合定义一些 hack 的序列化、反序列化规则。因此,要给组件 props 注入函数,需定义在组件元信息上。由于其定义了额外的 props 属性且不在组件树中,我们将其命名为 runtimeProps:

const divMeta = {
  componentName: "div",
  runtimeProps: () => ({
    onClick: () => { console.log('click') }
  })
  element: ({ onClick }) => (
    <button onClick={onClick}>
      点击我
    </button>
  ),
};

点击按钮后,会打印出 click。这是因为 runtimeProps 定义了函数类型 onClick 在运行时传入了组件 props。

当组件树与 componentMeta.runtimeProps 同时定义了同一个 key 时,runtimeProps 的优先级更高。

总结

本节介绍了组件注册与画布渲染的基础内容,我们重新梳理一下。

首先定义了 <Designer /> API,并支持传入 componentTree 与 componentMetas,有了组件树与组件元信息,即可实现可视化搭建画布的渲染。

我们还介绍了如何在组件元信息中定义组件的渲染函数,以及如何给渲染函数 props 传入基本变量、React 实例以及函数,使渲染函数可以对接任何成熟的组件库,且无需组件库做任何适配工作。

但这只是可视化搭建的第一步,在真正开始做项目后,你还会遇到越来越多的问题,例如除了渲染画布,还要在业务层定义属性配置面板、组件拖拽列表、图层列表、撤销重做等功能。这些功能如何获取画布属性?如何与画布交互?runtimeProps 如何基于项目数据流给组件注入不同的属性或函数?如何根据组件 props 的变化动态注入不同函数?如何保证注入的函数引用不变?

要解决这些问题,需要在本章基础上实现一套系统的数据流规则以及配套 API,这也是下一讲的内容,更多关于JS组件注册画布渲染的资料请关注本站其它相关文章!

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)

作者最新文章