导读
本文是《低代码工具调研》系列衍生的文章。为了保证主线文章的结构简洁,所以另起了这篇专门分析 amis 的文章。主要就是通过对其核心概念的分析解读,来快速深入地理解其设计思想,达到调研的目的。
简介
amis 的官网给的定义:
amis 是一个低代码前端框架,它使用 JSON 配置来生成页面,可以减少页面开发工作量,极大提升效率。
基本用法如下:
{
"title": "浏览器内核对 CSS 的支持情况",
"remark": "嘿,不保证数据准确性",
"type": "page",
"body": {
"type": "crud",
"draggable": true,
"syncLocation": false,
"api": "/amis/api/mock2/sample",
"keepItemSelectionOnPageChange": true,
"autoGenerateFilter": true,
"bulkActions": [
{
"type": "button",
"label": "批量删除",
"actionType": "ajax",
"api": "delete:/amis/api/mock2/sample/${ids|raw}",
"confirmText": "确定要批量删除?"
},
// ...
],
"quickSaveApi": "/amis/api/mock2/sample/bulkUpdate",
"quickSaveItemApi": "/amis/api/mock2/sample/$id",
"columns": [
// ...
]
}
}
amis 的文档写的非常好,非常详细。想快速理解 amis,就要先理解其核心概念,这是最事半功倍的方法。 本文会针对性的选取一些关键点展开说说,更多信息可以看 amis 的文档,下面开始。
核心概念
最基本的概念是配置与组件,这块不复杂,说穿了,页面或者组件就是一个树形结构,每个节点都有不同的属性,这用 JSON 来表达可以说没有什么难度,关键就是属性如何设计。普通的属性设计很简单,关键是下面提到的一些难点,我们接着看。
数据域与数据链
这个概念太关键了,解决数据流转和共享的问题就靠它了,所以单拎出来说说。先说一下最基本的使用。
data
属性。一些特定组件,只要设置了 data 属性,其子组件就可以用 ${xxx}
表达式来使用 data.xxx
的值。
同时,还有一个 initApi
属性,即初始化的请求,返回的数据只要满足指定格式,也可以通过 ${xxx}
访问 response.data.xxx
的值。当二者同时存在时,会将值合并。具体使用如下:
{
"type": "page",
"initApi": "/amis/api/mock2/page/initData",
"data": { "label": "date" },
"body": "${label} is ${date}"
}
/* API Response:
{
"status": 0,
"msg": "",
"data": {
"title": "Test Page Component",
"date": "2017-10-13"
}
}
*/
// Result: date is 2017-10-13
目前还比较好理解,接下来是稍微需要动一点脑子,而且非常重要的内容了。
试想一下,如果嵌套了很多层的组件,都带有 data 属性,会是什么样子?子孙组件可以跨级获取祖先组件的数据吗?如果出现字段名相同的情况要怎么办?
这个问题非常类似于 JS 当中的原型链,或者变量作用域。其实有一定编程经验的同学说到这里应该已经知道解决方案了,甚至当这两个概念出来的时候也已经猜的八九不离十了。官网给的解释是:
数据链的特性是,当前组件在遇到获取变量的场景(例如模板渲染、展示表单数据、渲染列表等等)时:
- 首先会先尝试在当前组件的数据域中寻找变量,当成功找到变量时,通过数据映射完成渲染,停止寻找过程;
- 当在当前数据域中没有找到变量时,则向上寻找,在父组件的数据域中,重复步骤
1
和2
;- 一直寻找,直到顶级节点,也就是
page
节点,寻找过程结束。- 但是如果 url 中有参数,还会继续向上查找这层,所以很多时候配置中可以直接
${id}
取地址栏参数。
答案很明显了,简单来说就是逐级上找,找到了就停下,最顶层是 URL 的参数。跟页面树一样,数据链实际上也是一个树,链条之间是不能互相访问的,可以简单的将此理解为数据域。至于跨链之间如何访问,且看后文。
表达式
在笔者看来模板、数据映射这两个核心概念的背后都是表达式,所以我们重点来说说表达式,搞明白了再去看另外两个概念就很容易理解了。
先说结论,amis 还是使用 ${xxx}
的语法来表示表达式,语法基于 JS 并且内置了大量的函数来支持表达式的功能,用法如下:
{
"type": "page",
"body": [
{
"type": "form",
"wrapWithPanel": false,
"body": [
{
"type": "static",
"label": "IF(true, 2, 3)",
"tpl": "${IF(true, 2, 3)}"
},
{
"type": "static",
"label": "MAX(1, -1, 2, 3, 5, -9)",
"tpl": "${MAX(1, -1, 2, 3, 5, -9)}"
},
{
"type": "static",
"label": "ROUND(3.5)",
"tpl": "${ROUND(3.5)}"
},
{
"type": "static",
"label": "AVG(4, 6, 10, 10, 10)",
"tpl": "${AVG(4, 6, 10, 10, 10)}"
},
{
"type": "static",
"label": "UPPERMONEY(7682.01)",
"tpl": "${UPPERMONEY(7682.01)}"
},
{
"type": "static",
"label": "TIMESTAMP(DATE(2021, 11, 21, 0, 0, 0), 'x')",
"tpl": "${TIMESTAMP(DATE(2021, 11, 21, 0, 0, 0), 'x')}"
},
{
"type": "static",
"label": "DATETOSTR(NOW(), 'YYYY-MM-DD')",
"tpl": "${DATETOSTR(NOW(), 'YYYY-MM-DD')}"
}
]
}
]
}
这里引起笔者注意的有 2 点。第一,文档中有提到:
原来的表达式用的就是原生 js,灵活性虽大,但是安全性不佳,为了与后端公式保持统一,故引入了新的规则……
笔者不禁要问,为什么会有安全性的问题呢?暂时得到的结论是:之前用纯 js 语法,应该是直接就把这段代码交给浏览器去运行了,运行产生的影响其实并不可控。当用 ${}
包裹之后,就可以明确的将 js 代码控制在一定范围,而且可以针对性的进行安全行的的处理,比如语法检测、在沙箱运行什么的。
但是又有一个问题了,不用 ${}
包裹就不能做安全处理了吗?笔者认为还是可以的。但是这样就与模板的功能耦合了,模板解析需要承载的逻辑就多了,所以这样做应该是考虑到功能解耦的因素,为了更方便的维护。
第二,文档提供的函数与 Excel 的函数高度一致,可以说一定程度的减少了学习成本。本以为采用了已经有的轮子,但是稍微看了下发现是自己造的,但是以目前笔者的研究程度还不能武断的说是重复造轮子。不管怎样,尽量贴合 Excel 函数的设计这点,还是考虑的比较周全的。
联动
理解了数据链和表达式,实际上对于联动就已经理解了一大半了。无外乎就是一个组件能够响应其他组件数据的变化,本质还是数据之间的共享。但是在数据链中也说了,不同链之间是无法互相访问数据的,那怎么解决这个问题呢?这里就要说到 target
属性了。组件的 name
属性是其唯一标识,所以通过 target: form1,form2
这种形式,就能把当前组件的数据发过去了,这着实是一个比较巧妙的设计。用法如下,更多详情可以看文档。
{
"type": "page",
"body": [
{
"type": "form",
"title": "form1",
"mode": "horizontal",
"api": "/amis/api/mock2/form/saveForm",
"body": [
{
"label": "Name",
"type": "input-text",
"name": "name"
},
{
"label": "Email",
"type": "input-text",
"name": "email"
},
{
"label": "Company",
"type": "input-text",
"name": "company"
}
],
"actions": [
{
"type": "action",
"actionType": "reload",
"label": "发送到 form2",
"target": "form2?name=${name}&email=${email}"
}
]
},
{
"type": "form",
"title": "form2",
"name": "form2",
"mode": "horizontal",
"api": "/amis/api/mock2/form/saveForm",
"body": [
{
"label": "MyName",
"type": "input-text",
"name": "name"
},
{
"label": "MyEmail",
"type": "input-text",
"name": "email"
},
{
"label": "Company",
"type": "input-text",
"name": "company"
}
]
}
]
}
事件动作
不出所料,事件这块的内容果然非常多,它跟表达式可以算是内容最多的两块了。的确,事件和表达式,本质都是代码实现的一堆逻辑,非常难穷举,十分考验设计者对计算机理解的深度,我们且来看一下 amis 是怎么解这道题的。先看下基本用法:
{
"type": "page",
"body": [
{
"type": "button",
"label": "尝试点击、鼠标移入/移出",
"level": "primary",
"onEvent": { // 1
"click": { // 2
"actions": [ // 3
{ // 4
"actionType": "toast",
"args": {
"msgType": "info",
"msg": "派发点击事件"
}
}
]
},
"mouseenter": { // 2
"actions": [ // 3
{ // 4
"actionType": "toast",
"args": {
"msgType": "info",
"msg": "派发鼠标移入事件"
}
}
]
},
"mouseleave": { // 2
"actions": [ // 3
{ // 4
"actionType": "toast",
"args": {
"msgType": "info",
"msg": "派发鼠标移出事件"
}
}
]
}
}
}
]
}
从上例可以看出,标了 1、2、3 的那几层比较固定,也比较好理解,最关键的是第 4 层,也就是对于动作
也就是 action
的抽象描述,文档的描述是:
动作包含
通用动作
、组件动作
、广播动作
、自定义动作
,可以通过配置actionType
来指定具体执行什么动作。
我们来分别看一下。
通用动作
通用动作包含发送 http 请求、跳转链接、浏览器回退、浏览器刷新、打开/关闭弹窗、打开/关闭抽屉、打开对话框、弹出 Toast 提示、复制、发送邮件、刷新、控制显示隐藏、控制启用禁用状态、更新数据。
好家伙,基本就是枚举了,把常用的场景都覆盖到了,可以说很暴力了。这块没有太多可分析的,基本使用查文档就好了,值得注意的是 自定义 JS
,使用如下:
{
"type": "page",
"body": [
{
"type": "button",
"label": "发送一个 http 请求",
"level": "primary",
"onEvent": {
"click": {
"actions": [
{
"actionType": "custom",
"script": "doAction({actionType: 'ajax', args: {api: '/amis/api/mock2/form/saveForm'}});\n //event.stopPropagation();"
}
]
}
}
}
]
}
看起来怪怪的,看下说明
如果是在 js 中也能直接写函数,这个函数可以接收到 3 个参数,分别是:
- context,上下文信息
- doAction 方法,用于调用其它动作
- event,事件传递的数据,以及可以禁止
看了高级部分的内容后,可知这里是可以直接写 JS 函数的,但是要求配置文件是 JS 而不是 JSON,因为 JSON 里是无法天然表示函数的。所以,由于 JSON 的格式限制,这里只能以纯字符串的形式来写了,所以直接就是函数体的内容了,也是初看起来很奇怪的一个原因。
关键是它默认带的三个参数,这就赋予了自定义 JS 整个系统的能力。有了这个设计,实际上上面那些内置的动作,都可以用自定义 JS 自己实现。甚至笔者猜测,没准就都是用自定义 JS 实现的。此功能让事件逻辑这块基本可以覆盖 100% 的场景了。
组件动作没有太多好说的,原理就是指定某个组件的 id,然后传参,触发指定事件即可,具体可以查看文档。
广播动作
案例的 JSON 太长了,截取其中关键的部分让大家看一下:
{
"name": "role",
"type": "select",
"label": "广播一下",
"mode": "row",
"options": [
{
"label": "海贼王的男人",
"value": "路飞"
},
{
"label": "海上华佗",
"value": "乔巴"
},
{
"label": "海上食神",
"value": "山治"
}
],
"onEvent": {
"change": {
"actions": [
{
"actionType": "broadcast", // 定义并触发广播
"eventName": "broadcast_1",
"args": {
"myrole": "${role}",
"age": 18
}
}
]
}
}
},
{
"type": "form",
"id": "form_001_form_01",
"title": "表单1(优先级低)",
"name": "sub-form1",
"body": [
{
"type": "input-text",
"label": "昵称",
"name": "myname",
"disabled": false,
"mode": "horizontal"
}
],
"onEvent": {
"broadcast_1": { // 订阅并响应广播
"actions": [
{
"actionType": "reload",
"args": {
"myname": "${event.data.value}"
}
},
{
"actionType": "toast",
"args": {
"msgType": "info",
"msg": "表单1刷新完成"
}
}
]
}
}
}
其实也比较容易理解,主要是通过这个方式解决了自定义事件的问题。理论上这就实现了任意组件可以响应任意事件的功能,配合自定义动作,理论上也是 100% 覆盖了。
编排动作
文档说明
通过配置
actionType: 'for'
或actionType: 'break'
或actionType: 'continue'
或actionType: 'switch'
或actionType: 'parallel'
实现动作的逻辑编排,支持嵌套。
这块也够暴力的,基本上就是把分支逻辑强行用 JSON 表示了一遍。让笔者比较疑惑的是,既然有了 自定义 JS
功能,为什么还需要如此设计呢?这种分支逻辑用代码实现不是更好吗?也许是因为字符串的可读性不好吧。
剩下的动作间数据传递和事件干预比较简单,就不展开了,看文档就行。
结论
通过对 amis 核心概念的梳理,基本上理解了其设计思想。以笔者目前的功力来看,暂时还挑不出什么死角。实际上 amis 还提供了很多高级功能,比如自定义组件、将 amis 当成组件库使用、扩展现有组件、多语言等功能。可以自定义组件就给了你一个底线,你甚至可以完全用代码写一个页面出来,这个自由度就太大了。
由于 JSON 层的抽象,实现可视化也不是什么难事,当然它也的确实现了,也就是 爱速搭(写到这才发现,就不展开了),只不过受到 JSON 格式的限制,会牺牲一些自定义功能。
最后,强行说一个点吧:
如果把 JSON 解析这层与 UI 层分开可能更好点,比如 amis-core
+ amis-react
+ antd
这样的结构。这样可以做到 JSON 解析层的语言无关,可以对接 React、Vue 或 Angular 等任意框架,也可以为二次开发提供更多的粒度选择。
不过总体说来,amis 的设计还是很全面的,目前来看应该是可以覆盖近乎 100% 的场景,更多内容还得更深入的研究下。