【YAML 界面编辑器】基于 YAML 动态构建界面

7,175 阅读8分钟

动机

之前开发过动态表单相关应用以及可视化设计器,但发现如果从开发角度来看无论提供多么丰富的功能,要想把界面实现的更具灵活性和多样化需要对设计器进一步的扩展增加更多的功能,这样设计器功能变得越来越臃肿,学习使用设计器的成本大大增加,面对更加复杂的交互需求往往还不如直接代码开发实现起来方便

可视化编辑一般只适用于不专门从事开发工作的人使用,并且界面交互逻辑的需求具有较高的可复用性,对于复杂应用场景尤其是高度定制化的情况不能覆盖全部需求

在一些配置文件定义的数据采用 yaml 格式,看上去编辑起来很方便,比起用 json 数据写起来更简洁,于是打算尝试是否可以采用 yaml 数据来直接构建界面来降低开发成本

还有就是一年一度的 1024 节,除了参加各种奇怪的游戏活动和聚餐之类的,想想怎么解决眼前的问题更务实一些

效果预览

基于 《vjdesign - vue 界面可视化设计器》 的理念设计,先搞出一版 yaml 编辑器的 demo,理论上可以支持任何 html 元素和任何基于 vue3 开发的组件库,目前集成了 element-plus 组件库

demo 效果可访问 jrender-plus.netlify.app/ 目前还属于探索阶段,之后可能会更新一些功能

由于项目用了 tailwindcss 所以对于一些原生的 html 组件例如 input h1 等在没有定义样式的时候会存在无边框无默认样式的情况,只要单独加上样式就行,大体不影响功能

规则说明

基于示例 demo 整理了一下相关定义规则

基本属性

yaml 数据定义的根级包括 datasource listeners fields 三个属性

datasource 是数据源,当前界面的数据可在这里定义

listeners 用于监听,响应数据变化

fields 是界面布局的定义

属性类型描述
datasourceobject可提供数据和 http 访问功能
listenersarray监听属性变化,执行响应操作
fieldsarray界面布局定义

使用组件

fields 里定义界面布局,每个节点就是一个组件,组件基本属性定义

属性类型描述
componentstring组件名称,可以是任何 html 元素或 vue 组件的名称
propsobject组件属性,可以是 html 元素或 vue 组件的属性,对应 html 或 vue 组件库文档里的定义
childrenarray子组件集合

示例 1:显示一个带边框的层

fields:
  - component: div
    props:
      style:
        width: 200px
        border: 1px solid red
    children:
      - component: p
        props:
          innerText: content

效果:

image.png

示例 2:vue slot 特性

支持在组件中设置 slot 属性来渲染到 vue 组件对应 slot 中

fields:
  - component: el-input
    model: model.text
    props:
      style:
        width: auto
    children:
      - component: span
        slot: append
        props:
          innerText: append

效果:

image.png

数据源

数据源相当于当前界面里可用的数据,在 datasource 里定义,资源包括对象、对象的属性和远程获取的数据,目前支持对象和 fetch 两种类型,对象只支持定义数据,而 fetch 可以发起 http 请求获取远程数据或发送数据到服务

对象数据源

对象的数据源定义方式如下,在 datasource 中定义对象,每个对象的 props 里的属性就是在界面中该对象可使用的属性,props 的值可以是一个对象也可以是一个数组或一个值

datasource:
  rawdata:
    props:
      prop1: aaa
      prop2: 123

  listdata:
    props:
      - name: sel1
        value: 1
      - name: sel2
        value: 2

数据源定义之后可直接在组件属性表达式里使用,方法如下

fields:
  - component: p
    props:
      innerText: $:rawdata.prop1
  - component: p
    props:
      innerText: $:rawdata.prop2

model 是默认存在的数据源,是一个对象,在属性表达式中可以用 model.<属性> 的形式取值赋值

fetch 数据源

fetch 数据源用来提供 http 请求能力,让数据可从服务端获取或向服务发送数据,数据源 type 定义成 fetch 就表示一个 fetch 数据源

fetch 数据源的 props 属性定义如下

