【低代码漫谈】lowcode-engine 协议浅析

3,777 阅读18分钟

背景

笔者最近在研究低代码,目的是为了能够提高前端日常的开发效率,希望能从各低代码产品当中获得一些灵感。调研过程中发现,阿里最近(2021.12)开源了一个低代码引擎 lowcode-engine。简单来说就是一个用来搭建低代码平台的工具,注意,它不是一个低代码平台,而是高一个维度的工具,已经上升到了协议层。为了方便理解,可以狭义的把协议的概念理解为一套 JSON 格式。这个 JSON,用以表达页面的布局、组件、属性、数据依赖,甚至是路由、i18n 等信息,这个 JSON 就是所谓的协议。

笔者最初的目的就是希望能够将代码的实现配置化,用配置来承载业务逻辑,以达到组件实现与业务逻辑解耦的目的。理想的状态就是以后写页面只需要写 JSON,不管项目是 Vue2、Vue3 甚至是 React,不管组件库是 AntD、Element,Naive UI,写出来的 JSON 都一样。这样不但在技术上有更多的可能性,在人员调配上也将更加灵活。所以抱着学习的心态来分析一下这个协议,站在巨人的肩膀上才能有更宽广的眼界。

简介

lowcode-engine 介绍:

An enterprise-class low-code technology stack with scale-out design / 一套面向扩展设计的企业级低代码技术体系

在他们在出品的《低代码引擎技术白皮书》中介绍了这个项目的由来:

2019 年年中,在阿里巴巴前端委员会的技术资产盘点中我们发现,已经有基于几十个具备低代码能力的平台在各业务中广泛使用了,而这些平台底层是基于 3、4 套基础能力或 SDK 来实现的。而这些能力中很多都是可以复用的,比如我们之后提到的低代码引擎的几大核心能力:入料、编排、渲染、出码。这些能力的重复建设是很耗费人力的,而每一个 SDK 所投入的人力由于规模的原因,也不足以庞大到打磨精细,导致这些 SDK 大多数都处于低水平建设中。

关于协议,白皮书当中是这么描述的:

从建设背景出发,共建小组明确了低代码引擎的建设理念:协议先行,最小内核,最强生态

协议先行

一份共同遵守的协议是整个体系的基石,决定了整个体系是否能够足够包容,兼容足够多的上层场景,同时也是整体技术体系能否足够稳定发展的基石,后续所有的引擎实现都是服务于协议的。在实践过程中,协议从成立专家组开始讨论,到 alpha 版本发布,到在阿里巴巴集团内部公开征集意见,到最终的 1.0 正式版本发布,也是持续了半年之久,最终产出两份协议:《低代码引擎搭建协议规范》和《低代码引擎物料规范》。后文也会有专门的章节对协议进行展开讲解。

从以上描述可以看出协议对于低代码平台的重要意义。同时也表显示出了阿里的野心,在低代码还处于百家争鸣的时候,率先高调的提出了要打造生态的目标。这个目标是否能成功暂且不论,这种高举高打的态度,就让人对这个项目的质量放心不少,下面我们就来具体分析一下这个协议。

低代码引擎搭建协议规范

篇幅有限,本篇主要聚焦在《低代码引擎搭建协议规范》上,而《低代码引擎物料协议规范》更像是一篇非常全面的组件封装指南,我们有机会再展开。

需要说明一下,因为笔者的目的是为开发人员提效,可视化编辑并不是必要需求。所以这份协议当中因为必须支持可视化而作出的妥协性设计,也许在笔者这里有更简单的实现方式。比如笔者需要的配置格式不需要一定是 JSON,而是 JS 对象,这就意味着可以原生的表达函数(JSON 字段没有函数类型),诸如此类的点可以更加简化、变通。

废话不多说,我们直接上 JSON:

