文章
问答
冒泡
从零开发一个表单设计器

前言

为什么要自己实现一个表单设计器

网上找了很久没有发现比较好的,比较好用的基本都不开源,formilyjs配套的designable功能比较强大,用起来也不错。但是很可惜,已经不维护了。另外,designable设计的很复杂,改造成本很高,对于大多数项目而言很多是用不到的。并且designable和antd深度绑定了。其他前端组件库很难与其集成。所以,我们决定,自己实现一套,一次性解决问题。 designable 地址 https://github.com/alibaba/designable

由于designable 停止维护了,我们提供了designable的基于antd v5的修复版本,有需要的可以直接拿源码去构建使用。https://github.com/trionesdev/triones-designable

期望目标

我们希望可以实现一个轻量的,可以适配不同组件库的方案。可以让使用者,自由的选择使用什么组件库。并且可以跟进规范,自由的扩展组件功能。

技术方案

状态管理

react自带的context,mobx还是@formily/reactive 根据调研比较 formilyjs 基本可以满足我们在表单设计上的需求,并且支持react和vue,以及多种组件库的支持。@formily/reactive也满足对于数据同步的相关需求。

拖拽动作

是基于dnd这种drag事件 还是基于mouse动作进行精细处理?如果只是实现拖拽dnd方案就可以,比较简单,但是如果想要画出操作的辅助线,就只能基于mousemove来执行,因为drag事件的时候,是无法监听mousemove的。

辅助线

辅助线很多是在组件外包一层,这种方案实现最简单,但是用过组件库的大多都注意到FormItem 组件基本都会有marginBottom属性。如果用外层元素包裹的话,就会明显的感觉大小不一致。这个时候,我们通过获取元素的实际大小,绘制一个图形,作为辅助线,视觉效果就会好很多。

样式处理

为了解决不同组件库采用的样式方案可能不一样的问题,我们没有采用less或者sass这样的方案,而是选择了css-in-js的解决方案@emotion/styled

实现方案

鉴于designable的优秀设计,我们大量的借鉴了其设计思路。

整体布局

依旧采用了通用的左中右布局,左边是工具面板,中间是工作台,右边是属性编辑

组件定义

为了给每个组件增加设计属性,所以需要对组件进行扩展。增加Resource属性。

export type IResource = {
    name?: string
    icon?: string
    schema?: ISchema
    designerProps?: {
        propsSchema?: ISchema
        defaultProps?: any
    },
    node?: TreeNode
    [key: string]: any
}

export type TdFC<P = {}> = React.FC<P> & {
    Resource?: IResource[]
}

以Input组件为例,对formiy的Input组件进行扩展

import {DesignerCore, TdFC} from "@trionesdev/form-designer-react";
import {Input as FormilyInput} from '@formily/antd-v5'
import React from "react";
import createResource = DesignerCore.createResource;


export const Input: TdFC<React.ComponentProps<typeof FormilyInput>> = FormilyInput

Input.Resource = createResource([
    {
        name: 'Input',
        icon: 'InputIcon',
        title: '输入框',
        componentName: 'Field',
        schema: {
            type: 'string',
            title: '输入框',
            'x-decorator': 'FormItem',
            'x-component': 'Input',
            required: true,
        },
        designerProps: {
            propsSchema: {
                type: 'object',
                properties: {
                    title: {
                        type: 'string',
                        title: '标题',
                        'x-decorator': 'FormItem',
                        'x-component': 'Input',
                    },
                    required: {
                        type: 'string',
                        title: '是否必填',
                        'x-decorator': 'FormItem',
                        'x-component': 'Switch',
                    },
                }
            }
        }
    },
    {
        name: 'Input.TextArea',
        icon: 'TextAreaIcon',
        title: '多行输入框',
        componentName: 'Field',
        schema: {
            type: 'string',
            title: '文本框',
            required: true,
            'x-decorator': 'FormItem',
            'x-component': 'Input.TextArea'
        },
        designerProps: {
            propsSchema: {
                type: 'object',
                properties: {
                    title: {
                        type: 'string',
                        title: '标题',
                        'x-decorator': 'FormItem',
                        'x-component': 'Input',
                    },
                }
            }
        }
    }
])
每个组件都会注册一个TreeNode对象

 