属性类型说明
urlstring请求资源 url
autoLoadboolean是否在界面加载后自动请求
typestring数据类型 json text
propsobjecthtml 原生 fetch 的 options
defaultDataarray/object在未请求或请求失败之后的默认数据
dataarray/object请求的结果数据,一般只在表达式里用

fetch 数据源还具有方法

方法说明
clear清空数据
fetch发起请求

示例 1:请求并显示列表数据

datasource:
  tabledata:
    type: fetch
    props:
      url: /data/table.json
      autoLoad: true
      type: json
      props:
        method: GET

fields:
  - component: div
    children:
      - component: el-button
        props:
          innerText: reload
          onClick: |
            $:() => {
              tabledata.clear()
              tabledata.fetch()
            }
  - component: el-table
    props:
      data: $:tabledata.data
    children:
      - component: el-table-column
        props:
          label: name
          prop: name
      - component: el-table-column
        props:
          label: remark
          prop: remark

效果:

record.gif

监听

监听定义在 listeners 中,用于响应数据的变化并触发操作行为,目前可监听据源里的对象属性(考虑是否加入组件 ref 支持实现直接监听组件属性)

监听一般定义形式如下:

listeners:
  # 监听一个
  - watch: $:model.num
    actions:
      - handler: $:() => console.log(model.num)
  # 监听多个
  - watch:
      - $:model.text
    actions:
      - handler: $:() => console.log('xxx')
      - handler: $:() => tabledata.fetch()
        condition: $:model.text && model.text.length === 5

一个监听的属性包括以下几项

属性类型说明
watcharray/object要监听的数据
actionsarray触发的行为
deepboolean深度监听
immediateboolean界面加载后立即执行

其中,actions 中一个操作行为的定义包括如下属性

属性类型说明
handlerfunction行为执行方法
conditionboolean触发操作的条件
timeoutnumber方法执行的延时(可能不太常用)

关于 conditiontimeout 这两个属性,在 handler 中代码实现都可以替代,但本着少写代码的原则还是留着吧

功能扩展

使用表达式

组件、数据源、监听里的任何属性都可以使用表达式实现数据联动

以前用过的表达式方案包括 @:<内容> 表示方法, @model.value:arguments[0] 表示更新值,#:文本 ${content} 表示模板文本,但是这种表达式多了可能还是会乱,而且 yaml 里 # @ 字符都需要转义,实际上对于表达式用一种形式就够了

$:<内容> 来表示一个表达式,定义了这种形式的属性都会被解析成程序实现,可以是属性值,也可以通过直接写 $:() => {} 来表示一个箭头函数

示例 1:属性值联动

fields:
  - component: input
    props:
      style:
        border: 1px solid silver
      value: $:model.text
      onInput: $:(e)=>model.text=e.target.value
  - component: p
    props:
      innerText: $:model.text

效果:

示例 2:实现点击事件

yaml 里可以通过属性值前加 | 来输入多行文本,复杂一点的脚本可换行写

fields:
  - component: button
    props:
      style:
        border: 1px solid
        padding: 0.75rem 1.25rem
      innerText: show
      onClick: |
        $:() => {
          alert('message')
        }

效果:

功能函数

功能函数类似于 excel 里的公式函数,提供表达式中一些常用函数操作,现在支持在数据源里定义一个包含方法的对象,并可在表达式中使用,因此常用函数就显得不那么重要了

这里只有一种情况需要注意,在 js 中表达式 aa.bb.cc.dd = value 中,父级属性为空或未定义则会报错,因此,提供了 SET 函数用来深度设置值

fields:
  - component: input
    props:
      value: $:model.obj.text
      onInput: $:(e) => SET(model, 'obj.text', e.target.value)
  - component: p
    props:
      innerText: $:model.obj.text

简化了空判断逻辑

快捷属性

集成了几种常用属性的扩展,进一步简化组件定义

示例 1:vue 组件 v-model

组件值的显示和更新是通过关联 modelValue 属性和响应 update:modelValue 事件实现,用 model 属性简化定义方式

fields:
  - component: el-input
    model: model.text
    props:
      style:
        width: auto
  - component: p
    props:
      innerText: $:model.text

