实践,制作一个高扩展、可视化低代码前端,详实、完整

本文详细介绍了低代码前端编辑器RxEditor的设计与实现,包括设计原则、基础原理、组件形态、Schema定义、状态管理和软件架构等。通过组件形态的讨论,阐述了如何处理不同类型的React组件以适配设计与预览形态,以及如何通过Schema描述组件树。此外,文章还提到了状态管理选择Redux,并简述了软件架构,强调了设计的可扩展性和组件资源利用。
摘要由CSDN通过智能技术生成

RxEditor是一款开源企业级可视化低代码前端,目标是可以编辑所有 HTML 基础的组件。比如支持 React、VUE、小程序等,目前仅实现了 React 版。

RxEditor运行快照:
image

项目地址:https://github.com/rxdrag/rxeditor

演示地址( Vercel 部署,需要科学的方法才能访问):https://rxeditor.vercel.app/

本文介绍RxEditor 设计实现方法,尽可能包括技术选型、软件架构、具体实现中碰到的各种小坑、预览渲染、物料热加载、前端逻辑编排等内容。

注:为了方便理解,文中引用的代码滤除了细节,是实际实现代码的简化版

设计原则

  • 尽量减少对组件的入侵,最大程度使用已有组件资源。
  • 配置优先,脚本辅助。
  • 基础功能原子化,组合式设计。
  • 物料插件化、逻辑组件化,尽可能动态插入系统。

基础原理

项目的设计目标,是能够通过拖拽的方式操作基于 HTML 制作的组件,如:调整这些组件的包含关系,并设置组件属性。

不管是 React、Vue、Angluar、小程序,还是别的类似前端框架,最终都是要把 JS 组件,以DOM节点的形式渲染出来。
image

编辑器(RxEditor)要维护一个树形模型,这个模型描述的是组件的隶属关系,以及 props。同时还能跟 dom 树交互,通过各种 dom 事件,操作组件模型树。

这里关键的一个点是,编辑器需要知道 dom 节点跟组件节点之间的对应关系。在不侵入组件的前提下,并且还要忽略前端库的差异,比较理想的方法是给 dom 节点赋一个特殊属性,并跟模型中组件的 id 对应,在 RxEditor 中,这个属性是rx-id,比如在dom节点中这样表示:

<div rx-id="one-uuid">  
</div>

编辑器监听 dom 事件,通过事件的 target 的 rx-id 属性,就可以识别其在模型中对应组件节点。也可以通过 document.querySelector([rx-id="${id}"])方法,查找组件对应的 dom 节点。

除此之外,还加了 rx-node-type 跟 rx-status 这两个辅助属性。rx-node-type 属性主要用来识别是工具箱的Resource、画布内的普通节点还是编辑器辅助组件,rx-status 计划是多模块编辑使用,不过目前该功能尚未实现。

rx-id 算是设计器的基础性原理,它给设计器内核抹平了前端框架的差异,几乎贯穿设计器的所有部分。

Schema 定义

编辑器操作的是JSON格式的组件树,设计时,设计引擎根据这个组件树渲染画布;预览时,执行引擎根据这个组件树渲染实际页面;代码生成时,可以把这个组件树生成代码;保存时,直接把它序列化存储到数据库或者文件。这个组件树是设计器的数据模型,通常会被叫做 Schema。

像阿里的 formily,它的Schema 依据的是JSON Schema 规范,并在上面做了一些扩展,他在描述父子关系的时候,用的是properties键值对:

{ <---- RecursionField(条件:object;渲染权:RecursionField)
  "type":"object",
  "properties":{
    "username":{ <---- RecursionField(条件:string;渲染权:RecursionField)
      "type":"string",
      "x-component":"Input"
    },
    "phone":{ <---- RecursionField(条件:string;渲染权:RecursionField)
      "type":"string",
      "x-component":"Input",
      "x-validator":"phone"
    },
    "email":{ <---- RecursionField(条件:string;渲染权:RecursionField)
      "type":"string",
      "x-component":"Input",
      "x-validator":"email"
    },
    ......
  }
}

用键值对的方式存子组件(children)有几个明显的问题:

  • 用这样的方式渲染预览界面时,一个字段只能绑定一个控件,无法绑定多个,因为key值唯一。
  • 键值对不携带顺序信息,存储到数据库JSON类型的字段时,具体的后端实现语言要进行序列化与反序列化的操作,不能保证顺序,为了避免出问题,不得不加一个类似index的字段来记录顺序。
  • 设计器引擎内部操作时,用的是数组的方式记录数据,传输到后端存储时,不得不进行转换。
    鉴于上述问题,RxEditor采用了数组的形式来记录Children,与React跟Vue控件比较接近的方式:
export interface INodeMeta<IField = any, IReactions = any> {
  componentName: string,
  props?: {
    [key: string]: any,
  },
  "x-field"?: IField,
  "x-reactions"?: IReactions,
}
export interface INodeSchema<IField = any, IReactions = any> 
  extends INodeMeta<IField, IReactions> {
  children?: INodeSchema[]
  slots?: {
    [name: string]: INodeSchema | undefined
  }
}

