动机
之前开发过动态表单相关应用以及可视化设计器,但发现如果从开发角度来看无论提供多么丰富的功能,要想把界面实现的更具灵活性和多样化需要对设计器进一步的扩展增加更多的功能,这样设计器功能变得越来越臃肿,学习使用设计器的成本大大增加,面对更加复杂的交互需求往往还不如直接代码开发实现起来方便
可视化编辑一般只适用于不专门从事开发工作的人使用,并且界面交互逻辑的需求具有较高的可复用性,对于复杂应用场景尤其是高度定制化的情况不能覆盖全部需求
在一些配置文件定义的数据采用 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 是界面布局的定义
| 属性 | 类型 | 描述 |
|---|---|---|
| datasource | object | 可提供数据和 http 访问功能 |
| listeners | array | 监听属性变化,执行响应操作 |
| fields | array | 界面布局定义 |
使用组件
fields 里定义界面布局,每个节点就是一个组件,组件基本属性定义
| 属性 | 类型 | 描述 |
|---|---|---|
| component | string | 组件名称,可以是任何 html 元素或 vue 组件的名称 |
| props | object | 组件属性,可以是 html 元素或 vue 组件的属性,对应 html 或 vue 组件库文档里的定义 |
| children | array | 子组件集合 |
示例 1:显示一个带边框的层
fields:
- component: div
props:
style:
width: 200px
border: 1px solid red
children:
- component: p
props:
innerText: content
效果:
示例 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
效果:
数据源
数据源相当于当前界面里可用的数据,在 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 属性定义如下
| 属性 | 类型 | 说明 |
|---|---|---|
| url | string | 请求资源 url |
| autoLoad | boolean | 是否在界面加载后自动请求 |
| type | string | 数据类型 json text |
| props | object | html 原生 fetch 的 options |
| defaultData | array/object | 在未请求或请求失败之后的默认数据 |
| data | array/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
效果:
监听
监听定义在 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
一个监听的属性包括以下几项
| 属性 | 类型 | 说明 |
|---|---|---|
| watch | array/object | 要监听的数据 |
| actions | array | 触发的行为 |
| deep | boolean | 深度监听 |
| immediate | boolean | 界面加载后立即执行 |
其中,actions 中一个操作行为的定义包括如下属性
| 属性 | 类型 | 说明 |
|---|---|---|
| handler | function | 行为执行方法 |
| condition | boolean | 触发操作的条件 |
| timeout | number | 方法执行的延时(可能不太常用) |
关于
condition和timeout这两个属性,在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 特性),目前都已经实现了只不过还没想好在编辑器中怎么编辑