示例 2:简化 element-plus 表单项定义

通过组件上定义 formItem 属性自动在外层加上 el-form-item

fields:
  - component: el-form
    props:
      labelWidth: 120px
    children:
      - component: el-input
        model: model.text
        formItem:
          label: input1
        props:
          style:
            width: auto

      - component: p
        formItem:
          label: display
        props:
          innerText: $:model.text

效果:

示例 3:控制元素显示隐藏

设置 condition 属性来控制元素是否显示

fields:
  - component: el-switch
    model: model.checked
  - component: p
    condition: $:model.checked
    props:
      innerText: hello!!

效果:

for 循环

示例 1:for 循环显示

当元素需要根据数组数据循环呈现时,可使用 for 属性来实现

datasource:
  listData:
    props:
      - aaaaa
      - bbbbb
      - ccccc

fields:
  - component: ul
    children:
      - component: li
        # 暂不支持自定义第二个参数名
        for: item in listData
        props:
          innerText: $:`${item} - ${index}`

其他特性

只要多开脑洞多尝试,就能发现更多特性

示例 1:加入一个 style 标签并设置样式

fields:
  - component: span
    props:
      class: custom
      innerText: 页面里自定义样式

  - component: style
    props:
      innerText: |
        .custom {
          color: red
        }

示例 2:在 datasource 里用表达式定义方法,并和下拉列表联动

datasource:
  list:
    props:
      - m1
      - m2
  methods:
    props:
      m1: $:() => { alert('输出1') }
      m2: $:() => { alert('输出2') }

fields:
  - component: el-select
    model: model.sel
    children:
      - component: el-option
        for: item in list
        props:
          value: $:item
          label: $:item
  - component: el-button
    props:
      onClick: $:methods[model.sel]
      innerText: 选择执行

效果:

依赖

版本
vue^3.2.20
monaco-editor^0.29.1
monaco-yaml^3.2.1
file-saver^2.0.5
js-yaml^4.1.0
element-plus^1.1.0-beta.24
ejs^3.1.6

关于

扩展性

示例的开发采用了跟以往 vjdesign - vue 界面可视化设计器 类似的可扩展设计模式,具体参考导出的界面代码

// 全局扩展,当前环境下任何渲染组件都启用这些扩展
useGlobalRender(
  ({ onBeforeRender, onRender, addDataSource, addFunction, addComponent }) => {
    // 当前组件渲染前还未做表达式解析时候的处理
    onBeforeRender(() => (field, next) => {
      next(field)
    })

    // 当前组件渲染前已经对表达式做了解析,拿到组件属性都是最终真实值
    onRender(() => (field, next) => {
      next(field)
    })

    // 增加一种类型的数据源
    addDataSource("类型", (options) => {})

    // 增加一个功能函数
    addFunction("Name", (callback) => (...args) => {})

    // 增加一个支持的自定义组件(未在vue全局注册只针对当前渲染,全局注册过就不需要了)
    addComponent("Name", Component)
  }
)

// 根级扩展,使用了该方法注册的扩展,下级节点中任何渲染组件都启用这些扩展
useRootRender(
  ({ onBeforeRender, onRender, addDataSource, addFunction, addComponent }) => {
    // ...
  }
)

可否直接导出 vue 组件

这个问题之前也考虑过,但 vue 组件相当于是已经编译好了的静态文件,每个组件在编译后就已经确定好了组件类型和属性,在这个方案中因为组件的呈现前可对每个组件进行预处理,处理结果可能是改变组件的属性值、或者直接改变要渲染的组件以及改变子元素,具有不确定性,最终输出也可能是类似于 <component :is="<组件名>" v-bind="props"></component> 的实现,所以导出的意义不大

持续改进

这个东西还在研究中,但现在实在太忙没那么多时间,之后要是有新的想法和建议可以再跟进一下,看看反响程度吧

比如通过 yaml 定义一个界面,这个界面就作为组件在另一个界面中复用,还有就是在一个组件里定义 slot 在复用的组件里 children 元素可输出到 slot 中(vue 的 slot 特性),目前都已经实现了只不过还没想好在编辑器中怎么编辑