背景
笔者最近在研究低代码,目的是为了能够提高前端日常的开发效率,希望能从各低代码产品当中获得一些灵感。调研过程中发现,阿里最近(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,包含生命周期定义、自定义方法、事件属性绑定、异步数据请求等。
换一种说法可能更好理解一些,组件结构对应了我们平时写的无状态组件,容器结构对应的就是有状态组件。组件结构相对简单很多,实际上只有 props、style、className、loop 等相对固定的几个属性,看文档不难理解,稍微复杂一些,难一些的就是容器结构。在展开分析容器结构之前,我们一定要先分析一下属性值类型描述,这将是笔者根据需求大幅优化的地方。
属性值类型描述
如文中所说
在上述组件结构和容器结构描述中,每一个属性所对应的值,除了传统的 JS 值类型(String、Number、Object、Array、Boolean)外,还包含有节点类型、事件函数类型、变量类型等多种复杂类型;
再重复一遍,复杂类型目前有:节点类型(JSSlot)、事件函数类型(JSFunction)、变量类型( JSExpression)。是不是觉得很抽象,完全没有概念,很难理解?没关系,我们举一个实际的 React 组件(伪代码)的例子就比较好理解了:
协议中给的例子如下:
{
"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
与之相关的概念有 ComponentDataSource、ComponentDataSourceItem、ComponentDataSourceItemOptions,内容多的实在是不想看,不想动脑。没关系,我们还是用一个实际的 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
};
为了方便区分,笔者在它们前面用数字标了号。简单解读一下,实际上就是:
- 在初始化的时候发了 3 个请求 Promise;
- 然后以
id为 key ,返回值为 value,处理成dataMap对象; - 把
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 协议当中的概念,只保留了 dataSource 和 dataHandler 属性,其他的所有配置都可以交给用户使用 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 } 国际化语料
这里面的绝大多数属性都跟前文差不多,只是作用域不同。前文的作用域为页面或组件级别,这里的则是整个应用的全局作用域。我们只把 config 和 meta 这两个比较特殊的属性拿出来说一下即可。
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"
}
},
不太难理解,主要是一些全局配置的属性,可能需要注意的就是 layout 和 theme 这两个属性了,尤其是前者。如果你的项目不是把 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 当中并没有 JSSlot、JSExpression、JSFunction 这种抽象概念。而是利用 tpl(模板)这个概念,承载了上述功能,即 ${xxxx} 当中可以放任何东西,这使得 amis 的概念更容易理解,当然 tpl 另一方面也可以看成是约束。
最后,还有一个很大的区别,关于数据(状态)流的设计。amis 非常清晰的提出了数据域和数据链的概念,并且用 target 和 broadcast 这两种方式补全了跨链沟通的能力。而 lowcode-engine 并没有为状态(数据)抽象出额外的概念,而主要是利用上下文(局部和全局)来赋予当前组件获取外部状态的能力。后者无疑抽象程度更高,也更接近我们平时写代码的思路,但是相应的理解和使用成本也会更高。
总体来说,因为 lowcode-engine 目标是实现一套协议,所以它抽象程度更高,理解成本更高,概念相对更少的倾向是必然的。笔者感觉实际上它就是把我们平时写代码的最底层的概念给重新用 JSON 表达了一下,而没有再做更多的定义了。相对的,amis 作为一个产品,为了更方便理解,更易用,额外定义了一些概念,这也是无可厚非的。
学习了这两个项目后的收获真的很大,让笔者对于业务的抽象又有了更多的理解,对于如何提高开发效率有了新的思考,对于解决这个问题也更多了信心。至于是直接复用二者还是再造一套轮子,还需要进一步的思考与实验。如果有进一步的进展,笔者也会及时整理出来。
PS:低代码引擎物料协议规范 简直是一份非常详尽的组件开发指南,建议各位都看看,尤其是想要自建组件库的同学!
“问题不可能在产生问题的意识水平上得到解决。”
"Problems cannot be solved at the same level of awareness that created them."——Albert Einstein
参考文献
- 低代码引擎技术白皮书
- 阿里低代码引擎LowCodeEngine正式开源
- 市面上常见的低代码产品可以看 Golden 的梳理