{
  "version": "1.0.0",                  // 当前协议版本号
  "componentsMap": [{                  // 组件描述
    "componentName": "Button",
    "package": "@alifd/next",
    "version": "1.0.0",
    "destructuring": true,
    "exportName": "Select",
    "subName": "Button"
  }],
  "utils": [{
    "name": "clone",
    "type": "npm",
    "content": {
      "package": "lodash",
      "version": "0.0.1",
      "exportName": "clone",
      "subName": "",
      "destructuring": false,
      "main": "/lib/clone"
    }
  }, {
    "name": "moment",
    "type": "npm",
    "content": {
      "package": "@alifd/next",
      "version": "0.0.1",
      "exportName": "Moment",
      "subName": "",
      "destructuring": true,
      "main": ""
    }
  }],
  "componentsTree": [{                 // 描述内容,值类型 Array
    "componentName": "Page",           // 单个页面,枚举类型 Page|Block|Component
    "fileName": "Page1",
    "props": {},
    "css": "body {font-size: 12px;} .table { width: 100px;}",
    "children": [{
      "componentName": "Div",
      "props": {
        "className": ""
      },
      "children": [{
        "componentName": "Button",
        "props": {
          "prop1": 1234,               // 简单 json 数据
          "prop2": [{                  // 简单 json 数据
            "label": "选项1",
            "value": 1
          }, {
            "label": "选项2",
            "value": 2
          }],
          "prop3": [{
            "name": "myName",
            "rule": {
              "type": "JSExpression",
              "value": "/\w+/i"
            }
          }],
          "valueBind": {               // 变量绑定
            "type": "JSExpression",
            "value": "this.state.user.name"
          },
          "onClick": {                 // 动作绑定
            "type": "JSFunction",
            "value": "function(e) { console.log(e.target.innerText) }"
          },
          "onClick2": {                // 动作绑定 2
            "type": "JSExpression",
            "value": "this.submit"
          }
        }
      }]
    }]
  }],
  "i18n": {
    "zh-CN": {
      "i18n-jwg27yo4": "你好",
      "i18n-jwg27yo3": "中国"
    },
    "en-US": {
      "i18n-jwg27yo4": "Hello",
      "i18n-jwg27yo3": "China"
    }
  }
}

更多详情见 官网文档。内容不少,我们挑重要的说下。

version

这个比较好理解,因为协议也有更新迭代,为了保证协议的稳定性,必须要有版本的概念。这个概念对于笔者想要的协议来说是非常必要的,毕竟更新迭代甚至产生破坏性更新的可能性更高。

componentsMap

其实就是各种组件依赖的声明,从出码结果来看更容易理解,实际上就是 import。

{
  "componentsMap": [{
    "componentName": "Button",
    "package": "@alifd/next",
    "version": "1.0.0",
    "destructuring": true
  }]
}
// 出码结果如下
import { Button } from '@alifd/next';

这个对于笔者想要的协议也许不是必须的,因为一般公司内项目的 UI 组件库是固定的,就算要适配多种组件库,也不会有太多选择。为了降低复杂度可能会简化配置,甚至去掉,默认引入固定的组件库即可。

utils

与 componentsMap 类似,是方法的依赖声明。也不是必须的,但是它支持的自定义函数很有用,可以兜住底,所以应该会保留。而且由于笔者没有必须用 JSON 的限制,所以简单地直接用一个函数就可以表示了,而不需要像下文的 content 字段那样的复杂。

{
  utils: [{
    name: 'recordEvent',
    type: 'function',
    content: {
      type: 'JSFunction',
      value: "function(logkey, gmkey, gokey, reqMethod) {\n  goldlog.record('/xxx.event.' + logkey, gmkey, gokey, reqMethod);\n}"
    }
  }]
}
// 出码结果如下
export const recordEvent = function(logkey, gmkey, gokey, reqMethod) {
  goldlog.record('/xxx.event.' + logkey, gmkey, gokey, reqMethod);
}

i18n

国际化这块比较好理解,只要定义好不同语言的字典,并且在渲染过程中默认带上 i18n 的解析函数即可。目前国际化这块的需求不是很强烈,不是必须的,暂时不会考虑。

componentsTree(重要)

最核心、最关键、最重头也是内容最多的部分来了。componentsTree 就是用来描述页面结构,组件属性的,生命周期等内容的。这部分协议是否能够覆盖到尽量多的场景,决定了整个协议的适用性,由于内容太多,我们还是挑重要的难点说,简单的我们一笔带过,而且为了方便理解,我们不会完全按照文档的顺序来介绍概念。

组件结构 & 容器结构

componentsTree 既然是描述页面结构的,那么肯定就是一个树形结构。第一个关键点来了,我们看一下对于这棵树的节点类型,该协议是如何定义的:

协议中用于描述搭建出来的组件树结构的规范,整个组件树的描述由组件结构&容器结构两种结构嵌套构成。

  • 组件结构:描述单个组件的名称、属性、子集的结构;
  • 容器结构:描述单个容器的数据、自定义方法、生命周期的结构,用于将完整页面进行模块化拆分。

与源码对应的转换关系如下:

  • 组件结构:转换成一个 .jsx 文件内 React Class 类 render 函数返回的 jsx 代码。
  • 容器结构:将转换成一个标准文件,如 React 的 jsx 文件, export 一个 React Class,包含生命周期定义、自定义方法、事件属性绑定、异步数据请求等。