上面formily的例子,相应转换成:

{ 
  "componentName":"Profile",
  "x-field":{
    "type":"object",
    "name":"user"
  },
  "chilren":[
    {
      "componentName":"Input",
      "x-field":{
        "type":"string",
        "name":"username"
      }
    },
    {
      "componentName":"Input",
      "x-field":{
        "type":"string",
        "name":"phone"
      }
    },
    {
      "componentName":"Input",
      "x-field":{
        "type":"string",
        "name":"email",
        "rule":"email"
      }
    }
  ]
}

其中 x-field 是表单数据的定义,x-reactions 是组件控制逻辑,通过前端编排来实现,这两个后面会详细介绍。

需要注意的是卡槽(slots),这个是 RxEditor 的原创设计,原生 Schema 直接支持卡槽,可以很大程度上支持现有组件,比如很多 React antd 组件,不需要封装就可以直接拉到设计器里来用,关于卡槽后面还会有更详细的介绍。

组件形态

项目中的前端组件,要在两个地方渲染,一是设计引擎的画布,另一处是预览页面。这两处使用的是不同渲染引擎,对组件的要求也不一样,所以把组件分定义为两个形态:

  • 设计形态,在设计器画布内渲染,需要提供ref或者转发rx-id,有能力跟设计引擎交互。
  • 预览形态,预览引擎使用,渲染机制跟运行时渲染一样。相当于普通的前端组件。

设计形态的组件跟预览形态的组件,对应的是同一份schema,只是在渲染时,使用不同的组件实现。

接下来,以React为例,详细介绍组件设计形态与预览形态之间的区别与联系,同时也介绍了如何制作设计形态的组件。

有 React ref 的组件

这部分组件是最简单的,直接拿过来使用就好,这些组件的设计形态跟预览形态是一样的,在设计引擎这样渲染:

export const ComponentDesignerView = memo((props: { nodeId: string }) => {
  const { nodeId } = props;
  //获取数据模型树中对应的节点
  const node = useTreeNode(nodeId);
  //通过ref,给 dom 赋值rx-id
  const handleRef = useCallback((element: HTMLElement | undefined) => {
    element?.setAttribute("rx-id", node.id)
  }, [node.id])
  //拿到设计形态的组件
  const Component = useDesignComponent(node?.meta?.componentName);

  return (<Component ref={handleRef} {...realProps} >
  </Component>)
}))

只要 rx-id 被添加到 dom 节点上,就建立了 dom 与设计器内部数据模型的联系。

预览引擎的渲染相对更简单直接:

export type ComponentViewProps = {
  node: IComponentRenderSchema,
}

export const ComponentView = memo((
  props: ComponentViewProps
) => {
  const { node, ...other } = props
  //拿到预览形态的组件
  const Component = usePreviewComponent(node.componentName)

  return (
    <Component {...node.props} {...other}>
      {
        node.children?.map(child => {
          return (<ComponentView key={child.id} node={child} />)
         })
      }
    </Component>
  )
})

无ref,但可以把未知属性转发到合适的dom节点上

比如一个React组件,实现方式是这样的:

export const ComponentA = (props)=>{
    const {propA, propB, ...rest} = props
    ...
    return(
        <div {...rest}>
            ...
        </div>    
    )
}

除了 propA 跟 propB,其它的属性被原封不动的转发到了根div上,这样的组件在设计引擎里面可这样渲染:

export const ComponentDesignerView = memo((props: { nodeId: string }) => {
  const { nodeId } = props;
  //获取数据模型树中对应的节点
  const node = useTreeNode(nodeId);

  //拿到设计形态的组件
  const Component = useDesignComponent(node?.meta?.componentName);

  return (<Component rx-id={node.id} {...node?.meta?.props} >
  </Component>)
}))

通过这样的方式,rx-id 被同样添加到 dom 节点上,从而建立了数据模型与 dom之间的关联。

通过组件 id 拿到 ref

有的组件,既不能提供合适的ref,也不能转发rx-id,但是这个组件有id属性,可以通过唯一的id,来获得对应 dom 的 ref:

export const WrappedComponentA = forwardRef((props, ref)=>{
    const node = useNode()
    useLayoutEffect(() => {
      const element = node?.id ? document.getElementById(node?.id) : null
      if (isFunction(ref)) {
        ref(element)
      }
    }, [node?.id, ref])
    return(
       <ComponentA id={node?.id} {...props}/>
    )
})

提取成高阶组件:

export function forwardRefById(WrappedComponent: ReactComponent): ReactComponent {
  return memo(forwardRef<HTMLInputElement>((props: any, ref) => {
    const node = useNode()
    useLayoutEffect(() => {
      const element = node?.id ? document.getElementById(node?.id) : null
      if (isFunction(ref)) {
        ref(element)
      }
    }, [node?.id, ref])

    return <WrappedComponent id={node?.id} {...props} />
  }))
}
export const WrappedComponentA = forwardRefById(ComponentA)

使用这种方式时,要确保组件的id没有其它用途。

嵌入隐藏元素

如果一个组件,通过上述方式安插 rx-id 都不合适

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值