zswl 低代码平台
zswl 的低代码平台是面向开发的一款低代码,通过拖拽以及右侧编辑可以实现快速生成一个可渲染的前端代码,并生成到项目之中

为了方便生成,通过联邦模块集成在项目中,路径是/polvo
基础架构
组件引入
组件是通过Module Federation引入,代码维护在federation-components库中,相当于低代码和组件库的中间转化,每一个组件都需要编写index.js和setter.js
index.js是你需要做的一些渲染,可以做一些包裹或者 css 样式预设
settier.js是你在配置台需要渲染的 form 配置,点击组件的时候可以进行配置化的修改页面。 简单如下,主要是 element 中的渲染,同样是支持渲染器的一段 JSON

渲染器
渲染器写在 zswl@admin 里,考虑到这是个通用渲染器,可以在业务中直接调用,所以不维护在低代码平台
想要了解渲染器是怎么工作的,我们得先了解我们需要配置成一个怎么样的 json

可以看到比较重要的是$element,$render,并且都支持变量,以 {}包裹的字符串代表。
同时$element 预设了一些组件,可以通过一个简单的字符串直接匹配
渲染实现
具体的逻辑实现可以看packages/admin/src/renderer/index.js 中的 Wrapper 方法
具体代码不贴了,无非是针对配置台的四个 tab 中的 if、for、{}、$element、$render的分别处理
但这部分主要其实先要想好 JSON 应该如何定义,我觉得阿里的Formily是一套很棒的 json 化模板引擎。
这个插件系统是一个很好的设计,很值得学习。
插件系统
渲染器支持插件系统,返回的配置信息会和原有的配置合并,每一个节点元素(包含$render或者$element的)都会依次调用插件方法 比如你可以在原有的配置信息中加额外字段className:‘xxx’,原本这个配置不起任何作用的 然后在插件中判断节点中存在className,那么就返回props:{className:‘xxx’}, 这样就将原来的节点属性props中塞进去了className属性,这样就起作用了 插件方法必须返回Config,前一个插件返回的配置会当作下一个插件方法的参数.
比如下面这个插件是在渲染器为空的时候,给出一个拖拽提示的插件
const SelectionPlugin = (config) => {
const { $element, children } = config
const key = config[elementKeyDataName]
if ($element && key) {
const hidden = config[elementHiddenDataName]
const storeName = config[elementStoreDataName]
return {
$render(_store, node) {
const props = {
[elementKeyDataName]: key,
onClick: (e) => {
e.stopPropagation?.()
e.preventDefault?.()
store.setActiveId(key)
},
onMouseOver: (e) => {
e.stopPropagation?.()
store.setHoverId(key)
},
}
if (containerElements[$element]) {
const style = { paddingTop: 8, paddingBottom: 8 }
if ($element === 'page') {
style.minHeight = `calc( 100vh - 100px )`
}
props.style = style
}
if (containerElements[$element] && isEmpty(children)) {
const { height } = containerElements[$element]
node = cloneElement(
node,
props,
<div className={styles.dropPlace} style={{ height }}>
拖拽组件到这里
</div>
)
} else {
node = cloneElement(node, props)
}
return (
<Node
hidden={hidden}
storeName={storeName}
rendererStore={_store}
element={$element}
id={key}
>
{node}
</Node>
)
},
}
}
}预览面板
预览面板就是中间那块 ,主要是通过渲染器实现,但是额外通过上面讲的插件系统,给组件添加了拖拽、组件增删改查等额外功能,这样就能通过一个插件的区别实现低代码和平时页面上的区别
拖拽
拖拽暂时实现的比较简陋,通过引入react-dnd,对整体进行包裹一层,通过 useDrop来和 store 进行联动。
import { DndProvider } from 'react-dnd'
export default function Admin({ children }) {
return (
<ConfigProvider locale={zhCN} autoInsertSpaceInButton={false}>
<DndProvider backend={HTML5Backend}>{children}</DndProvider>
</ConfigProvider>
)
}当然拖拽肯定会涉及到位置的计算,这里使用现在最外层放置了一个全局的画布悬浮在最外层
.editor {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}然后通过ResizeObserver去监听组件 dom 的变化,拿到插入的 DOM和画布的属性,就可以在画布上依次向下添加元素。
而横向的元素则可以通过先移入分栏组件来实现
const resizeObserverRef = useRef(null)
const realDom = document.querySelector(`[${elementKeyDataName}="${id}"]`)
useEffect(() => {
if (realDom) {
resizeObserverRef.current = new ResizeObserver(() => {
forceUpdate()
})
resizeObserverRef.current.observe(realDom)
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect()
}
}
}
}, [realDom])
let style = {}
if (realDom) {
const { top, left, width, height } = realDom.getBoundingClientRect()
const editor = document.getElementById('editor')
const { left: editorLeft, top: editorTop } = editor.getBoundingClientRect()
style = {
height,
width,
top: top - editorTop,
left: left - editorLeft,
}
}组件的增删改查
增删的逻辑基本一致,只是触发时间有所不同,增加肯定是在你 drop 结束后,而删除只有主动触发时
// 删除
removeRendererStoreProp = (name) => {
if (name) {
const ast = parse(this.rendererStoreContent)
traverse(ast, {
ClassBody(path) {
path.node.body = path.node.body.filter((node) => {
const isClassProperty = t.isClassProperty(node)
return !isClassProperty || (isClassProperty && node.key.name !== name)
})
},
})
const res = generate(ast)
this.setRendererStore(res.code)
}
}特殊处理
因为Modal和 Drawer 都不能直接渲染在页面上,所以需要包裹一层,通过渲染一个Button来触发,这里用到一个知识点createPortal,在写全局组件的时候经常用到
if (['drawer', 'modal'].includes(element)) {
content = <ModalNode key={id} {...props} />
}
const action = createPortal(
<Tooltip title={`绑定的store变量:${storeName}`} placement="right">
<Button
type="dashed"
className={styles.hidden_element}
onClick={(e) => {
e.stopPropagation()
if (hidden) {
store.showNode(id)
} else {
store.hideNode(id)
}
}}
>
{title}
</Button>
</Tooltip>,
document.getElementById('element-hidden-container')
)配置台
主要分成四个模块,Store,style,Props和高级
style编辑器
本身的编辑是比较简单的,通过引入@monaco-editor/react,但需要注意,保存的时候需要配合 prettie/parser-postcss'保持代码的整洁。
Store
本身没有太大难点,和 style 编辑器一样,多了prettier/parser-babel格式化
组件联动
但多了一个难点,也就是和组件进行联动,我们引入组件的时候,需要同步修改 store 的代码,比如引入 form 的时候,store 里应该新增一行(当然可以通过组件写更丰富的配置
form = new FormStore({})这里的实现是通过定义了需要联动的组件,根据组件名进行相应的模板插入
const elementStorePropMap = {
table: {
name: 'tableStore',
value: `new TableStore({
request:(params)=>{
return {
list:[
{id:1,name:'张三',age:11},
]
}
}
})`,
},
}在拖拽的时候,传入 element,这个时候就知道是否需要往 Store中插入相应的 store,然后使用 ast 找到 Class 的 body 进入尾部插入
// src/components/Editor/Canvas/Node.js
const [{ isOver }, drop] = useDrop(
() => ({
accept: 'ele',
drop(item, monitor) {
const over = monitor.isOver({ shallow: true })
const { config } = item
if (over && canDrop) {
const storeObj = getElementStoreProp(config.$element)
if (storeObj) {
store.setRendererStoreProp(storeObj.name, storeObj.value)
// 绑定store
config.store = `{store.${storeObj.name}}`
config[elementStoreDataName] = storeObj.name
}
store.append(id, config)
}
},
collect: (monitor) => {
return {
canDrop,
isOver: monitor.isOver({ shallow: true }),
}
},
}),
[id, canDrop, element]
)
// src/components/Editor/store.jsÏ
setRendererStoreProp = (name, val) => {
const ast = parse(this.rendererStoreContent)
traverse(ast, {
ClassBody(path) {
const { body } = path.node
const exist = body.find((node) => {
return t.isClassProperty(node) && node.key.name === name
})
if (!exist) {
const newNode = t.classProperty(t.identifier(name), t.identifier(val || 'null'))
path.node.body.push(newNode)
}
},
})
const res = generate(ast)
this.setRendererStore(res.code)
}然后在删除的时候再调一下,所以前面在塞入的时候要记下相关的 AST key,方便后面删除
自此联动完成。
变量引入
另外这些相关变量如何引入也是一个问题
现在是通过固定引入一些变量,来实现
const rendererStoreParts = {
http,
makeAutoObservable,
TableStore,
ModalStore,
PageStore,
DescStore,
DrawerStore,
FormStore,
}
export function useRendererStore(storeContent) {
storeContent = storeContent || getRendererStore()
return useMemo(() => {
const Store = new Function(...Object.keys(rendererStoreParts), `return ${storeContent.trim()}`)(
...Object.values(rendererStoreParts)
)
return new Store()
}, [storeContent])
}这里如果想要更加智能化可以通过 AST解析一下,但也需要写对应的变量和引入地址,所以必要不大
高级
高级模块是对特殊事件和$if、$for的处理,配置台不复杂,主要在渲染器对他们的处理。
props
组件引入模块中写的setter.js,就渲染在这,本质上就是通过渲染成 form 表单,支持你可视化的更改这串 JSON
代码生成
这块是简单的生成一个渲染器,再将配置传入
/**
* 生成代码
*/
generateCode = async () => {
const { config, rendererStoreContent } = this
const configStr = formatJs(`
import { Renderer } from '@zswl/admin'
function Index(){
return <Renderer config={${JSON.stringify(optimizeConfig(config))}} />
}
export default Index
`)
console.log(configStr, rendererStoreContent)
// await pageTemplate.create({ path, ...optimize(values) })
// const blob = new Blob([configStr])
// FileSaver.saveAs(blob, 'test.js')
}
}后续可以生成一个 config 文件,再导入页面。
再后面可以灵活的生成组件代码,方便维护和修改,毕竟 json 还是需要一定的熟悉成本
优劣势分析
这是公司结合自身脚手架,组件库生成的低代码平台,同时和后端进行结合,可以通过低代码平台快速的对接增删改查接口和字段。
具体实现分为三块,分别是物料仓库、渲染引擎和参数配置。
物料仓库是基于公司组件库进行的二次封装,通过模块联邦引入,可与低代码平台解耦单独给业务使用,只需要给每个组件添加一份 setter 文件,用于低代码平台可视化配置,目前已积累 30+组件。
渲染引擎主要是对定义的 JSON进行对应的渲染,除了支持基础的 H5,组件库,还可以自定义函数渲染。另外还设计了一套插件系统,比如在低代码预览时,只需要加载一个插件就可以添加复制,删除,拖拽等功能,而生成的代码不受影响。
参数配置平台支持 css和组件参数的动态表单配置,也支持 if 和 for 动态的渲染组件,还有 Store 可以直接编辑,支持一些难以配置的功能。
出码是结合脚手架,可以在任意公司项目打开一个页面配置并生成到指定位置,大大降低了交互的复杂性。
后期用的多了,还做了一个模板市场,把平时用的比较多的模板保存下来,下次只需要选择模板,再选择下接口,就可以快速的生成一个后台页面。