换一种说法可能更好理解一些,组件结构对应了我们平时写的无状态组件容器结构对应的就是有状态组件。组件结构相对简单很多,实际上只有 propsstyleclassNameloop 等相对固定的几个属性,看文档不难理解,稍微复杂一些,难一些的就是容器结构。在展开分析容器结构之前,我们一定要先分析一下属性值类型描述,这将是笔者根据需求大幅优化的地方。

属性值类型描述

如文中所说

在上述组件结构容器结构描述中,每一个属性所对应的值,除了传统的 JS 值类型(String、Number、Object、Array、Boolean)外,还包含有节点类型事件函数类型变量类型等多种复杂类型;

再重复一遍,复杂类型目前有:节点类型(JSSlot)事件函数类型(JSFunction)变量类型( JSExpression)。是不是觉得很抽象,完全没有概念,很难理解?没关系,我们举一个实际的 React 组件(伪代码)的例子就比较好理解了:

image.png

协议中给的例子如下:

{
  "componentName": "TabelColumn",
  "props": {
    "cell": {
      "type": "JSSlot", // JSSlot
      "params": ["value", "index", "record"],
      "value": [{
        "componentName": "Input",
        "props": {}
      }]
    },
    "condition": {
      "type": "JSExpression", // JSExpression
      "value": "this.state.num > this.state.num2"
    }
  },
  "lifeCycles": {
    "componentDidMount": {
      "type": "JSFunction", // JSFunction
      "value": "function() {\
        console.log('did mount');\
      }"
    }
  },
}

这就是上文多次提到的,最典型的受限于 JSON 的格式约束不得不用如此复杂的格式来描述的例子。假设我们用普通 JS 对象将会是这样:

{
  componentName: "TabelColumn",
  props: {
    cell: (value, index, record) => <Input />, // JSSlot。注意!这样实际上是不行的。
    // cell: { // 还是得用这种方式表示
    //   type: "JSSlot",
    //   params: ["value", "index", "record"],
    //   value: [
    //     {
    //       componentName: "Input",
    //       props: {},
    //     },
    //   ],
    // },
    condition: this.state.num > this.state.num2, // JSExpression
  },
  lifeCycles: {
    componentDidMount() { // JSFunction
      console.log("did mount");
    },
  },
};

怎么样?是不是清爽而且直观了很多。但是请注意!JSSlot 部分出现了 jsx 的代码,这是不应该的!我们本来就是在用 JSON(或 JS 对象)来表示组件,也就是 jsx 代码。这里又出现了 jsx 代码,那肯定是有问题的。而且,这类属性还不是组件定义,实际上它已经是一个组件实例了,所以才额外需要一个 params 字段。这段可能比较费脑子,同学们可以反复推敲几遍,目前也就是这一种属性类型需要特殊处理了。

当彻底理解了这部分内容之后,对于属性值类型描述应该已经有了比较完整的认知了。这时候再去看容器结构的文档,问题也不大了。其实容器只有 3 种,即 componentName 只有 3 个值,'Page'(代表页面容器)、'Block'(代表区块容器)、'Component'(代表低代码业务组件容器),但是组件理论上却是无穷的,从这个角度来看,容器反而更容易把握一些。

最后,还有一个特别复杂的属性需要单独拿出来说一下,那就是 dataSource,放心,笔者并没有忘了它,只是为了让文章思路更连贯,把它放到了后面。

dataSource

与之相关的概念有 ComponentDataSourceComponentDataSourceItemComponentDataSourceItemOptions,内容多的实在是不想看,不想动脑。没关系,我们还是用一个实际的 React 组件(伪代码)例子来看一下,就好懂了:

const CustomComponent = () => {
  const [dataSource, setDataSource] = useState();

  useEffect(() => {
    Promise.all([ // 0 - ComponentDataSource - list
      fetch(option1) // 2- ComponentDataSourceItemOptions
        .then((res) => dataHandler1(res)) // 1 - ComponentDataSourceItem - dataHandler
        .catch((err) => errorHandler1(err)), // 1 - ComponentDataSourceItem - errorHandler
      fetch(option2).then((res) => dataHandler2(res)).catch((err) => errorHandler2(err)), // 1 - ComponentDataSourceItem
      fetch(option3).then((res) => dataHandler3(res)).catch((err) => errorHandler3(err)), // 1 - ComponentDataSourceItem
    ]).then((resArr) => {
      const dataMap = {};
      resArr.forEach((val) => {
        dataMap[r.id] = val;
      });
      setDataSource(dataMap);
      dataHandler(dataMap); // 0 - ComponentDataSource - dataHandler
    });
  }, []);

  // other code
};

