作为前端开发一定会非常熟悉 AST 抽象语法树(Abstract Syntax Tree),当浏览器加载 JS 代码时,它会首先将代码转换为一棵抽象语法树(AST),然后再根据 AST 来渲染对应的 DOM 结构,对于一款低代码产品来说,如果能直接去解析 AST 肯定是最方便但这也是麻烦的,因为 AST 包含的内容非常多,所以大部分的低代码产品都会使用自定义的 Schema 来描述搭建的内容。
但也由于 Schema 只是一种通用的协议,并没有非常好的规范与最佳实践,现阶段都是属于各自为战的边界探索阶段,所以各个低代码平台中的 Schema 的规范并不相同。
其实就算不是探索阶段,大多数平台的低代码产品肯定也很难做到统一,除了开发者的习惯也会涉及到用户习惯以及行业差异、产品定位等,此外商业产品为了盈利会主动营造技术壁垒、增加用户粘性、培养用户习惯以及迁移成本。
但当我们想把这个产品升级为 Pro Code 或者想再添加更多交互功能的时候,是不是等同于又重新创建了一个新 DSL,这也是我个人感觉低代码一个非常尴尬的点
当然在产品的初期由于时间与资源有限,肯定不会最开始就设计 DSL 解析,所以接下来我们将围绕 Schema 来逐步分析从设计到落地以及扩展的全过程。
什么是 Schema 协议
Schema 本质上就是一个 JSON 格式的定义块,通过抽象属性定义来表达页面和组件的布局、属性配置、依赖关系、表达式解析,如果在偏向业务也有页面路由、多语种、数据源、权限等等各种各样的抽象声明。 因此,我们也将刚刚提到的内容统称为 Schema 协议,它也属于元数据结构模型的范畴。
如果想要进行更多的了解,可以 Google 看看元数据的相关内容。或者入群探讨,小册里就不再过多展开。
什么是协议渲染
当了解了 Schema 的基本概念后,接下来就需要具体来实施和设计相关 Schema 协议的实现了。
在正式开始设计协议之前,我们一起先来看下面的例子,一起来了解下协议渲染究竟是什么东西?
相信很多朋友为了提高效率或多或少都封装过一些通用型的组件,比如通过 JSON 配置来实现一组表单布局,如下图,是一个简单的表单区块:
前端实现的代码如下所示:
<Form>
<Form.Item
label="用户名"
name="username"
>
<Input />
</Form.Item>
<Form.Item
label="性别"
name="sex"
>
<Select />
</Form.Item>
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Button htmlType="reset">
重置
</Button>
<Button type="primary" htmlType="submit">
提交
</Button>
</Form.Item>
</Form>
可以看出,这其中大部分的代码都是冗余,特别是对于中台场景或者问答场景中会频繁大量的出现,所以社区早期就出现了配置式的解法,根据相对应的配置规则,我们可以将其抽象为以下代码:
import { Form } from 'antd'
import { FormRender, FormRenderProps } from '@you-team/form-render'
const config: FormRenderProps['config'] = [
// username form.item config.
{
label: '用户名',
name: 'username',
renderType: 'Input',
},
// sex form.item config.
{
label: '性别',
name: 'sex',
renderType: 'Select',
}
]
<FormRender
as={Form}
config={config}
onFinish={data => console.log('FormData', data)}
/>
上述方式就是一个简单表单类型的 Schema 设计,借助封装好的 FormRender 组件来递归约定好的表单 Schema 协议快速进行页面表单内容的渲染。
此类方案是协议约定式渲染的方案之一,在低代码平台中,通常也是使用相同的方式来实现的,只不过会更加的通用,复杂程度也会更高。
目前而言,社区存在很多类似的实现解法,大部分都是 UI(config) 的思想。如果感兴趣可以搜索下对应的文章学习与了解。
设计与实现
上述的例子非常简单也是大家常用的组件封装方式,接下来就是我们这个低代码产品的 Schema 协议 的设计与实现了,首先来说下整个协议的主体采取 JSON 方式的原因:
- 方便存储,可以存储到服务端中形成记录;
- 方便操作,跨平台解析;
- 结构简单,通俗易懂,方便开发者查阅。
如下图所示,Schema 协议第一个版本先预留了如下几个领域区块,分别是依赖管理、国际化(多语种)、状态管理、数据源、生命周期以及页面结构等耳熟能详的结构定义。
协议版本(version)
代表着当前协议的版本,用于后续协议 break change 带来的兼容问题,可以通过版本来区分渲染器和解析器。而版本的升降级也是有规范可循的,如社区中比较常见的像semver,大体上的规则如下:
- major: 如果包含 Break Change(破坏更新)的内容;
- minor: 当你产出了一个新的功能的时候(无破坏更新);
- patch: 当你修复了一个 BUG 问题的时候(无破坏更新)。
依赖管理(library)
代表当前协议在编辑器中依赖的一些类库和包,为后续异步加载资源和动态引入留坑位。在内部声明出依赖的名称、加载的资源地址(如组件库会导出 index.js
和 index.css
多个资源),类库的声明名称等等。那么可以分析得出如下依赖的大体结构:
const librarys: SchemaModelConfig['librarys'] = [
{
name: 'dayjs',
urls: [
'https://unpkg.com/dayjs@1.11.7/dayjs.min.js'
],
globalVar: 'dayjs'
},
{
name: 'arco',
urls: [
'https://unpkg.com/@arco-design/web-react@2.46.3/dist/arco.min.js',
'https://unpkg.com/@arco-design/web-react@latest/dist/arco-icon.min.js',
'https://unpkg.com/@arco-design/web-react@2.46.3/dist/css/arco.css',
],
globalVar: 'Arco'
}
]
国际化(i18n)
管理当前协议生成页面的 react-i18n-next 相关的键对值,用于维护国际化项目时需要进行多语种的文案切换带来的业务诉求。参考业内成熟 i18n 的方案,多语种的协议字段就相对而言比较简单:
const i18n: SchemaModelConfig['i18n'] = {
zh: {},
eu: {},
...后续补充需要支持的多语种
},
状态管理(store)
维护一份页面上的状态。方便后续做绑定通信和事件广播的实现,用于赋予整个页面的组件联动交互,最常见的就是点击相关按钮唤起相关弹窗类型组件。
当低代码产物为工程类型时,那么就会涉及到跨模块、跨页面这种全局状态管理,当然随之而来的是这块的配置会更加复杂,包括 Schema 的设计与配置的形式。
数据源(dataSource)
在大多数业务场景当中,页面的元素结构渲染并不是根据静态数据来渲染的,而是通过获取相关接口中的远程数据来展示。所以数据源与远端挂钩,可以是远程的 JSON 文件,也可以是一个 fetch 请求,主要的目的是为了帮助页面组件支持动态渲染数据的能力。
一个请求包含以下几个重要的内容,请求资源 URI、Request、Header、Response => params | query | body
,所以在定义数据源的时候,我们将其抽象成如下结构:
const i18n: SchemaModelConfig['dataSource'] = [
{
key: 'string|uuid',
name: 'getUserList',
request: {
url: 'https://localhost:3000/user/list',
params: {
pageSize: 10,
current: 1,
},
method: 'GET',
body: {},
header: {}
...AxiosInstanceConfig
}
}
]
// 最终会抽象成一个函数调用来动态的执行。
lowcodeSandBox?.loadDataSource('getUserList', ...其他参数): Promise<any>
生命周期(lifeCycles)
一个项目的使用中有初始化、使用中、销毁等多个不同的生命周期,每个状态需要做的事情也不同,比如在程序初始化时会加载或者配置后续使用中需要的数据、资源等,同理对于低代码平台应用而言,搭建页面时与传统项目一样,同样需要自定义一套生命周期来帮助更好管理产物的拉取、Dom 渲染、数据更新等操作。
页面结构(htmlBody)
与 虚拟 DOM 相似,本质上是对于当前页面渲染的抽象结构,便于跨平台之间的转换,为后续运行时渲染和动态出码垫定基础,提供后续结构化转换的能力。
我们先来看一下 React 组件的createElement
方法的构成:
React.createElement(type, props, children);
- type: 可以是原生标签,也可以是 函数组件 和 Class 组件 等;
- props:组件元素需要的属性;
- chidren:组件内容;
熟悉 React 的同学都知道,在编译时我们所写的 JSX|TSX 会被编辑成 React.createElement
执行函数,而我们抽象出来的 Schema 结构也是做类似的事情。
如下代码所示,就是一个对页面的抽象设计,其中主要包含的内容就是渲染的组件名称、Props、子组件等等信息。
至于属性具体有什么作用,在后续相关的实战章节会着重的分析,在这里只要先了解页面结构的基本画像即可。
{
"ROOT": {
"type": {
"resolvedName": "Container"
},
"props": {
"width": 800,
"height": "100%",
"paddingTop": 20,
"paddingBottom": 20,
"paddingLeft": 20,
"paddingRight": 20,
"background": "#FFFFFF"
},
"displayName": "基础容器",
"custom": {},
"hidden": false,
"nodes": [
"rpVYvatknx"
],
"linkedNodes": {}
},
"rpVYvatknx": {
"type": {
"resolvedName": "Text"
},
"props": {},
"displayName": "文本",
"custom": {},
"parent": "ROOT",
"hidden": false,
"nodes": [],
"linkedNodes": {}
}
}
最后
以上就是根据思维导图中初步拟定的协议草稿的字段定义解释。
在有了明确的定义结构后,我们就可以写出一个简单的Schema
的数据结构,如下所示:
const schema = JSON.stringify({
version: 1.0.0,
librarys: [],
i18n: {
zh: {},
eu: {},
},
store: {},
dataSource: {},
lifeCycles: {},
htmlBody: {
"ROOT": {
"type": {
"resolvedName": "Container"
},
"props": {
"width": 800,
"height": "100%",
"paddingTop": 20,
"paddingBottom": 20,
"paddingLeft": 20,
"paddingRight": 20,
"background": "#FFFFFF"
},
"displayName": "基础容器",
"custom": {},
"hidden": false,
"nodes": [
"rpVYvatknx"
],
"linkedNodes": {}
},
"rpVYvatknx": {
"type": {
"resolvedName": "Text"
},
"props": {},
"displayName": "文本",
"custom": {},
"parent": "ROOT",
"hidden": false,
"nodes": [],
"linkedNodes": {}
}
}
})
这里需要注意的是,你的协议一定要遵守 JSON 数据格式的约束,否则会导致解析时出现问题,为了避免开发中出现解析 Schema 产生不可预期的错误,可以使用第三方推荐的 JSON Schema 库来检验 Schema 是否符合规范:
- json-schema-validator
- json-schema
总结
作为低代码编辑器的通用能力之一,Schema 协议在设计到使用中起着至关重要的转换器作用,使得不同的编辑器和工具之间可以共享和使用相同的数据结构,方便地将数据在不同的应用程序和系统之间进行转换和交换,从而实现更高效、更可靠的工作流程。
目前初版协议起草其实已经能够面对绝大部分的问题了,在后续实战中涉及更加复杂的功能需要依赖协议的话,则可以在此基础上继续延伸做扩展。
此章节的内容会随着项目的更新进度不断优化
写在最后
如果你有什么疑问或者更好的建议,欢迎在评论区提出。 👏
5 架构:通用 Schema 设计