共计 27279 个字符,预计需要花费 69 分钟才能阅读完成。
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(按钮变红),调用游戏容器的计分办法,减少积分。
其它组件也是相似的实现形式,这里就不开展了。具体的实现例子,请参考在线演示。
这里只是初步介绍了逻辑编排的大略原理,具体实现有机会再起一篇专门文章来写吧。
总结
本文介绍了一个可视化前端的实现原理,包含可视化编辑、运行时渲染等方面内容,所涵盖内容,能够构建一个残缺低代码前端,只是限于精力有限、篇幅无限,很多货色没有开展,具体的能够翻阅一下实现代码。有问题,欢送留言