为了方便区分,笔者在它们前面用数字标了号。简单解读一下,实际上就是:

  1. 在初始化的时候发了 3 个请求 Promise;
  2. 然后以 id 为 key ,返回值为 value,处理成 dataMap 对象;
  3. dataMap 赋予 state; 理解了代码逻辑,再看注释去理解以上概念就好理解多了。其实这块如果不受 JSON 的限制,是可以简单很多的。当然,单纯用 JS 对象也不太够了,我们需要用函数参数的形式拓展一些能力了,比如用上下文为函数赋予(暴露出) setState 的能力:
const props = (ctx) => {
  const { state, setState } = ctx;
  return {
    dataSource: {
      key1: fetch(option1).then((res) => dataHandler1(res)).catch((err) => errorHandler1(err)),
      key2: fetch(option2).then((res) => dataHandler2(res)).catch((err) => errorHandler2(err)),
      key3: fetch(option3).then((res) => dataHandler3(res)).catch((err) => errorHandler3(err)),
    },
    dataHandler(dataMap) {
      setState(dataMap);
    },
    visible: state[key1].hide === false,
  };
};

这里相当于用代码减少了 JSON 协议当中的概念,只保留了 dataSourcedataHandler 属性,其他的所有配置都可以交给用户使用 JS 原生代码替代,只需要规定 dataSource 是一个 Record<string, Promise> 结构就行了,减少了心智负担。当然,这里只是表达了这个思路,笔者暂时也不敢说所有的属性都可以被替代。

上下文 API 描述

其实在上文 dataSource 的部分已经引出了上下文(ctx)的概念,我们是需要为某些属性,尤其是函数类型的属性赋予一些能力的,比如操作 state,访问组件实例,访问路由参数,全局状态操作等。这些能力就需要通过 ctx 的注入来实现了。而 ctx 有什么能力,正是这个引擎决定的,看你引擎实现的怎么样了。

说实话,上下文这个概念挺抽象,挺难理解的。最初接触的上下文就是 this,这在很长一段时间还是面试必考的一个知识点。笔者也是在有了几年经验之后才比较彻底的理解了它,这块暂时也想不到什么更通俗的方式去解释它了,大家还是再多看一下 dataSource 部分的内容还有低代码引擎的文档来细品一品吧,相信对你编程水平的提高会有很大帮助。

应用描述

介绍完了以上内容,重要的概念都已经说的差不多了,下面补充一下应用级别(全局)的一些协议就算完整了。相信有了前文的铺垫,这部分内容会很好理解。我们直接列一下结构描述:

  • version { String } 当前应用协议版本号
  • componentsMap { Array } 当前应用所有组件映射关系
  • componentsTree { Array } 描述应用所有页面、低代码组件的组件树
  • utils { Array } 应用范围内的全局自定义函数或第三方工具类扩展
  • css { string } 应用范围内的全局样式;
  • config: { Object } 当前应用配置信息
  • meta: { Object } 当前应用元数据信息
  • dataSource: { Array } 当前应用的公共数据源 (待定)
  • i18n { Object } 国际化语料

这里面的绝大多数属性都跟前文差不多,只是作用域不同。前文的作用域为页面或组件级别,这里的则是整个应用的全局作用域。我们只把 configmeta 这两个比较特殊的属性拿出来说一下即可。

config

"config": {                // 当前应用配置信息
  "sdkVersion": "1.0.3",   // 渲染模块版本
  "historyMode": "hash",   // 浏览器路由:browser  哈希路由:hash
  "container": "J_Container",
  "layout": {
    "componentName": "BasicLayout",
    "props": {
      "logo": "...",
      "name": "测试网站"
    },
  },
  "theme": {
    // for Fusion use dpl defined
    "package": "@alife/theme-fusion",
    "version": "^0.1.0",
    // for Antd use variable
    "primary": "#ff9966"
  }
},

不太难理解,主要是一些全局配置的属性,可能需要注意的就是 layouttheme 这两个属性了,尤其是前者。如果你的项目不是把 layout 放在应用级别维护的话,那也许可以再思考借鉴下。

meta

meta 即元信息,又分为应用元信息、页面元信息、组件元信息,字段也有不同的地方。其实就是把最后不好归类的信息放到这里就完事了,看了例子就很好理解了。

