快速搭建表单 demo 展示(数据流整理)

829 阅读4分钟

数据流

前言

日常开发中会有一些紧急的活动页面的需求或者需求变更频繁,需要进行快速开发和响应。之前有了解一个开源项目 luban-h5,年前也给该开源项目提过多次 pr(主要技术栈是 Vue JSX)。

最近公司有这样一个可视化搭建平台的业务需求就准备用它了,落地过程中发现一些问题(功能点待开发阶段如: 接口配置表单数据提交拖拽时候体验不好等)。所以我就 fork 了一份代码在源码上直接改,实现一套适合自己需求的搭建平台。经过(68次 commit 共 5587 行代码) 目前完成了第一版并上线。可以一起来看下基本的功能。

功能演示 bilibili视频
下面演示的代码,大多可以在luban-h5 源码中找到。

页面数据结构

{
  // work 级别数据 
  // new Work() 创建一个 h5 页面
  id: 1,
  page_mode:"h5_long_page",
  title:"标题",
  is_publish:true,
  pages:[
    // 页面级数据...
    // 如果是长页面模式只有一项
    {
      uuid: 234123123,
      // 
      elements:[
        {
          // 组件级数据...
          commonStyle: {
            top:100,
            left:100,
            // 组件的 css
          },
          name: "lbp-background", // 组件名(悬浮框展示的可使用的组件)
          pluginProps: {  // 组件接受的 props 
            // ...
          },
          methodList: [] // 组件的执行的事件,例如跳转可以添加到该数组中
          // ...
        }
      ]
    }
  ]
}

查看了 JSON Scheme 的定义,可以看到一个页面组成的最小颗粒度是 element, 多个 element 组成一个 page 对象,如果是多页面模式由多个 page 组成。

如何渲染

假设现在已经有如上这样一个数据对象,如何将这个对象转化成页面。需要用到 Vue JSX 的能力。查看 core/editor/canvas/preview.js 文件

    renderPreview() {
      const elements = this.elements || []
      const pageWrapperStyle = { height: this.height + 'px' || '100%', position: 'relative' }
      // 添加 component 标记
      return (
        <div component={true} style={pageWrapperStyle}>
          {
            elements.map((element, index) => {
              return <node-wrapper element={element} height={(this.height)}>
                {
                  this.$createElement(element.uuid, {
                    ...element.getPreviewData({ isNodeWrapper: false }),
                    nativeOn: this.genEventHandlers(element)
                  })
                }
              </node-wrapper>
            })
          }
        </div>
      )
    }

我们暂时不需要追究这些方法和功能是如何实现的,通过这段代码来了解大致的工作流程。读懂这段代码需要知道一些渲染函数的语法

在对渲染函数语法有一定了解后,这里的 this.$createElement 就是渲染函数,一般用 h 表示。

  • 接收的第一个参数是组件名、标签名,说明 element.uuid 就是组件的名称。
  • 接收第二个参数是对象包含了对组件或标签的细致描述,如: classstyleprops 等。
    • 说明 element.getPreveiewData() 方法 返回的是一个对象,包含了对元素的描述。
    • nativeOn 仅用于组件,我们这里使用 h 渲染的都是组件,监听组件原生事件例如 click 事件。所以 this.genEventHandlers(element) 返回的是对原生事件的监听。

通过这个流程的梳理,知道了如何将一个 page 对象包含的 elements 渲染出来。 elements 就是我们添加的一个个组件,我们通过拖拉拽的方式改变每个组件的样式,然后把这些组件数据保存在 page 中,在渲染的时候根据这些数据还原出页面。

继续看下 node-wrapper 组件是啥样的

  • this.element.getStyle() 返回一个 style 对象,决定了组件的 width、height、left、top 等等样式。
  • this.$slots.default 就是组件默认的 slot ,也就是我们上面的代码了。
  render(h) {
    // ...
    return (
      <div style={ this.element.getStyle({ position, height: this.height })}>
        {this.$slots.default}
      </div>
    )
  }

Element Model

