RxEditor是一款开源企业级可视化低代码前端,目标是可以编辑所有 HTML 基础的组件。比如支持 React、VUE、小程序等,目前仅实现了 React 版。
RxEditor运行快照:
项目地址:https://github.com/rxdrag/rxeditor
演示地址( Vercel 部署,需要科学的方法才能访问):https://rxeditor.vercel.app/
本文介绍RxEditor 设计实现方法,尽可能包括技术选型、软件架构、具体实现中碰到的各种小坑、预览渲染、物料热加载、前端逻辑编排等内容。
注:为了方便理解,文中引用的代码滤除了细节,是实际实现代码的简化版
设计原则
- 尽量减少对组件的入侵,最大程度使用已有组件资源。
- 配置优先,脚本辅助。
- 基础功能原子化,组合式设计。
- 物料插件化、逻辑组件化,尽可能动态插入系统。
基础原理
项目的设计目标,是能够通过拖拽的方式操作基于 HTML 制作的组件,如:调整这些组件的包含关系,并设置组件属性。
不管是 React、Vue、Angluar、小程序,还是别的类似前端框架,最终都是要把 JS 组件,以DOM节点的形式渲染出来。
编辑器(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 都不合适