export const createResource = (resources: IResource[]): IResource[] => {
    return _.reduce(resources, (result: any, source: any) => {
        return _.concat(result, source)
    }, []).map((item: any) => _.assign(item, {
        node: new TreeNode({
            isSourceNode: true,
            componentName: item.componentName,
            schema: item.schema,
        })
    }))
}

 

工作台组件渲染

首先,我们确定好我们的数据结构是一个树形的,参考html的dom树。工具栏的组件,在首先加载到一个节点池里。在工作区,根节点就是Form组件,拖入设计区的组件根据id找到对应的TreeNode对象,添加到目标节点的children中,然后通过递归来渲染节点。总的来说,整个组件,都是围绕TreeNode来进行操作的。

export interface ITreeNode {

    children?: ITreeNode[]
    id?: string
    componentName?: string
    isSourceNode?: boolean
    schema?: ISchema
    operation?: Operation

    [key: string]: any
}

const TreeNodes = new Map<string, TreeNode>()

export class TreeNode {
    parent?: TreeNode
    root?: TreeNode
    children: TreeNode[] = []
    id: string
    componentName: string
    isSourceNode?: boolean
    schema?: ISchema
    operation?: Operation

    constructor(node: ITreeNode, parent?: TreeNode) {
        if (node instanceof TreeNode) {
            return node
        }
        this.id = node.id || `td_${randomstring.generate({
            length: 10,
            charset: 'alphabetic'
        })}`
        this.root = node?.root
        this.parent = node?.parent
        this.isSourceNode = node?.isSourceNode
        this.componentName = node?.componentName || 'Field'
        this.schema = node?.schema
        this.operation = node?.operation || parent?.operation

        if (parent) {
            this.root = parent?.root
            this.parent = parent
        } else {
            this.root = this
            this.parent = null
        }

        TreeNodes.set(this.id, this) //同步设置节点到TreeNodes

        if (node) {
            this.from(node)
        }
        this.makeObservable()
    }

    makeObservable() {
        define(this, {
            children: observable.shallow,
            schema: observable,
            designerProps: observable.computed,
            append: action
        })
        reaction(() => {
            return this.children
        }, () => {
            if (!this.isSourceNode) {
                this.operation.onChange(`${this.id} children changed`)
            }
        })

    }


    from(node?: ITreeNode) {
        if (!node) return

        if (node.id && node.id !== this.id) {
            TreeNodes.delete(this.id)
            TreeNodes.set(node.id, this)
            this.id = node.id
        }
        if (node.componentName) {
            this.componentName = node.componentName
        }
        this.schema = node.schema ?? {}

        if (node.children) {
            this.children =
                node.children?.map?.((node) => {
                    node.operation = this.operation
                    return new TreeNode(node,this)
                }) || []
        }
    }

    get designerProps() {
        return GlobalStore.getDesignerResourceByNode(this)?.designerProps?.propsSchema || {}
    }

    get title() {
        return GlobalStore.getDesignerResourceByNode(this)?.title
    }

    get displayName() {
        return _.get(this.schema, 'title', GlobalStore.getDesignerResourceByNode(this)?.title)
    }

    get icon() {
        return GlobalStore.getDesignerResourceByNode(this)?.icon
    }

    get droppable() {
        return GlobalStore.getDesignerResourceByNode(this)?.droppable || false
    }

    findNodeById(id: string) {
        return TreeNodes.get(id)
    }

    get index() {
        if (this.parent === this || !this.parent) return 0
        return this.parent.children.indexOf(this)
    }

    get next() {
        if (this.parent === this || !this.parent) return
        return this.parent.children[this.index + 1]
    }

    /**
     * 在当前节点内添加节点
     * @param nodes
     */
    append(...nodes: TreeNode[]) {
        const droppableNode = this.droppableNode() //找到最近的可以拖入的节点
        if (droppableNode) {
            const appendNodes = this.restNodes(nodes, droppableNode);
            droppableNode.children = _.concat(droppableNode.children, appendNodes)
            this.operation.setSelectionNode(appendNodes[0]) //设置新增节点为选中状态
        }
    }

