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 都不合适,这个组件恰好有 children 的话,可以在 children 里面插入一个隐藏元素,通过隐藏元素 dom 的parentElement 获取 ref,直接上高阶组件:
const HiddenElement = styled.div` display: none; ` export function forwardRefByChildren(WrappedComponent: ReactComponent): ReactComponent { return memo(forwardRef<HTMElement>((props: any, ref) => { const { children, ...rest } = props const handleRefChange = useCallback((element: HTMLElement | null) => { if (isFunction(ref)) { ref(element?.parentElement) } }, [ref]) return <WrappedComponent {...rest}> {children} <HiddenElement ref={handleRefChange} /> </WrappedComponent> })) } export const WrappedComponentA = forwardRefByChildren(ComponentA)
调整 ref 位置
有的组件,提供了 ref,但是 ref 位置并不合适,基于 ref 指示的 dom 节点画编辑时的轮廓线的话,会显的别扭,有个这样实现的组件:
export const ComponentA = forwardRef<HTMElement>((props: any, ref) => { return (<div style={padding:16}> <div ref={ref}> ... </div> </div>) })
编辑时这个组件的轮廓线,会显示在内层 div,距离外层 div 差了16个像素。为了把rx-id插入到外层 div, 加入一个转换 ref 的高阶组件:
// 传出真实ref用的回调 export type Callback = (element?: HTMLElement | null) => HTMLElement | undefined | null; export const defaultCallback = (element?: HTMLElement | null) => element; export function switchRef(WrappedComponent: ReactComponent, callback: Callback = defaultCallback): ReactComponent { return memo(forwardRef<HTMLInputElement>((props: any, ref) => { const handleRefChange = useCallback((element: HTMLElement | null) => { if (isFunction(ref)) { ref(callback(element)) } }, [ref]) return <WrappedComponent ref={handleRefChange} {...props} /> })) } export const WrappedComponentA = forwardRefByChildren(ComponentA, element=>element?.parentElement)
组件外层包一个 div
如果一个组件,既不能提供合适的ref,不能转发rx-id,没有id属性,也没有children, 可以在组件外层直接包一个 div,使用div 的 ref :
export const WrappedComponentA = forwardRef((props, ref)=>{ return( <div ref={ref}> <ComponentA {...props}/> </div> ) })
提取成高阶组件:
export type ReactComponent = React.FC<any> | React.ComponentClass<any> | string export function wrapWithRef(WrappedComponent: ReactComponent):ReactComponent{ return memo(forwardRef<HTMLDivElement>((props: any, ref) => { return <div ref = {ref}> <WrappedComponent {...props} /> </div })) } export const WrappedComponentA = wrapWithRef(ComponentA)
这个实现方式有个明显的问题,凭空添加了一个div,隔离了 css 上下文,为了保证设计器的显示效果跟预览时一样,所见即所得,需要在组件的预览形态上也加一个div,就是说直接修改原生组件,设计形态跟预览形态都使用转换后的组件。即便是这样,也像做不可描述的事情时带T一样,有些许不爽。
带卡槽(slots)的组件
Vue 中有卡槽,分为具名卡槽跟不具名卡槽,不具名卡槽就是 children。React 中没有明确的卡槽概念,但是React.ReactNode 类型的 props 就相当于具名卡槽了。
在可视化设计器中,是需要卡槽的。
卡槽可以非常清晰的区分组建的各个区域,并且能很好地复用逻辑。
可视化编辑器中的拖拽,是把组件拖入(拖出)children(非具名卡槽),对于具名卡槽,这种普通拖放是无能无力的。
如果schema不支持卡槽,通常会特殊处理一下组件,就是在组件外封装一层,并且还用不了高阶组件。比如 antd 的 List 组件,它有 header 跟 footer 两个 React.ReactNode 类型的属性,这就是两个卡槽。要想在设计器中使用这两个卡槽,设计形态的组件一般会这么写:
import { List as AntdList, ListProps } from "antd" export type ListAddonProps = { hasHeader?: boolean, hasFooter?: boolean, } export const List = memo(forwardRef<HTMLDivElement>(( props: ListProps<any> & ListAddonProps, ref) => { const {hasHeader, hasFooter, children, ...rest} = props const footer = useMemo(()=>{ //这里根据Schema树和children构造footer卡槽 ... }, [children, hasFooter]) const header = useMemo(()=>{ //这里根据Schema树和children构造header卡槽 ... }, [children, hasHeader]) return(<AntdList header = {header} header={footer} {...rest}}/>) }
组件的设计形态也需要类似的封装,这里就不详细展开了。
这个方式,相当于把所有的具名卡槽转换成非具名卡槽,然后在渲染的时候,再根据配置把非具名卡槽解析成具名卡槽。hasHeader这类属性不设置,也能解析,只是换了种实现方式,并无本质区别。
拥有具名卡槽的前端库太多了,每一种组件都这样处理,复杂而繁琐,并且违背了设计原则:“尽量减少对组件的入侵,最大程度使用已有组件资源”。
基于这个因素,把卡槽(slots)放入了 schema,只需要在渲染的时候跟非具名卡槽稍微做一下区别,就可以插入插槽:
export type ComponentViewProps = { node: IComponentRenderSchema, } export const ComponentView = memo(( props: ComponentViewProps ) => { const { node, ...other } = props //拿到预览形态的组件 const Component = usePreviewComponent(node.componentName) //渲染卡槽 const slots = useMemo(() => { const slts: { [key: string]: React.ReactElement } = {} for (const name of Object.keys(node?.slots || {})) { const slot = node?.slots?.[name] if (slot) { slts[name] = <ComponentView node={slot} /> } } return slts }, [node?.slots]) return ( <Component {...node.props} {...slots} {...other}> { node.children?.map(child => { return (<ComponentView key={child.id} node={child} />) }) } </Component> ) })
这是预览形态的渲染代码,设计形态类似,此处不详细展开了。
用这样的方式处理卡槽,卡槽是不能被拖入的,只能通过属性面板的配置打开或者关闭卡槽:
并且,卡槽只能是一个独立节点,不能是节点数组,相当于把React.ReactNode转换成了React.ReactElement,不过这个转换对用户体验的影响并不大。
需要独立制作设计形态的组件
通过上述各种高阶组件、schema原生支持的slots,已有的组件,基本上不需要修改就可以纳入可视化设计。
但是,也有例外。有些组件,还是需要独立制作设计形态。需要独立制作设计形态的组件,一般基于两个方面的考虑:
- 用户体验;
- 业务逻辑复杂。
在用户体验方面,看一个例子,antd 的 Button 组件。Button的使用代码:
<Button type="primary"> Primary Button </Button>
组件的children可以是 text 文本,text 文本不是一个组件,在编辑器中式很难被拖入的,要想拖入的话,可以加一个文本类型的组件 Text:
<Button type="primary"> <Text>Primary Button</Text> </Button>
这样就解决了拖放问题,并且Text组件可以在很多地方被使用,也不算增加实体。但是这样每个Button 嵌套一个 Text方式,会大量增加设计器画布中控件的数量,用户体验并不好。这种情况,最好重写Buton组件:
import {Button as AntdButton, ButtonProps} from "antd" export Button = memo(forwardRef<HTMLElement>( (props: ButtonProps&{title?:string}}, ref) => { const {title, ...rest} = props return (<AntdButton {...rest}> {title} </AntdButton>) }
进一步提取为高阶组件:
export function mapComponent(WrappedComponent: ReactComponent, maps: { [key: string]: string }): ReactComponent { return memo(forwardRef<HTMLElement>((props: any, ref) => { const mapedProps = useMemo(() => { const newProps = {} as any; for (const key of Object.keys(props || {})) { if (maps[key]) { newProps[maps[key]] = props?.[key] } else { newProps[key] = props?.[key] } } return newProps }, [props]) return ( <WrappedComponent ref={ref} {...mapedProps} /> ) })) } export const Button = mapComponent(AntdButton, { title: 'children' })
业务逻辑复杂的例子,典型的是table,设计形态跟预览形态的区别:
设计形态
预览形态
这种组件,是需要特殊制作的,没有什么简单的办法,具体实现请参考源码。
Material,物料的定义
一个Schema,只是用来描述一个组件,这个组件相关的配置,比如多语言信息、在工具箱中的图标、编辑规则(比如:它可以被放置在哪些组件下,不能被放在什么组件下)等等这些信息,需要一个配置来描述,这个就是物料的定义。具体定义:
export interface IBehaviorRule { disabled?: boolean | AbleCheckFunction //默认false selectable?: boolean | AbleCheckFunction //是否可选中,默认为true droppable?: boolean | AbleCheckFunction//是否可作为拖拽容器,默认为false draggable?: boolean | AbleCheckFunction //是否可拖拽,默认为true deletable?: boolean | AbleCheckFunction //是否可删除,默认为true cloneable?: boolean | AbleCheckFunction //是否可拷贝,默认为true resizable?: IResizable | ((engine?: IDesignerEngine) => IResizable) moveable?: IMoveable | ((engine?: IDesignerEngine) => IMoveable) // 可用于自由布局 allowChild?: (target: ITreeNode, engine?: IDesignerEngine,) => boolean allowAppendTo?: (target: ITreeNode, engine?: IDesignerEngine,) => boolean allowSiblingsTo?: (target: ITreeNode, engine?: IDesignerEngine,) => boolean noPlaceholder?: boolean, noRef?: boolean, lockable?: boolean, } export interface IComponentConfig<ComponentType = any> { //npm包名 生成代码用 packageName?: string, //组件名称,要唯一,可以加点号:. componentName: string, //组件的预览形态 component: ComponentType, //组件的设计形态 designer: ComponentType, //组件编辑规则,比如是否能作为另外组件的children behaviorRule?: IBehaviorRule //右侧属性面板的配置Schema designerSchema?: INodeSchema //组件的多语言资源 designerLocales?: ILocales //组件设计时的特殊props配置,比如Input组件的readOnly属性 designerProps?: IDesignerProps //组件在工具箱中的配置 resource?: IResource //卡槽slots用到的组件,值为true时,用缺省组件DefaultSlot, // string时,存的是已经注册过的component resource名字 slots?: { [name: string]: IComponentConfig | true | string | undefined }, //右侧属性面板用的多语言资源 toolsLocales?: ILocales, //右侧属性面板用到的扩展组件。是的,组合式设计,都可以配置 tools?: { [name: string]: ComponentType | undefined }, }
IBehaviorRule接口定义组建的编辑规则,随着项目的逐步完善,这个接口大概率会变化,这里也没必要在意这么细节的东西,要重点关注的是IComponentConfig接口,这就是一个物料的定义,泛型使用的ComponetType是为了区别前端差异,比如React的物料定义是这样:
export type ReactComponent = React.FC<any> | React.ComponentClass<any> | string export interface IComponentMaterial extends IComponentConfig<ReactComponent> { }
物料如何使用
物料定义,包含了一个组件的所有内容,直接注册进设计器,就可以使用。后面会有相关讲述。
物料的热加载
一个不想热加载的低代码平台,不是一个有出息的平台。但是,这个版本并没有来得及做热加载,后续版本会补上。这里简单分享前几个版本的热加载经验。
一个物料的定义是一个js对象,只要能拿到这个队形,就可以直接使用。热加载要解决的问题式拿到,具体拿到的方式可能有这么几种:
import
js 原生import可以引入远程定义的物料,但是这个方式有个明显的缺点,就是不能跨域。如果没有跨域需求,可以用这种方式。
webpack组件联邦
看网上介绍,这种方式似乎可行,但并没有尝试过,有类似尝试的朋友,欢迎留言。
src引入
这种方式可行的,并且以前的版本中已经成功实现,具体做法是在编译的物料库里,把物料的定义挂载到全局window对象上,在编辑器里动态创建一个 script 元素,在load事件中,从全局window对象上拿到定义,具体实现:
function loadJS(src: string, clearCache = false): Promise<HTMLScriptElement> { const p = new Promise<HTMLScriptElement>((resolve, reject) => { const script = document.createElement("script", {}); script.type = "text/JavaScript"; if (clearCache) { script.src = src + "?t=" + new Date().getTime(); } else { script.src = src; } if (script.addEventListener) { script.addEventListener("load", () => { resolve(script) }); script.addEventListener("error", (e) => { console.log("Script错误", e) reject(e) }); } document.head.appendChild(script); }) return p; } export function loadPlugin(url: string): Promise<IPlugin> { const path = trimUrl(url); const indexJs = path + "index.js"; const p = new Promise<IPlugin>((resolve, reject) => { loadJS(indexJs, true) .then((script) => { //从全局window上拿到物料的定义 const rxPlugin = window.rxPlugin console.log("加载结果", window.rxPlugin) window.rxPlugin = undefined rxPlugin && resolve(rxPlugin); script?.remove(); }) .catch(err => { reject(err); }) }) return p; }
物料的单独打包使用webpack,这个工具不是很熟练,勉强能用。有熟悉的大佬欢迎留言指导一下,不胜感激。
设计器的画布目前使用的iframe,选择iframe的原因,后面会有详细介绍。使用iframe时,相当于一个应用启动了两套React,如果从设计器通过window对象,把物料传给iframe画布,react会报错。所以需要在iframe内部单独热加载物料,切记!
状态管理
如果不考虑其它前端库,只考虑React的话,状态管理肯定会选择recoil。如果要考虑vue、angular等其它前端,就只能放弃recoil,从知道的其它库里选:redux、mobx、rxjs。
rxjs虽然看起来不错,但是没有使用经验,暂时放弃了。mobx,个人不喜欢,与上面的设计原则“尽量减少对组件的入侵,最大程度使用已有组件资源”相悖,也只能放弃。最后,选择了Redux。
虽然Redux的代码看起来会繁琐一些,好在这种可视化项目本身的状态并不多,这种繁琐度是可以接受的。
在使用过程中发现,Redux做低代码状态管理,有很多不错的优势。足够轻量,数据的流向清晰明了,可以精确控制订阅。并且,Redux对配置是友好的,在可视化业务编排里,配置订阅其状态数据非常方便。
年少无知的的时候,曾经诋毁过Reudx。不管以前说过多少Redux坏话,它还是优雅地在那里,任你随时取用,不介曾经意被你误解过,不在意是否被你咒骂过。或许,这就是开源世界的包容。
目前项目里,有三个地方用到了Redux,这三处位置以后会独立成三个npm包,所以各自维护自己的状态树的Root 节点,也就是分别维护自己的状态树。这三个状态树分别是:
设计器状态树
设计器引擎逻辑上维护一棵节点树,节点树跟带 rx-id 的 dom 节点一一对应。前面定义的schema,是协议性质,用于传输、存储。设设计引擎会把schema转换成节点树,然后展平存储在Redux里面。节点树的定义:
//这个INodeMeta跟上面Schema定义部分提到的,是一个 export interface INodeMeta<IField = any, IReactions = any> { componentName: string, props?: { [key: string]: any, }, "x-field"?: IField, "x-reactions"?: IReactions, } //节点经由Schema转换而成 export interface ITreeNode { //节点唯一ID,对应dom节点上的rx-id id: ID //组件标题 title?: string //组件描述 description?: string //组件Schema meta: INodeMeta //父节点Id parentId?: ID //子节点Id children: ID[] 是否是卡槽节点 isSlot: boolean, //卡槽节点id键值对 slots?: { [name: string]: ID } //文档id,设计器底层模型支持多文档 documentId: ID //标识专用属性,不通过外部传入,系统自动构建 //包含rx-id,rx-node-type,rx-status三个属性 rxProps?: RxProps //设计时的属性,比如readOnly, open等 designerProps?: IDesignerProps //用来编辑属性的schema designerSchema?: INodeSchema //设计器专用属性,比如是否锁定 //designerParams?: IDesignerParams }
展平到Redux里面:
//多文档模型,一个文档的状态 export type DocumentState = { //知否被修改过 changed: boolean, //被选中的节点 selectedIds: ID[] | null //操作快照 history: ISnapshot[] //根节点Id rootId?: ID } export type DocumentByIdState = { [key: string]: DocumentState | undefined } export type NodesById = { [id: ID]: ITreeNode } export type State = { //状态id stateId: StateIdState //所有的文档模型 documentsById: DocumentByIdState //当前激活文档的id activedDocumentId: ID | null //所有文档的节点,为了以后支持跨文档拖放,全部节点放在根下 nodesById: NodesById }
数据模型状态树
fieldy模块的数据模型主要用来管理页面的数据模型,树状结构,Immutble的。数据模型中的数据,通过 schema 的 x-field 属性绑定到具体组件。
预览页面、右侧属性面板都是用这个模型(右侧属性面板就是一个运行时模块,根页面预览使用相同的渲染引擎,就是说右侧属性面板是基于低代码配置来实现的)。
状态定义:
//字段状态 export type FieldState = { //自动生成id,用于组件key值 id: string; //字段名 name?: string; //基础路径 basePath?: string; //路径,path=basePath + "." + name path: string; //字段是否已被初始化 initialized?: boolean; //字段是否已挂载 mounted?: boolean; //字段是否已卸载 unmounted?: boolean; //触发 onFocus 为 true,触发 onBlur 为 false active?: boolean; //触发过 onFocus 则永远为 true visited?: boolean; display?: FieldDisplayTypes; pattern?: FieldPatternTypes; loading?: boolean; validating?: boolean; modified?: boolean; required?: boolean; value?: any; defaultValue?: any; initialValue?: any; errors?: IFieldFeedback[]; validateStatus?: FieldValidateStatus; meta: IFieldMeta } export type FieldsState = { [path: string]: FieldState | undefined } export type FormState = { //字段是否已挂载 mounted?: boolean; //字段是否已卸载 unmounted?: boolean; initialized?: boolean; pattern?: FieldPatternTypes; loading?: boolean; validating?: boolean; modified?: boolean; fields: FieldsState; fieldSchemas: IFieldSchema[]; initialValue?: any; value?: any; } export type FormsState = { [name: string]: FormState | undefined } export type State = { forms: FormsState }
熟悉formily的朋友,会发现这个结构定义跟fomily很像。没错,就是这个接口的定义就是借鉴(抄)了formily。
逻辑编排设计器状态树
这个有机会再单独成文介绍吧。
软件架构
软件被划分为两个比较独立的部分:
- 设计器,用于设计页面,消费的是设计形态的组件。生成页面Schema。
- 运行时,把设计器生成的页面Schema,渲染为正常运行的页面,消费的是预览形态的组件。
采用分层设计架构,上层依赖下层。
设计器架构
设计器的最底层是core包,在它之上是react-core、vue-core,再往上就是shell层,比如Antd shell、Mui shell等。下图是架构图,图中虚线表示只是规划尚未实现的部分,实线是已经实现的部分。后面的介绍,也是以已经实现的 React 为主。
core包是整个设计器的基础,包含了 Redux 状态树、页面互动逻辑,编辑器的各种状态等。
react-core 包定义了 react 相关的基础组件,把 core 包功能封装为hooks。
react-shells 包,针对不同组件库的具体实现,比如 antd 或者 mui 等。
运行时架构
运行时包含三个包:ComponentRender、fieldy跟minions,前者依赖后两者。
fieldy 是数据模型,用于组织页面数据,比如表单、字段等。
minions(小黄人)是控制器部分,用于控制页面的业务逻辑以及组件间的联动关系。
ComponertRender 负责把Schema 渲染为正常运行的页面。
core包的设计
Core包是基于接口的设计,这样的设计方式有个明显的优点,就是清晰模块间的依赖关系,封装了具体的实现细节,能方便的单独替换某个模块。Core 包含的模块:
设计器引擎是 IDesignerEngine 接口的具体实现,也是 Core 包入口,通过 IDesignerEngine 可以访问包内的其它模块。接口定义:
export interface IDesignerEngine { //获取设计器当前语言代码,比如:zh-CN, en-US... getLanguage(): string //设置设计设计语言代码 setLanguage(lang: string): void //中创建一个文档模型,注:设计器是多文档模型,core支持同时编辑多个文档 createDocument(schema: INodeSchema): IDocument //通过 id 获取文档模型 getDocument(id: ID): IDocument | null //通过节点 id 获取节点所属文档模型 getNodeDocument(nodeId: ID): IDocument | null //获取所有文档模型 getAllDocuments(): IDocument[] | null //获取监视器 monitor,监视器用于传递Redux store的状态数据 getMonitor(): IMonitor //获取Shell模块,shell用与获取设计器的事件,比如鼠标移动等 getShell(): IDesignerShell //获取组件管理器,组件管理器管理组件物料 getComponentManager(): IComponentManager //获取资源管理器,资源是指左侧工具箱上的资源,一个资源对应一个组件或者一段组件模板 getResourceManager(): IResourceManager //获取国语言资源管理器 getLoacalesManager(): ILocalesManager //获取装饰器管理器,装饰器是设计器的辅助工具,主要用于给画布内的节点添加附加dom属性,比如outline,辅助边距,数据绑定提示等 getDecoratorManager(): IDecoratorManager //获取设计动作,动作的实现方法,大部分会转换成redux的action getActions(): IActions //注册插件,rxeditor是组合式设计,插件没有功能性接口,只是为了统一销毁被组合的对象,提供了简单的销毁接口 registerPlugin(pluginFactory: IPluginFactory): void //获取插件 getPlugin(name: string): IPlugin | null //发送 redux action dispatch(action: IAction<any>): void //销毁设计器 destory(): void //获取一个节点的行为规则,比如是否可拖放等 getNodeBehavior(nodeId: ID): NodeBehavior }
Redux store 是设计其引擎的状态管理模块,通过Monitor模块跟文档模型,把最新的状态传递出去。
监视器(IMonitor)模块,提供订阅接口,发布设计器状态。
动作管理(IActions)模块,把部分常用的Redux actions 封装成通用接口。
文档模型(IDocument),Redux store存储了文档的状态数据,文档模型直接使用Redux store,并将其分装为更直观的接口:
export interface IDocument { //唯一标识 id: ID //销毁文档 destory(): void //初始化 initialize(rootSchema: INodeSchema, documentId: ID): void //把一个节点移动到树形结构的指定位置 moveTo(sourceId: ID, targetId: ID, pos: NodeRelativePosition): void //把多个节点移动到树形结构的指定位置 multiMoveTo(sourceIds: ID[], targetId: ID, pos: NodeRelativePosition): void //添加新节点,把组件从工具箱拖入画布,会调用这个方法 addNewNodes(elements: INodeSchema | INodeSchema[], targetId: ID, pos: NodeRelativePosition): NodeChunk //删除一个节点 remove(sourceId: ID): void //克隆一个节点 clone(sourceId: ID): void //修改节点meta数据,右侧属性面板调用这个方法修改数据 changeNodeMeta(id: ID, newMeta: INodeMeta): void //删除组件卡槽位的组件 removeSlot(id: ID, name: string): void //给一个组件卡槽插入默认组件 addSlot(id: ID, name: string): void //发送一个redux action dispatch(action: IDocumentAction<any>): void //把当前文档状态备份为一个快照 backup(actionType: HistoryableActionType): void //撤销时调用 undo(): void //重做是调用 redo(): void //定位到某个操作快照,撤销、重做的补充 goto(index: number): void //获取文档根节点 getRootNode(): ITreeNode | null //通过id获取文档节点 getNode(id: ID): ITreeNode | null //获取节点schema,相当于把ItreeNode树转换成 schema 树 getSchemaTree(): INodeSchema | null }
组件管理器(IComponentManager),管理组件信息(组件注册、获取等)。
资源管理器(IResourceManager),管理工具箱的组件、模板资源(资源注册、资源获取等)。
多语言管理器(ILocalesManager),管理多语言资源。
Shell管理(IDesignerShell),与界面交互的通用逻辑,基于事件模型实现,类图:
DesignerShell类聚合了多个驱动(IDriver),驱动通过IDispatchable接口(DesignerShell就实现了这个接口,代码中使用的就是DesignerShell)把事件发送给 DesignerShell,再由 DesignerShell 把事件分发给其它订阅者。驱动的种类有很多,比如键盘事件驱动、鼠标事件驱动、dom事件驱动等。不同的shell实现,需要的驱动也不一样,比如画布用div实现跟iframe实现,需要的驱动会略有差异。
随着后续的进展,可以有更多的驱动被组合进项目。
插件(IPlugin),RxEditor组合式的编辑器,只要拿到 IDesignerEngine 实例,就可以扩展编辑器的功能。只是有的时候需要在编辑器退出的时候,需要统一销毁某些资源,故而加入了一个简单的IPlugin接口:
export interface IPlugin { //唯一名称,可用于覆盖默认值 name: string, destory(): void, }
代码中的 core/auxwidgets 跟 core/controllers 都是 IPlugin 的实现,查看这些代码,就可以明白具体功能是怎么被组合进设计器的。实际代码中,为了更好的组合,还定义了一个工厂接口:
export type IPluginFactory = ( engine: IDesignerEngine, ) => IPlugin
创建 IDesignerEngine 的时候直接传入不同的 Plugin 工厂就可以:
export function createEngine( plugins: IPluginFactory[], options: { languange?: string, debugMode: boolean, } ): IDesignerEngine { //构建IDesignerEngine .... } const eng = createEngine( [ StartDragController, SelectionController, DragStopController, DragOverController, ActiveController, ActivedOutline, SelectedOutline, GhostWidget, DraggedAttenuator, InsertionCursor, Toolbar, ], { debugMode: false } )
装饰器管理(IDecoratorManager),装饰器用于给画布内的节点,插入html标签或者属性。这些插入的元素不依赖于节点的编辑状态(依赖于编辑状态的,通过插件插入,比如轮廓线),比如给所有的节点加入辅助的outline,或者标识出已经绑定了后端数据的节点。可以自定义多种类型的装饰器,动态插入编辑器。
装饰器的接口定义:
export interface IDecorator { //唯一名称 name: string //附加装饰器到dom节点 decorate(el: HTMLElement, node: ITreeNode): void; //从dom节点,卸载装饰器 unDecorate(el: HTMLElement): void; } export interface IDecoratorManager { addDecorator(decorator: IDecorator, documentId: string): void removeDecorator(name: string, documentId: string): void getDecorator(name: string, documentId: string): IDecorator | undefined }
一个辅助轮廓线的示例:
export const LINE_DECORTOR_NAME = "lineDecorator" export class LineDecorator implements IDecorator { name: string = LINE_DECORTOR_NAME; decorate(el: HTMLElement, node: ITreeNode): void { el.classList.add("rx-node-outlined") } unDecorate(el: HTMLElement): void { el.classList.remove("rx-node-outlined") } } //css .rx-node-outlined{ outline: dashed grey 1px; }
react-core 包
这个包是使用 React 对 core 进行的封装,并且提供一些通用 React 组件,不依赖具体的组件库(类似antd,mui等)。
上下文(Contexts)
DesignerEngineContext 设计引擎上下文,用于下发 IDesignerEngine 实例,包裹在设计器最顶层。
DesignComponentsContext 设计形态组件上下文,注册进设计器的组件,它们的设计形态通过这个上下文下发。
PreviewComponentsContext 预览形态组件上下文,注册进设计器的组件,他们的预览形态通过这个上下文下发。
DocumentContext 文档上下文,下发一个文档模型(IDocument),包裹在文档视图的顶层。
NodeContext 节点上下文,下发 ITreeNode,每个节点包裹一个这样的上下文。
通用组件
Designer 设计器根组件。
DocumentRoot 文档视图根组件。
ComponentTreeWidget 在画布上渲染节点树,调用 ComponentDesignerView 递归实现。
画布(Canvas)
实现不依赖具体画布。使用 ComponentTreeWidget 组件实现。
core 包定义了画布接口 IShellPane,和不同的画布实现逻辑(headless的):IFrameCanvasImpl(把画布包放入iframe的实现逻辑),ShadowCanvasImpl(把画布放入Web component的实现逻辑)。如果需要,可以做一个div的画布实现。
在react-core包,把画布的实现逻辑跟具体界面组件挂接到一起,具体可以阅读相关代码,有问题欢迎留言。
画布的实现方式大概有三种方式,都有各自的优缺点,下面分别说说。
div实现方式,把设计器组件树渲染在一个div内,跟设计器没有隔离,这中实现方式比较简单,性能也好。缺点就是js上下文跟css样式没有隔离机制,被设计页面的样式不够独立。类似 position:fixed 的样式需要在画布最外层加一个隔离,比如:transform:scale(1) 。
响应式布局,是指随着浏览器的大小改变,会呈现不同的样式,css中使用的是 @media 查询,比如:
@media (min-width: 1200){ //>=1200的设备 } @media (min-width: 992px){ //>=992的设备 } @media (min-width: 768px){ //>=768的设备 }
一个设计器中,如果能通过调整画布的大小来触发@media的选择,就可以直观的看到被设计的内容在不同设备上的外观。div作为画布,是模拟不了浏览器大小的,无法触发@media 查询,对响应式页面的设计并不十分友好。
web component沙箱方式,用 shadow dom 作为画布,把设计器组件树渲染在 shadow dom 内。这样的实现方式,性能跟div方式差不多,还可以有效隔离js上下文跟css样式,比div的实现方式稍微好一些,类似 position:fixed 的样式还是需要在画布最外层加一个隔离,比如:transform:scale(1) 。并且 shadow dom 不能模拟浏览器大小,它的大小改变也不能触发无法触发@media 查询。
iframe实现方式,把设计器组件树渲染在 iframe 内,iframe会隔离js跟css,并且iframe尺寸的变化也会触发 @media 查询,是非常理想的实现方式,RxEditor 最终也锁定在了这种实现方式上。
往iframe内部渲染组件,也有不同的渲染方式。在 RxEditor 项目中,尝试过两种方式:
ReactDOM.Root.render渲染,这种方式需要拿到iframe里面第一个div的dom,然后传入ReactDOM.createRoot。相当于在主程序渲染画布组件,这种实现方式性能还是不错的,画面没有闪烁感。但是,组件用的css样式跟js链接,需要从外部传入iframe内部。很多组件库的不兼容这样实现方式,比如 antd 的 popup 系列组件,在这种方式下很难正常工作,要实现类似功能,不得不重写组件,与设计原则 “尽量减少对组件的入侵,最大程度使用已有组件资源” 相悖。
iframe.src方式渲染,定义一个画布渲染组件,并配置路由,把路由地址传入iframe.src:
<Routes> ... <Route path={'/canvas-render'} element={<IFrameCanvasRender designers={designers} />} > </Route> ... </Routes> //iframe渲染 <iframe ref={ref} src={'/canvas-render'} onLoad={handleLoaded} > </iframe>
这样的渲染方式,完美解决了上述各种问题,就是渲染画布的时候,需要一段时间初始化React,性能上比上述方式略差。另外,热加载进来的组件不能通过window全局对象的形式传入iframe,热加载需要在iframe内部完成,否则React会报冲突警告。
react-shells 包
依赖于组件库部分的实现,目前只是先了 antd 版本。代码就是普通react组件跟钩子,直接翻阅一下源码就好,有问题欢迎留言。
runner 包
这个包是运行时,以正常运行的方式渲染设计器生产的页面,消费的是预览形态的组件。设计器右侧的属性面板也是基于低代码实现,使用的是这个包。
runner 包能渲染一个完整的前端应用,包含表单数据绑定,组件的联动。采用模型数据、行为、UI界面三者分离的方式。
数据模型在 fieldy 模块定义,基于Redux实现,前面已经介绍过其接口。这个模块,在逻辑上管理一棵数据树,组件可以绑定树的具体节点,一个节点可以绑定多个组件。绑定方式,在 schema 的 x-field 字段定义。
本文的开始的设计原则中说过,尽量减少对组件的入侵,最大程度使用已有组件资源。这就意味着,控制组件的时候,不要重写组件或者侵入其内部,而是通过组件对外的接口props来控制。在组件外层,包装一个控制器,来实现对组件的控制。比如一个组件ComponentA,控制器代码可以这样:
export class ControllerA{ setProp(name: string, value: any): void subscribeToPropsChange(listener: PropsListener): UnListener destory(): void, ... } export const ComponentAController = memo((props)=>{ const [changedProps, setChangeProps] = useState<any>() const handlePropsChange = useCallback((name: string, value: any) => { setChangeProps((changedProps: any) => { return ({ ...changedProps, [name]: value }) }) }, []) useEffect(() => { const ctrl = new ControllerA() const unlistener = ctrl?.subscribeToPropsChange(handlePropsChange) return () => { ctrl.destory() unlistener?.() } }, []) const newProps = useMemo(() => { return { ...props, ...controller?.events, ...changedProps } }, [changedProps, controller?.events, props]) return( <Component {...newProps}> ) })
这段代码,相当于把组件的控制逻辑抽象到ControllerA内部,通过 props 更改 ComponentA 的状态。ControllerA 的实例可以注册到全局或者通过Context下发到子组件(上面算是伪代码,未展示这部分),其它组件可以通过ControllerA 的实例,传递联动控制。
在RxEditor中,控制器实例是通过Context逐级下发的,子组件可以调用所有父组件的控制器,因为控制器本身是个类,所以可以通过属性变量传递数据,实际的控制器定义如下:
//变量控制器,用于组件间共享数据 export interface IVariableController { setVariable(name: string, value: any): void, getVariable(name: string): any, subscribeToVariableChange(name: string, listener: VariableListener): void } //属性控制器,用于设置组件属性 export interface IPropController { setProp(name: string, value: any): void } //组件控制器接口 export interface IComponentController extends IVariableController, IPropController { //唯一Id id: string, //并称,编排时作为标识 name?: string, //逻辑编排的meta数据 meta: IControllerMeta, subscribeToPropsChange(listener: PropsListener): UnListener destory(): void, //其它 ... }
runner 渲染跟设计器一样,是通过 ComponentView 组件递归完成的。所以 ComponentAController 可以提取为一个高阶组件 withController(具体实现请阅读代码),ComponentView 渲染组件时,根据schema配置,如果配置了 x-reactions,就给组件包裹高阶组件withController,实现组件控制器的绑定。如果配置了x-field,就给组件包裹一个数据绑定的高阶组件 withBind。
ComponentRender 调用 ComponentView, 通过递归机制把schema树渲染为真实页面。渲染时,会根据x-field的配置渲染fieldy模块的一些组件,完成数据模型的建立。
另外,IComponentController 的具体实现,依赖逻辑编排,逻辑编排的实现原理在下一节介绍。
逻辑编排
一直对逻辑编排不是很感兴趣,觉得用图形化的形式实现代码逻辑,不会有什么优势。直到看到 mybricks 的逻辑编排,才发现换个思路,可以把业务逻辑组件化,逻辑编排其实大有可为。
接下来,以打地鼠逻辑为例,说一下逻辑编排的实现思路。
打地鼠的界面:
左侧9个按钮是地鼠,每隔1秒会随机活动一只(变为蓝色),鼠标点击活动地鼠为击中(变为红色,并且积分器上记1分),右侧上方的输入框为计分器,下面是两个按钮用来开始或者结束游戏。
前面讲过,RxEditor 组件控制器是通过Context下发到子组件的,就是是说只有子组件能访问父组件的控制器,父组件访问不了子组件的控制器,兄弟组件之间也不能相互访问控制器。如果通过全局注册控制器的方式,组件之间就可以随意访问控制器,实现这种地鼠逻辑会简单些。但是,如果全局的方式注册控制器,会带来一个新的问题,就是动态表格的控制器不好注册,表格内的控件是动态生成的,他的控制器不好在设计时绑定,所以目前只考虑Context的实现方式。
游戏主控制器
在最顶层的组件 antd Row 上加一个一个游戏控制,控制器取名“游戏容器”:
这个控制器的可视化配置:
这个可视化配置的实现原理,改天再写吧,这里只介绍如何用它实现逻辑编排。
这是一个基于数据流的逻辑编排引擎,数据从节点的输入端口(左侧端口)流入,经过处理以后,再从输出端口(右侧端口)流出。流入与流出是基于回调的方式实现(类似Promise),并且每个节点可以有自己的状态,所以上图跟流程图有个本质的不同,流程图是单线脚本,而上图每一个节点是一个对象,有点像电影《超级奶爸》里面的小黄人,所以我给这个逻辑编排功能起名叫minions(小黄人),不同的是,这里的小黄人可以组合成另外一个小黄人,可以任意嵌套、任意组合。
这样的实现机制相当于把业务逻辑组件化了,然后再把业务逻辑组件可视化。
控制器的事件组件内置的,antd 的 Row 内置了三个事件:初始化、销毁、点击。可以在这些事件里实现具体的业务逻辑。本例中的初始化事件中,实现了打地鼠的主逻辑:
监听“运行”变量,如果为true,启动一个信号发生器,信号发生器每1000毫秒产生一个信号,游戏开始;如果为false,则停止信号发生器,游戏结束。信号发生器产生信号以后,传递给一个随机数生成器,用于生成一个代表地鼠编号的随机数,这个随机数赋值给变量”活跃地鼠“,地鼠组件会订阅变量”活跃地鼠“,如果变量值跟自己的编号一致,就把自己变为激活状态
交互相当于类的方法(实际上用一个类来实现),是自定义的。这里定义了三个交互:开始、结束、计分,一个交互就是一个类,可以通过Context下发到子组件,子组件可以实例化并用它们来组合自己的逻辑。
开始,就是把变量”运行“赋值为true,用于启动游戏。
结束,就是把变量”运行“赋值为false,用于结束游戏。
计分,就是把成绩+1
变量相当于组件控制器类的属性,外部可以通过 subscribeToVariableChange 方法订阅变量的变化。
地鼠控制器
在初始化事件中,地鼠订阅父组件”游戏容器“的活跃地鼠变量,通过条件判断节点判断是否跟自己编号一致,如果一致,把按钮的disabled属性设置为常量false,并启动延时器,延时2000毫秒以后,设置disabled为常量true,并重置按钮颜色(danger属性设置为false)。
点击事件的编排逻辑:
给danger属性赋值常量true(按钮变红),调用游戏容器的计分方法,增加积分。
其它组件也是类似的实现方式,这里就不展开了。具体的实现例子,请参考在线演示。
这里只是初步介绍了逻辑编排的大概原理,详细实现有机会再起一篇专门文章来写吧。
总结
本文介绍了一个可视化前端的实现原理,包括可视化编辑、运行时渲染等方面内容,所涵盖内容,可以构建一个完整低代码前端,只是限于精力有限、篇幅有限,很多东西没有展开,详细的可以翻阅一下实现代码。有问题,欢迎留言