export default class Element {
  // 接收一个 Element 组件作为参数
  constructor(ele) {
    this.name = ele.name
    this.pluginType = ele.name
    this.uuid = this.getUUID(ele)
    this.title = ele.title // 组件展示用的中文名称
    // 初始化数据...
  }
  getPreviewData({ position = 'static', isRem = false, mode = 'preview', isNodeWrapper = true } = {}) {
    const data = {
      // 与 edit 组件保持样式一致
      style: this.getStyle({ position, isNodeWrapper }),
      props: this.getProps({ mode }),
      attrs: this.getAttrs(),
      nativeOn: this.getEventHandlers()
    }
    return data
  }
  getStyle() {
    const style = {
      left: parsePx(pluginProps.left || commonStyle.left, isRem),
      width,
      // 判断组件是否使用默认高度, 如果使用默认高度,是否已经获取到组件原本高度,如果没有则使用 auto
      height: isAutoSettingHeightElement(this.name) && !isSettingHeight ? 'auto' : pluginProps.height || commonStyle.height + commonStyle.heightUnit,
      fontSize: parsePx(pluginProps.fontSize || commonStyle.fontSize, isRem),
      ...boxModel,
    }
    return style
  }
  getProps({ mode = 'edit' } = {}) {
    const pluginProps = mode === 'preview' ? bindData(this.pluginProps, window) : this.pluginProps
    return {
      ...pluginProps,
      disabled: disabledPluginsForEditMode.includes(this.name) && mode === 'edit'
    }
  }

通过一个 Element 数据结构来描述一个组件。并且提供一些方法,将 Element 的数据整合出来提供给其他地方使用。可以将 Element 看作数据存储地,每个组件就是通过这些存储的数据进行渲染。

页面创建流程:

点击新建页面 => new Work(work) 构建 work 层数据 => new Page(page) 构建 page 层数据 => new Element(element) 构建 element 数据。

编辑组件

右侧面板如何控制编辑区域的组件渲染?首先我们要选中一个组件才会可进行属性的修改,当我们选中组件就会将该组件设置为 editingElement

    // propConfig 取自 const vm = getVM(editingElement.name) vm.$options.props 
    // propKey 就是 props 的字段名
    renderPropFormItem(h, { propKey, propConfig }) {
      const editingElement = this.editingElement
      const item = propConfig.editor
      const data = {
        // style: { width: '100%' },
        props: {
          ...item.props || {},
          style: {
            // ...
          },
          on: {
          // https://vuejs.org/v2/guide/render-function.html#v-model
          [item.type === 'tinymce-editor' ? 'input' : 'change']: function(e) {
                if (isInputNumber) {
                  typeof e === 'number' && onElementPluginChange(e || 0, propKey)
                } else {
                  onElementPluginChange(e, propKey)
                }
              }
            }
          }
        }
      return (
        <div style={{ display: 'flex', justifyContent: 'center', width: '100%', ...item.style }}>
          {/* extra: 操作补充说明 */}
          { item.extra && <div slot='extra'>{typeof item.extra === 'function' ? item.extra(h) : item.extra}</div>}
          {/* 根据组件是否存在 label 来判断是否显示 */}
          {
            item.label ? (
              <a-popover placement='topLeft'>
                <template slot='content'>
                  {item.label}
                </template>
                { h(item.type, data) }
              </a-popover>
            ) : h(item.type, data)
          }

        </div>
      )
    }

代码在core/editor/right-panel/props.js 我们要渲染该组件的属性控制器。renderPropFormItem() 函数返回一个 h(item.type,data)来渲染出控制器组件。关注一下 propConfig 属性的值,它是组件中定义的 props,平时我们定义 props 一般定义 defaulttype 这两个属性。由于我们根据 props 来渲染一个控制器,所以我们需要定义更多的属性。

规范 props 定义

plugin-common-props.js 文件中规范不同数据类型 props 定义以及其返回的数据结构。增加 editor 字段用于描述控制器组件。

控制器如何做到修改属性值

h(item.type,data) 这行代码是渲染出控制器,当我们改变控制器的值时候会触发事件,也就是 data 对象中的 on 事件,执行 onElementPluginChange(e, propKey) 修改 editingElement.pluginsProps 对应 prop 的值。

  const onElementPluginChange = (e, key) => {
    const value = e.target ? e.target.value : e
    // 设置元素的 pluginProps 值
    this.setElementPluginProps({ [key]: value })
  }
  // core/store/modules/element.js
  setElementPluginProps(state, payload) {
    if (state.editingElement) {
      state.editingElement.pluginProps = {
        ...state.editingElement.pluginProps,
        ...payload
      }
    }
  },

继续来梳理下数据流程

  1. Element.pluginProps 默认包含组件的 props 的默认值的一个对象。例如 pluginProps 默认值为 {text:'按钮',color:'red'}
  2. 当我们修改控制器的值时候触发事件onElementPluginChange('text','修改后的值') 当前组件的 pluginProps 的值被修改为 {text:'修改后的值',color:'red'}
  3. 回到之前组件的渲染,通过 element.getPreviewData() 获取组件渲染需要的数据,由于我们修改 text 的值,那么 props 值发生改变,所以会触发组件的重新渲染。达到控制器修改,组件样式跟着变化的效果。
this.$createElement(element.uuid, {
  ...element.getPreviewData({ isNodeWrapper: false }),
  nativeOn: this.genEventHandlers(element)
})

结尾

梳理了一个可视化搭建的数据结构,有助于更好的理解和扩展页面功能。如果你对文章敢兴趣后续更新如何实现 form 表单的数据提交、拖拽过程辅助性的生成等。