    /**
     * 插入当前节点之前
     * @param nodes
     */
    insertBefore(...nodes: TreeNode[]) {
        const insertNodes = _.filter(nodes, (node: TreeNode) => {
            return node.id !== this.id
        })
        if (_.isEmpty(insertNodes)) {
            return
        }
        const droppableNode = this.droppableNode() //找到最近的可以拖入的节点
        if (droppableNode) {
            const dropNodes = this.restNodes(insertNodes, droppableNode);
            const index = _.findIndex(droppableNode.children, (node: TreeNode) => {
                return node.id === this.id
            })
            const dropNodesIds = _.map(dropNodes, (node: TreeNode) => {
                return node.id
            })
            const beforeNodes = _.filter(droppableNode.children.slice(0, index), (node: TreeNode) => {
                return !_.includes(dropNodesIds, node.id)
            });
            const afterNodes = _.filter(droppableNode.children.slice(index), (node: TreeNode) => {
                return !_.includes(dropNodesIds, node.id)
            });
            droppableNode.children = _.concat(beforeNodes, dropNodes, afterNodes)
            this.operation.setSelectionNode(dropNodes[0])
        }
    }

    /**
     * 插入当前节点后面
     * @param nodes
     */
    insertAfter(...nodes: TreeNode[]) {
        const insertNodes = _.filter(nodes, (node: TreeNode) => {
            return node.id !== this.id
        })
        if (_.isEmpty(insertNodes)) {
            return
        }
        const droppableNode = this.droppableNode() //找到最近的可以拖入的节点
        if (droppableNode) {
            const dropNodes = this.restNodes(insertNodes, droppableNode);
            const index = _.findIndex(droppableNode.children, (node: TreeNode) => {
                return node.id === this.id
            })
            const dropNodesIds = _.map(dropNodes, (node: TreeNode) => {
                return node.id
            })
            const beforeNodes = _.filter(droppableNode.children.slice(0, index + 1), (node: TreeNode) => {
                return !_.includes(dropNodesIds, node.id)
            });
            const afterNodes = _.filter(droppableNode.children.slice(index + 1), (node: TreeNode) => {
                return !_.includes(dropNodesIds, node.id)
            });
            droppableNode.children = _.concat(beforeNodes, dropNodes, afterNodes)
            this.operation.setSelectionNode(dropNodes[0])
        }
    }

    remove() {
        const index = this.parent.children.indexOf(this)

        this.parent.children = this.parent.children.filter((node) => {
            return node.id !== this.id
        })
        if (_.isEmpty(this.parent.children)) {
            this.operation.selectionNode = this.parent
        } else {
            if (index > 0) {
                this.operation.selectionNode = this.parent.children[index - 1]
            } else {
                this.operation.selectionNode = this.parent.children[index]
            }
        }
        TreeNodes.delete(this.id)
    }

    /**
     * 最近的可以拖入的节点
     */
    droppableNode() {
        if (this.isSourceNode) {
            return
        } else {
            if (this.droppable) {
                return this;
            } else {
                return this.parent?.droppableNode()
            }
        }
    }

    private restNodes(nodes: TreeNode[], parentNode: TreeNode): TreeNode[] {
        return nodes.map(node => {
            if (node.isSourceNode) {
                return node.clone(parentNode);
            } else {
                if (!node.parent) {
                    node.parent = parentNode
                }
                return node
            }

        })
    }

    clone(parent?: TreeNode): TreeNode {
        const newNode = new TreeNode({
            componentName: this.componentName,
            schema: _.cloneDeep(this.schema), //一定要深拷贝,否则数据会干扰,都是直接用的source组件的数据
            isSourceNode: false,
            operation: parent.operation
        },parent)
        return newNode
    }

    get layout() {
        if (this == this.root) {
            return 'vertical'
        }
        //TODO 根据组件类型获取布局
        return 'vertical'
    }
}

 

属性编辑

属性编辑好,要自动刷新工作台组件的的渲染,这里主要是依赖 @formily/reactive 实现的,所以,@formily/reactive 在整个项目中是至关重要的,数据的同步都依赖它。这里我们为了摆脱对组件库的依赖,属性编辑栏不依赖任何组件,而是使用方,自己设置需要哪些组件来满足业务。

type SettingsPanelProps = {
    className?: string,
    components?: Record<string, JSXComponent>
    /**
     * form 组件属性
     */
    formProps?: Omit<any, 'form'>
}

