zswl 低代码平台

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

为了方便生成,通过联邦模块集成在项目中,路径是/polvo

基础架构

组件引入

组件是通过Module Federation引入,代码维护在federation-components库中,相当于低代码和组件库的中间转化,每一个组件都需要编写index.jssetter.js

index.js是你需要做的一些渲染,可以做一些包裹或者 css 样式预设

settier.js是你在配置台需要渲染的 form 配置,点击组件的时候可以进行配置化的修改页面。 简单如下,主要是 element 中的渲染,同样是支持渲染器的一段 JSON

渲染器

渲染器写在 zswl@admin 里,考虑到这是个通用渲染器,可以在业务中直接调用,所以不维护在低代码平台

想要了解渲染器是怎么工作的,我们得先了解我们需要配置成一个怎么样的 json

可以看到比较重要的是$element,$render,并且都支持变量,以 {}包裹的字符串代表。

同时$element 预设了一些组件,可以通过一个简单的字符串直接匹配

渲染实现

具体的逻辑实现可以看packages/admin/src/renderer/index.js 中的 Wrapper 方法

具体代码不贴了,无非是针对配置台的四个 tab 中的 iffor{}$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 可以直接编辑,支持一些难以配置的功能。

出码是结合脚手架,可以在任意公司项目打开一个页面配置并生成到指定位置,大大降低了交互的复杂性。

后期用的多了,还做了一个模板市场,把平时用的比较多的模板保存下来,下次只需要选择模板,再选择下接口,就可以快速的生成一个后台页面。