实践,制作一个高扩展、可视化低代码前端,详实、完整

RxEditor是一款开源企业级可视化低代码前端,目标是可以编辑所有 HTML 基础的组件。比如支持 React、VUE、小程序等,目前仅实现了 React 版。

RxEditor运行快照:
image

项目地址:github.com/rxdrag/rxeditor

演示地址( Vercel 部署,需要科学的方法才能访问):rxeditor.vercel.app/

本文介绍RxEditor 设计实现方法,尽可能包括技术选型、软件架构、具体实现中碰到的各种小坑、预览渲染、物料热加载、前端逻辑编排等内容。

注:为了方便理解,文中引用的代码滤除了细节,是实际实现代码的简化版

设计原则

  • 尽量减少对组件的入侵,最大程度使用已有组件资源。
  • 配置优先,脚本辅助。
  • 基础功能原子化,组合式设计。
  • 物料插件化、逻辑组件化,尽可能动态插入系统。

    基础原理

    项目的设计目标,是能够通过拖拽的方式操作基于 HTML 制作的组件,如:调整这些组件的包含关系,并设置组件属性。

不管是 React、Vue、Angluar、小程序,还是别的类似前端框架,最终都是要把 JS 组件,以DOM节点的形式渲染出来。
image

编辑器(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(
(props: ButtonProps&{title?:string}}, ref) => {
const {title, …rest} = props
return (<AntdButton {…rest}>
{title}
)
}

进一步提取为高阶组件:

export function mapComponent(WrappedComponent: ReactComponent, maps: { [key: string]: string }): ReactComponent {

return memo(forwardRef((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,设计形态跟预览形态的区别:

### 设计形态
![image](https://img2023.cnblogs.com/blog/355003/202303/355003-20230301091738149-769159540.png)

### 预览形态
![image](https://img2023.cnblogs.com/blog/355003/202303/355003-20230301091745099-66247315.png)

这种组件,是需要特殊制作的,没有什么简单的办法,具体实现请参考源码。

# 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
| React.ComponentClass | string
export interface IComponentMaterial
extends IComponentConfig {
}

## 物料如何使用
物料定义,包含了一个组件的所有内容,直接注册进设计器,就可以使用。后面会有相关讲述。

## 物料的热加载
一个不想热加载的低代码平台,不是一个有出息的平台。但是,这个版本并没有来得及做热加载,后续版本会补上。这里简单分享前几个版本的热加载经验。

一个物料的定义是一个js对象,只要能拿到这个队形,就可以直接使用。热加载要解决的问题式拿到,具体拿到的方式可能有这么几种:

### import
js 原生import可以引入远程定义的物料,但是这个方式有个明显的缺点,就是不能跨域。如果没有跨域需求,可以用这种方式。

### webpack组件联邦
看网上介绍,这种方式似乎可行,但并没有尝试过,有类似尝试的朋友,欢迎留言。

### src引入
这种方式可行的,并且以前的版本中已经成功实现,具体做法是在编译的物料库里,把物料的定义挂载到全局window对象上,在编辑器里动态创建一个 script 元素,在load事件中,从全局window对象上拿到定义,具体实现:

function loadJS(src: string, clearCache = false): Promise {
const p = new Promise((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 {
const path = trimUrl(url);
const indexJs = path + “index.js”;

const p = new Promise((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 为主。
![image](https://img2023.cnblogs.com/blog/355003/202303/355003-20230301092312586-1403538788.png)

core包是整个设计器的基础,包含了 Redux 状态树、页面互动逻辑,编辑器的各种状态等。

react-core 包定义了 react 相关的基础组件,把 core 包功能封装为hooks。

react-shells 包,针对不同组件库的具体实现,比如 antd 或者 mui 等。

## 运行时架构
运行时包含三个包:ComponentRender、fieldy跟minions,前者依赖后两者。
![image](https://img2023.cnblogs.com/blog/355003/202303/355003-20230301092339275-403491432.png)

fieldy 是数据模型,用于组织页面数据,比如表单、字段等。

minions(小黄人)是控制器部分,用于控制页面的业务逻辑以及组件间的联动关系。

ComponertRender 负责把Schema 渲染为正常运行的页面。

## core包的设计
Core包是基于接口的设计,这样的设计方式有个明显的优点,就是清晰模块间的依赖关系,封装了具体的实现细节,能方便的单独替换某个模块。Core 包含的模块:
![image](https://img2023.cnblogs.com/blog/355003/202303/355003-20230301092525806-1750877039.png)

设计器引擎是 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): 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): 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)**,与界面交互的通用逻辑,基于事件模型实现,类图:
![image](https://img2023.cnblogs.com/blog/355003/202303/355003-20230301092632850-592862468.png)

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:

... } > ...

//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 的逻辑编排,才发现换个思路,可以把业务逻辑组件化,逻辑编排其实大有可为。

接下来,以打地鼠逻辑为例,说一下逻辑编排的实现思路。

打地鼠的界面:
image

左侧9个按钮是地鼠,每隔1秒会随机活动一只(变为蓝色),鼠标点击活动地鼠为击中(变为红色,并且积分器上记1分),右侧上方的输入框为计分器,下面是两个按钮用来开始或者结束游戏。

前面讲过,RxEditor 组件控制器是通过Context下发到子组件的,就是是说只有子组件能访问父组件的控制器,父组件访问不了子组件的控制器,兄弟组件之间也不能相互访问控制器。如果通过全局注册控制器的方式,组件之间就可以随意访问控制器,实现这种地鼠逻辑会简单些。但是,如果全局的方式注册控制器,会带来一个新的问题,就是动态表格的控制器不好注册,表格内的控件是动态生成的,他的控制器不好在设计时绑定,所以目前只考虑Context的实现方式。

游戏主控制器
在最顶层的组件 antd Row 上加一个一个游戏控制,控制器取名“游戏容器”:
image

这个控制器的可视化配置:
image

这个可视化配置的实现原理,改天再写吧,这里只介绍如何用它实现逻辑编排。

这是一个基于数据流的逻辑编排引擎,数据从节点的输入端口(左侧端口)流入,经过处理以后,再从输出端口(右侧端口)流出。流入与流出是基于回调的方式实现(类似Promise),并且每个节点可以有自己的状态,所以上图跟流程图有个本质的不同,流程图是单线脚本,而上图每一个节点是一个对象,有点像电影《超级奶爸》里面的小黄人,所以我给这个逻辑编排功能起名叫minions(小黄人),不同的是,这里的小黄人可以组合成另外一个小黄人,可以任意嵌套、任意组合。

这样的实现机制相当于把业务逻辑组件化了,然后再把业务逻辑组件可视化。

控制器的事件组件内置的,antd 的 Row 内置了三个事件:初始化、销毁、点击。可以在这些事件里实现具体的业务逻辑。本例中的初始化事件中,实现了打地鼠的主逻辑:
image

监听“运行”变量,如果为true,启动一个信号发生器,信号发生器每1000毫秒产生一个信号,游戏开始;如果为false,则停止信号发生器,游戏结束。信号发生器产生信号以后,传递给一个随机数生成器,用于生成一个代表地鼠编号的随机数,这个随机数赋值给变量”活跃地鼠“,地鼠组件会订阅变量”活跃地鼠“,如果变量值跟自己的编号一致,就把自己变为激活状态

交互相当于类的方法(实际上用一个类来实现),是自定义的。这里定义了三个交互:开始、结束、计分,一个交互就是一个类,可以通过Context下发到子组件,子组件可以实例化并用它们来组合自己的逻辑。

开始,就是把变量”运行“赋值为true,用于启动游戏。

结束,就是把变量”运行“赋值为false,用于结束游戏。

计分,就是把成绩+1

变量相当于组件控制器类的属性,外部可以通过 subscribeToVariableChange 方法订阅变量的变化。

地鼠控制器

在初始化事件中,地鼠订阅父组件”游戏容器“的活跃地鼠变量,通过条件判断节点判断是否跟自己编号一致,如果一致,把按钮的disabled属性设置为常量false,并启动延时器,延时2000毫秒以后,设置disabled为常量true,并重置按钮颜色(danger属性设置为false)。

点击事件的编排逻辑:
image

给danger属性赋值常量true(按钮变红),调用游戏容器的计分方法,增加积分。

其它组件也是类似的实现方式,这里就不展开了。具体的实现例子,请参考在线演示。

这里只是初步介绍了逻辑编排的大概原理,详细实现有机会再起一篇专门文章来写吧。

总结

本文介绍了一个可视化前端的实现原理,包括可视化编辑、运行时渲染等方面内容,所涵盖内容,可以构建一个完整低代码前端,只是限于精力有限、篇幅有限,很多东西没有展开,详细的可以翻阅一下实现代码。有问题,欢迎留言

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!