{
  "meta": {                                  // 应用元数据信息
    "name": "demo 应用",                      // 应用中文名称,
    "git_group": "appGroup",                 // 应用对应 git 分组名
    "project_name": "app_demo",              // 应用对应 git 的 project 名称
    "description": "这是一个测试应用",          // 应用描述
    "spma": "spa23d",                        // 应用 spma A 位信息
    "gmt_create": "2020-02-11 00:00:00",     // 创建时间
    "gmt_modified": "2020-02-11 00:00:00",   // 修改时间
  },
  "componentsTree": [{                       // 应用内页面、低代码组件描述
    "componentName": "Page",                          // 单个页面
    "fileName": "page_index",
    "props": {},
    "css": "body {font-size: 12px;} .table { width: 100px;}",
    "meta": {                                         // 页面元信息
      "title": "首页",                                 // 页面标题描述
      "router": "/",                                  // 页面路由
      "spmb": "abef21",                               // spm B 位
      "url": "https://fusion.design",                 // 页面访问地址
      "creator": "xxx",
      "gmt_create": "2020-02-11 00:00:00",            // 创建时间
      "gmt_modified": "2020-02-11 00:00:00",          // 修改时间
    },
    "children": [
      {
        "componentName": "Component",                           // 单个组件
        "fileName": "BasicLayout",                              // 组件名称
        "props": {},
        "css": "body {font-size: 12px;} .table { width: 100px;}",
        "meta": {                                               // 组件元信息
          "title": "导航组件",                                   // 组件中文标题
          "description": "这是一个导航类组件...",                  // 组件描述
          "creator": "xxx",
          "gmt_create": "2020-02-11 00:00:00",                  // 创建时间
          "gmt_modified": "2020-02-11 00:00:00",                // 修改时间
        },
      }
    ]
  }],
}

个人认为三种 meta 中最有用的就是页面元信息,它里面包含了路由信息,这其实很关键。另外两个 meta 对于开发层面的意义不大,但是对于可视化编辑来说却是必须的了,一些必要的信息都要存在这里。

最后还有应用级别 APIs,也就是全局上下文(global ctx),本质与局部上下文没区别,就是暴露出来的数据和方法更多了一些,就不多展开了,看文档就行。

总结

之前的一篇《amis 核心概念浅析》相当于分析了 amis 的协议。现尝试总结下二者的异同点,从而帮助加深理解。

首先,二者都是为了适配可视化编辑,必须采用 JSON 格式的描述,这点无可厚非,也的确是最好的选择。但是 amis 提供了 JS 对象的使用方式(将 amis 当成 UI 库用),而 lowcode-engine 并没有提及。只能说二者定位不同,后者因为定位是协议,所以必须要更抽象,更普适。而前者因为定位是具体的实现,在自己规定的规则中可以有更多的灵活度。

另一个例子就是,amis 当中并没有 JSSlotJSExpressionJSFunction 这种抽象概念。而是利用 tpl(模板)这个概念,承载了上述功能,即 ${xxxx} 当中可以放任何东西,这使得 amis 的概念更容易理解,当然 tpl 另一方面也可以看成是约束。

最后,还有一个很大的区别,关于数据(状态)流的设计。amis 非常清晰的提出了数据域和数据链的概念,并且用 targetbroadcast 这两种方式补全了跨链沟通的能力。而 lowcode-engine 并没有为状态(数据)抽象出额外的概念,而主要是利用上下文(局部和全局)来赋予当前组件获取外部状态的能力。后者无疑抽象程度更高,也更接近我们平时写代码的思路,但是相应的理解和使用成本也会更高。

总体来说,因为 lowcode-engine 目标是实现一套协议,所以它抽象程度更高,理解成本更高,概念相对更少的倾向是必然的。笔者感觉实际上它就是把我们平时写代码的最底层的概念给重新用 JSON 表达了一下,而没有再做更多的定义了。相对的,amis 作为一个产品,为了更方便理解,更易用,额外定义了一些概念,这也是无可厚非的。

学习了这两个项目后的收获真的很大,让笔者对于业务的抽象又有了更多的理解,对于如何提高开发效率有了新的思考,对于解决这个问题也更多了信心。至于是直接复用二者还是再造一套轮子,还需要进一步的思考与实验。如果有进一步的进展,笔者也会及时整理出来。

PS:低代码引擎物料协议规范 简直是一份非常详尽的组件开发指南,建议各位都看看,尤其是想要自建组件库的同学!

“问题不可能在产生问题的意识水平上得到解决。”

"Problems cannot be solved at the same level of awareness that created them."——Albert Einstein

参考文献