const SchemaField = createSchemaField({
    components: {},
})

export const SettingsPanel: React.FC<SettingsPanelProps> = observer(({
                                                                         className,
                                                                         components,
                                                                         formProps
                                                                     }) => {
    const operation = useOperation()
    const {selectionNode} = operation

    const form = useMemo(() => {
        return createForm({
            initialValues: selectionNode?.designerProps?.defaultProps,
            values: selectionNode?.schema,
            effects(form) {

            }
        })
    }, [selectionNode, selectionNode?.id])

    /**
     * 如果有Form组件,则使用Form组件包裹,如果没有则使用FormProvider包裹
     * @param children
     */
    const formRender = (children: React.ReactNode) => {
        const formComp = components['Form']
        if (formComp) {
            return React.createElement(formComp, {form, ...formProps}, children)
        } else {
            return React.createElement(FormProvider, {form}, children)
        }
    }

    return <SettingsPanelStyled className={className}>
        <div className={`properties-header`}>
            {selectionNode && <>
                <IconWidget icon={GlobalStore.getIcon(selectionNode.icon)}/>
                <span>{selectionNode.title}</span>
            </>}
        </div>
        <div className={`properties-body`}>
            {formRender(<SchemaField components={components} schema={selectionNode?.designerProps}/>)}
        </div>
    </SettingsPanelStyled>
})

以上就是整个表单设计器最核心的部分了,其他的细节处理,可以自己查看源码。

设计器使用

由于需要摆脱组件库的限制,所以,具体的业务组件,都是在使用处进行封装的

为了避免名称冲突,建议属性面板进行二次封装

AntdSettingsPanel

import React from "react";
import {SettingsPanel} from "@trionesdev/form-designer-react";
import {Form, FormItem, Input, Select, Switch} from "@formily/antd-v5";

export const AntdSettingsPanel = () => {
    return <SettingsPanel components={{Form, FormItem, Input, Select,Switch}} formProps={{
        layout: "vertical"
    }}/>
}

使用

function App() {

    GlobalStore.registerIcons(icons)

    const handleOnChange = (value: any) => {
        console.log("[TreeInfo]value", value)
    }

    const value = {
        "x-id": "td_tXAABwaZAE",
        "type": "object",
        "x-component-name": "Form",
        "properties": {
            "td_rszikvOzVh": {
                "type": "string",
                "title": "文本框",
                "required": true,
                "x-decorator": "FormItem",
                "x-component": "Input.TextArea",
                "x-id": "td_rszikvOzVh",
                "x-index": 0,
                "x-component-name": "Field",
            },
            "td_AaMFjiFfps": {
                "title": "性别",
                "x-decorator": "FormItem",
                "x-component": "Select",
                "x-id": "td_AaMFjiFfps",
                "x-index": 1,
                "x-component-name": "Field",
            }
        }
    }

    return (
        <div className="App">
            <FormDesigner value={value} onChange={handleOnChange}>
                <StudioPanel>
                    <CompositePanel style={{width: 300}}>
                        <ResourceWidget title={`基础组件`} sources={[Input, Select, Password]}/>
                    </CompositePanel>
                    <WorkspacePanel>
                        <ViewportPanel>
                            <ViewPanel >
                                <ComponentsWidget components={{Form, Input, Select, Password}}/>
                            </ViewPanel>
                        </ViewportPanel>
                    </WorkspacePanel>
                    <AntdSettingsPanel/>
                </StudioPanel>
            </FormDesigner>
        </div>
    );
}

export default App;

 

为了解决自动报错的需求,可自定义onChange函数,进行数据变化的回调处理,但是不建议用在FormItem中,毕竟此处数据变化频繁,且结构复杂。
 

运行效果

PC模式
Mobile 模式

以上就是react版本的 triones-form-designer 后续会推出vue版本。等我们在项目中使用,没什么问题了会推送到npmjs仓库。

注意: 基于 formily/reactive 订阅对象的变化 需要用 observer , 并不能被useEffect获取

github地址:https://github.com/trionesdev/triones-form-designer

演示地址:https://trionesdev.github.io/triones-form-designer/

react
低代码
表单设计器

关于作者

落雁沙
非典型码农
获得点赞
文章被阅读