【低代码漫谈】amis 核心概念浅析

3,436 阅读5分钟

导读

本文是《低代码工具调研》系列衍生的文章。为了保证主线文章的结构简洁,所以另起了这篇专门分析 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": [
      // ...
    ]
  }
}

image.png

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. 当在当前数据域中没有找到变量时,则向上寻找,在父组件的数据域中,重复步骤12
  3. 一直寻找,直到顶级节点,也就是page节点,寻找过程结束。
  4. 但是如果 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% 的场景,更多内容还得更深入的研究下。