Skip to main content

上一篇文章我们讲述了在 EasyMABuilder 中如何通过表达式和微应用函数来进行数据处理,让低代码平台也获得了与专业代码媲美的编写代码的能力和体验,不过这些代码仅限于数据加工,它无法参与 UI 界面相关的工作,虽然 EasyMABuilder 提供了数百个开箱即用的构件,并提供了在微应用层面封装模板的能力,但对于 UI 界面,总有更个性化的场景和需求,对此,我们提供了用户编写新构件的能力,并提供配套的脚手架工具来方便用户更快捷的编写新构件。

准备

开始前,我们需要准备本地开发环境。

  1. NodeJS (>=14)
  2. Yarn (1.x),安装方式:执行 npm install -g yarn
  3. Lerna,安装方式:执行 npm install -g lerna
note

Windows 用户请使用 Subsystem for Linux。

创建一个新的构件项目

开发者可以按需创建自己的构件项目,每个项目中可以包含多个构件包,每个构件包又可以定义多个构件。

使用优维官方提供的 CLI 工具即可一键生成新的构件项目:

# 指定项目名称,例如 `my-repo`,将在当前目录创建一个子目录:
npx @next-core/create-next-repo my-repo

# 项目创建后,进入项目并安装 Node 模块。
cd my-repo && yarn

这个新生成的项目已经初始化提供了构建、测试、打包、代码静态检查等现代化的前端工程化配置,如果我们将项目托管在 GitHub 上,还可以开箱即用地使用包括持续集成和依赖更新等在内的自动化工作流。

创建一个新的构件包

初始化的项目还没有任何构件包,我们先使用项目中内置的脚手架工具创建一个:

  1. 运行 yarn yo
  2. 选择 a new package of bricks
  3. 输入构件包的名称,例如 my-bricks
  4. 然后,我们可以选择同时创建一个构件,本文为了演示我们先选择跳过。

新的构件包的代码就已经初始化完成,其中的各种工程配置文件可以按需自由调整。

创建一个新构件

构件包有了,我们再创建一个新的构件:

  1. 运行 yarn yo
  2. 选择 a new brick
  3. 输入构件的名称,例如 hello-world

编写构件

初始化的构件主要包含以下文件:

bricks/my-bricks/src/
hello-world/ # 构件目录
HelloWorld.spec.tsx # React 组件单元测试
HelloWorld.tsx # React 组件代码
index.spec.ts # Custom Elements 单元测试
index.spec.tsx # Custom Elements 代码

这个构件已经可以使用,我们先启动开发版本的构建:

yarn start --scope @next-bricks/my-bricks

然后打开一个新的终端来启动开发服务:

yarn serve --local-bricks=my-bricks

接着就可以在我们的微应用中使用该构件了:

不过这个构件还只是一个空壳,我们接下来完善它。假设我们的需求是做一个按钮构件,简单做下修改:

HelloWorld.tsx
import React from "react";
import { Button } from "antd";

export function HelloWorld(): React.ReactElement {
return <Button>Hello World</Button>;
}

保存后,本地页面将立即更新:

属性

我们再让构件支持一个“按钮类型”的属性:

HelloWorld.tsx
import React from "react";
import { Button } from "antd";
import type { ButtonType } from "antd/lib/button";

export interface HelloWorldProps {
buttonType?: ButtonType;
}

export function HelloWorld({
buttonType,
}: HelloWorldProps): React.ReactElement {
return <Button type={buttonType}>Hello World</Button>;
}
index.tsx
export class HelloWorldElement extends UpdatingElement {
@property()
buttonType: ButtonType;

// ...

protected _render(): void {
if (this.isConnected) {
ReactDOM.render(
<BrickWrapper>
<HelloWorld buttonType={this.buttonType} />
</BrickWrapper>,
this
);
}
}
}

最后修改我们的应用编排,设置一个按钮类型:

brick: "my-bricks.hello-world"
properties:
buttonType: "primary"

事件

我们再为它添加一个点击事件:

HelloWorld.tsx
export interface HelloWorldProps {
buttonType?: ButtonType;
onButtonClick(greeting?: string): void;
}

export function HelloWorld({
buttonType,
onButtonClick,
}: HelloWorldProps): React.ReactElement {
const handleClick = useCallback(() => {
onButtonClick("Hello World");
}, [onButtonClick]);

return (
<Button type={buttonType} onClick={handleClick}>
Hello World
</Button>
);
}
index.tsx
import { event, EventEmitter } from "@next-core/brick-kit";

export class HelloWorldElement extends UpdatingElement {
// ...

@event({ type: "button.click" })
private _buttonClickEvent: EventEmitter<string>;

private _handleButtonClick = (greeting: string): void => {
this._buttonClickEvent.emit(greeting);
};

protected _render(): void {
if (this.isConnected) {
ReactDOM.render(
<BrickWrapper>
<HelloWorld
buttonType={this.buttonType}
onButtonClick={this._handleButtonClick}
/>
</BrickWrapper>,
this
);
}
}
}

最后修改我们的应用编排,绑定一个按钮事件:

brick: "my-bricks.hello-world"
properties:
buttonType: "primary"
events:
button.click:
action: "message.info"
args:
- "<% EVENT.detail %>"

点击该按钮,将弹出提示信息:

note

由于我们的构件即是 HTML 元素,因此,鼠标点击等原生事件可以直接在构件中使用,无需进行编程,这里我们仅用作编写构件事件的示例。

至此,我们完成了基本的构件能力的开发。

上一篇文章我们讲述了在 EasyMABuilder 中通过 Context 和 State 在编排中管理数据,其中使用到了形如 <% CTX.saving %> 的表达式,和无代码平台不同,低代码平台为了能解决更丰富的应用场景、满足更个性化的需求,仍然离不开代码的编写,包括简单的逻辑计算和数据加工处理,而对于高阶开发者搭建更复杂的应用时,甚至需要不逊色于专业代码的开发体验,对此 EasyMABuilder 提供了对应的代码开发能力:表达式微应用函数

表达式

表达式用于在编排中引用动态数据并进行简单的加工处理,可以在编排中随处使用,包括属性、事件、回调等等。

这里的表达式是 JavaScript 的表达式子集,对于有少量 Web 编程、或者 C/Java 编程经验的同学,上手都不会太难。

  • 引用数据: <% CTX.saving %>
  • 逻辑判断:<% CTX.saving ? "Saving..." : "Save" %>
  • 数学运算: <% CTX.rate * 100 %>
  • 字符串组装:<% `/project/${DATA.projectId}` %>
  • 数据过滤:<% DATA.list.filter(item => item.active) %>

表达式还支持最新的 JavaScript 的语言特性,例如在 ES2020 中引入的可选链 Optional Chaining空值转换 Nullish Coalescing 语法:

  • <% a?.b %>
  • <% data.settings ?? defaultSettings %>

我们还在表达式中集成了 LodashMoment,对于有经验的开发者可以更得心应手的进行数据加工。此外还集成了 EasyMABuilder 官方提供的管道加工函数 brick-next-pipes,进行特定应用场景的数据加工。

  • <% _.findLastIndex(list, "active") %>
  • <% moment(date).format() %>
  • <% PIPES.yamlStringify(input) %>

而为了使流式的、连续的数据加工可以更流畅的编写、并具有更好的可读性,我们加入了 JavaScript 目前还在提案阶段的 Pipeline Operator 的特性,并采用了其 minimal 草案

  • <% DATA.source |> JSON.parse |> PIPES.yamlStringify %>

另一方面,由于表达式的定位是处理简单的数据加工,除了不能使用 JS 语句类语法外,也限制了修改操作类的 JS 表达式语法,例如:赋值 a = 1、更新值 ++i、删除 delete a.b 等,同时也无法访问 DOM 对象。

微应用函数

表达式使用简单的语法就可以完成常见的数据加工,而当面对更加复杂的数据处理的需求时,它就显得力不从心了,同时越复杂的逻辑越需要自动化测试来保障其健壮性和可维护性,这些都是表达式不具备的,因此,我们提供了微应用函数

微应用函数直接在 Visual Builder 中相关的项目中编写,使用 JavaScript 或 TypeScript:

编写好的函数可以在该微应用的编排表达式中使用:<% FN.sayHello("World") %>

微应用函数中可以使用上文表达式中提到的内置对象,例如 _momentPIPES 等。同时函数也可以使用同一个微应用下的其它函数。而无论使用 JavaScript 或 TypeScript ,函数编辑器都具有类型提示、自动补全、代码检查等能力,其体验向桌面 IDE 看齐。

对函数进行调试也非常简单,只需给出输入参数即可运行并查看返回结果:

微应用函数为解决复杂问题而生,虽然它直接在 Visual Builder 界面中编写,但仍需要手段来保障其质量、以及健壮性和可维护性。对此,我们提供了对函数进行单元测试的能力,并提供覆盖率统计。

与编写普通代码的单元测试不同,由于微应用函数专注于处理数据,不依赖副作用也不产生副作用,因此,我们的测试用例可以直接来自 Debug 的输入参数和输出结果。编写单元测试后,可以直接统计覆盖率,并在代码编辑器中给出未覆盖的代码部分的相关提示。

而在维护微应用函数时,修改代码并保存前,系统会自动运行单元测试,并对比测试预期结果,以便发现可能的问题:

至此,我们在低代码平台中获得了与专业代码媲美的编写代码的能力和体验,不过这些代码仅限于数据加工,它无法参与 UI 界面相关的工作,虽然 EasyMABuilder 提供了数百个开箱即用的构件,并提供了在微应用层面封装模板的能力,但对于 UI 界面,总有更个性化的场景和需求,对此,我们提供了用户编写新构件的能力,并提供配套的脚手架工具来方便用户更快捷的编写新构件,下一期我们将讲述《构件开发 Step by Step》。

上一篇文章我们讲述了 EasyMABuilder 中的自定义模板,它是对构件组合的封装,和构件一样是组织 UI 界面结构的可复用单元。

而在低代码平台中,除了组织基本的 UI 界面结构外,还有一项重要的工作是维护和管理数据,特别是来自远端的异步数据、或者动态变更的状态数据。

对此,EasyMABuilder 提供了 ContextState 两种数据管理方式。

Context

Context 在 EasyMABuilder 中用于管理全局的状态数据,在一个路由页面的生命周期中有效,其数据的初始化可以来自异步的远端请求的结果,也可以是静态声明的,这些数据可以在构件的属性或事件中引用,另一方面,它的值可以在页面的交互过程中按需动态更新,同时引用了相关数据的构件的属性也可以实时地获得更新。

例如,假设我们的页面需要一个显示主机列表的表格,该数据来自 CMDB 中的主机模型,我们可以在页面中声明一个 Context 数据:

然后在该页面的构件中就可以通过表达式来引用这些数据,例如赋值给表格的数据源属性:

另一方面,该状态数据可以在界面的交互过程中按需更新,例如通过某个特定事件来更改其状态数据。假设我们页面中有一个按钮,点击它将保存当前表单中的数据,同时我们希望点击它后立即禁用它,以防止连续点击发生,并在请求完成后恢复按钮状态。我们可以先定义一个 saving 的状态数据,并赋值给按钮的 disabled 属性:

注意我们使用了逗号表达式并前置了字符串 "track context",它提示系统在相关数据变更时自动同步该属性。

接着,我们为按钮添加一个点击事件,并使用 context.replace 的动作,将 CTX.saving 更新为 true

最后在请求完成的事件中设置回调动作,将 CTX.saving 重新设置为 false

这样在我们的页面中,点击该按钮后,系统就会自动禁用它,直到请求完成再将按钮状态恢复。

除了上述基本的能力外,Context 还支持对请求返回的数据进行转换、请求的参数来自其他的 Context 等等。

State

State 的能力和 Context 几乎完全一致,不同的是,Context 的作用域是整个页面、是全局的,而上一篇文章我们讲到了自定义模板,同一个模板在页面中可能有多个实例,如果直接使用 Context,则多个实例间的数据会互相影响,另外,使用全局的 Context 也会破坏模板的封装,削弱应用的可维护性,并带来潜在的问题。

因此 State 正是为了解决这个问题,它用于管理自定义模板内的数据,其作用域是模板的实例,多个模板实例之间的数据互相隔离,同时,在能力上完全与 Context 对等。State 与 Context 的区别有点像 JavaScript 中的 let/constvar

上文中我们多次使用了「表达式」,和无代码平台不同,低代码平台为了能解决更丰富的应用场景、满足更个性化的需求,仍然离不开代码的编写,包括简单的逻辑计算和数据加工处理,而对于高阶开发者搭建更复杂的应用时,甚至需要不逊色于专业代码的开发体验,对此 EasyMABuilder 提供了对应的代码开发能力,本系列下一篇将讲述《数据处理:表达式和微应用函数》。

上一篇文章我们讲述了基于 Web Components 的构件,可复用的构件是 EasyMABuilder 的基础。

现代软件的开发追求最大化的可复用性,因为软件复用带来诸多好处:提升研发效率、降低研发成本、降低过程风险、增加可靠性、有利于标准化等。

构件就是 EasyMABuilder 中可复用的单元,通过组合现有的构件就可以搭建出完整的 Web 应用。

而随着应用规模的增长,开发者可能需要更上层的复用,即:将一套构件的组合封装成一个新的可复用的单元,并且希望它能像普通构件那样使用。例如我们可能需要一个显示趋势图的卡片,它由一个卡片构件及一个趋势图构件组成,我们希望将这个定制卡片能复用在多个页面中。

EasyMABuilder 对此提供了对构件组合的封装:自定义模板,以下我们简称为模板。

创建模板的界面与编排一个普通页面的界面完全一致,因为它们都是编排一套构件的组合。

不同的是,模板将内部的构件封装了起来,并且可以按需对外暴露属性、事件、方法和插槽,并将这些信息映射到内部的构件,这样,模板也有了普通构件的几大要素,因此它就可以像普通构件那样在各个地方复用。

例如,上文提到的趋势图卡片,我们可以先编排好模板的构件结构:

为了提升这个模板的可复用性,我们扩展一下它的能力,支持由外部传入卡片的标题和趋势图的数据源,进行一下属性映射的配置:

name: "tpl-trend-card"
properties:
chartTitle:
# 通过引用 ID `ref` 来查找内部构件
ref: "my-card"
refProperty: "cardTitle"
dataSource:
ref: "trend-chart"
refProperty: "dataSource"

这样,我们的模板就有了两个属性 chartTitledataSource,设置或读取它们等同于读取内部构件的对应属性。

例如,我们可以在页面中同时编排两个趋势图卡片,分别显示 CPU 使用率和内存使用率:

path: "/my-page"
bricks:
- brick: "tpl-trend-chart"
properties:
chartTitle: "CPU Usage"
dataSource: '<% ... %>'
- brick: "tpl-trend-chart"
properties:
chartTitle: "Memory Usage"
dataSource: '<% ... %>'

事件、方法和插槽的映射方式与属性类似,其中,插槽的映射是系统在运行时通过模板的展开来实现的。

例如,假设我们为上面的模板再添加一个附加按钮的插槽 addon,映射到卡片构件的对应插槽上,然后我们为上面的 CPU 使用率卡片添加一个查看详细信息的附加按钮。

在模板被展开之前的页面 storyboard 中,构件树大约长这样:

展开后:

每个模板在运行时将被分别展开,其中的插槽下的子构件也会被移植到指定的内部构件的对应插槽上。例如上面的查看详情的附加按钮 view-detail 就被移植到了 CPU 使用率所在模板内部的卡片构件的 addon 插槽中。

自定义模板可以在每个应用内维护,也可以选择在多个应用之间连接共享,还可以单独打包并纳入平台的构件资源库中,就像普通的构件包一样。

由于模板就是构件组合的编排,因此可以轻易地将现有的页面编排的某个部分转换成模板,以便在别处复用,这样可以更加平滑地进行应用的迭代研发。

以上我们讲诉了自定义模板在兼容普通构件的基本能力的前提下,通过对构件组合的封装,在 EasyMABuilder 中提供了更上层的可复用单元,从而让开发者以更高的效率和更好的可靠性完成应用的编排。

在低代码平台中,除了组织基本的 UI 界面结构外,还有一项重要的工作是维护和管理数据,特别是来自远端的异步数据和动态变更的状态数据,本系列下一篇将讲述 EasyMABuilder 中的数据管理:Context 和 State。

上一篇文章我们讲到了优维科技的前端低代码平台 EasyMABuilder 的基础部分--构件,构件用于渲染特定的 UI 界面部分,具有属性、事件、方法和插槽等要素。熟悉传统 Web 开发的同学会觉得有些熟悉,这和 HTML 元素看起来很像。

其实,构件就是 HTML 元素。

但构件并不是 HTML 标准里已经定义好的那些元素(比如 bodydiv 等),而是开发者自定义的元素。这是一套 Web 标准技术 -- Web Components,它允许开发者使用 JavaScript API 来创建新的自定义的、可重用的、封装的 HTML 标签,并且可以像普通的 HTML 标签那样使用它们。这套技术可以追溯到 2011 年的 Fronteers Conference [1],并且在 2016 年就开始逐渐获得主流浏览器的支持(Chrome 54+ [2], Firefox 63+)。

因此,EasyMABuilder 正是利用了原生的 Web 标准技术、结合组件化的开发思想,来搭建开发者熟悉的 Web 应用界面。

这些基于 Web Components 的构件,可以直接使用原生的 JS/CSS/HTML 来创建,或者结合你熟悉的任意主流第三方技术(例如 React/Vue/Angular)来创建[3]。这使得 EasyMABuilder 的用户除了使用官方提供的构件资源外,也可以轻易地创建自己的个性化的构件。

Web Components 主要包含两项内容:Custom elements,和 Shadow DOM。

Custom elements 定义了一套 JavaScript API,允许开发者定义新的 HTML 标签以及它们的行为,或者加强已有的 HTML 标签,或者扩展其他开发者创作的组件。

而 Shadow DOM 则为元素提供了封装的能力,将样式、DOM 等行为都封装在元素内部,而不必担心它与外部的 ID 或者样式类名等产生冲突。

接下来,我们尝试结合上面的技术创建一个卡片元素。

首先,我们可以使用 <template> 来定义这个元素内部的结构:

<template id="tpl-my-card">
<div class="card">
<div class="title">My Card</div>
<!-- 使用插槽来放置外部的子元素 -->
<slot></slot>
</div>
<style>
.card {
border: 1px solid gray;
}
.title {
border-bottom: 1px solid gray;
}
</style>
</template>

接着,我们可以创建这个元素:

class MyCard extends HTMLElement {
constructor() {
super();
// 使用 Shadow DOM 封装内部结构。
let shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(
document.querySelector("#tpl-my-card").content.cloneNode(true)
);
}
}

window.customElements.define("my-card", MyCard);

然后我们就可以使用这个元素了:

<my-card>
<p>Hello World!</p>
</my-card>

元素即构件,我们也可以在 EasyMABuilder 中使用它:

brick: "my-card"
slots:
"":
brick: "p"
properties:
textContent: "Hello World!"

开发者除了可以像上面那样从头开始编写一个元素/构件外,平台还提供了一些脚手架工具和 API 来帮助开发者更高效地编写构件:

import { UpdatingElement, property } from "@next-core/brick-kit";

class MyCard extends UpdatingElement {
@property()
cardTitle: string;

@event({
type: "card.collapse",
})
private _collapseEvent: EventEmitter;

@method()
collapse() {
// ...
}
}

关于 Web Components 更多的细节建议阅读谷歌 Web Fundamentals 系列的相关文章[4]或 MDN 相关文档[5]

我们通过属性、方法和事件来灵活地定制每个构件的外观和行为,然后借助插槽、通过组合的方式来建立构件之间的层次,这和现代 Web 开发等组件化开发的思想相契合,是广受采纳和经受考验的最佳实践。

下一期我们将继续讲述对构件组合的封装:自定义模板。

优维科技的前端低代码平台 EasyMABuilder 的架构大致如下图:

其中主要包含以下三个基础部分:

  • 微应用:即 Web 应用程序,由多个路由页面组成,每个页面由构件组装而来,构件如何组装定义在 Storyboard 中。Storyboard 是结构化数据,可以通过可视化工具编排出来,也可以直接用 YAML 或 JSON 配置。

  • 构件:和前端开发者常说的「组件」类似,主要用于在页面上渲染特定的 UI 内容。开发者可以很容易地通过声明的方式使用构件,无需编程。

  • EasyMABuilder Core:负责解析微应用的 storyboard,按照其定义加载路由并装载相应的构件,完成页面的渲染。

整个架构和乐高很像,用一块块积木、按特定的方式组合在一起,就可以拼搭出各式各样的东西出来。

EasyMABuilder 的基石就是这些积木--构件,正是一个个构件的组合搭建起了最终的应用。我们已经提供了数百个开箱即用的构件,包括功能完整的 UI 构件,以及包含特定业务属性的业务构件。同时开发者也可以开发自己的构件,以满足更加个性化的场景。

构件主要包含以下几大要素:

  • 属性:构件的属性通常用于对该构件进行基本的参数设置。例如对于一个按钮构件,它可能会对外提供:按钮的文字内容、文字颜色等属性,这样应用在编排时可以按需设置属性,以实现不同的 UI 效果:

    brick: "my-button"
    properties:
    text: "Search"
    color: "blue"

    构件的属性可以是任意类型的数据,包括字符串、数字、布尔值、对象、数组等。

  • 事件:构件可以在特定的用户交互等行为发生时触发特定的事件。例如对于一个表单构件,如果用户点击其中的提交按钮时,会发起一个表单提交的事件,并同时提供已填写的表单数据,那么开发者就可以按需配置提交后的动作:

    brick: "my-form"
    events:
    submit:
    action: "console.log"

    平台提供了多种形式的事件处理方式,包括页面跳转、更新构件属性或调用构件方法、发起远端请求、打印日志等等。

  • 方法:构件可以对外提供特定的方法,以供别的地方按需调用。例如对于一个对话框构件,它会提供一个「打开对话框」的方法,可以在用户点击按钮时调用:

    brick: "my-button"
    events:
    click:
    target: "my-dialog"
    method: "open"

    注意,构件的方法可以接收任意的参数。

  • 插槽:构件通过插槽可以让其他构件能以特定的方式容纳在该构件之内。例如表单构件可以提供一个插槽,允许其他的构件例如文本框、单选框等插入到表单中,同时表单构件还可以统一管理这些输入项的数据:

    brick: "my-form"
    slots:
    items:
    - brick: "my-input"
    - brick: "my-radio"

    插槽实际上定义了父子构件的关系,通过层层的构件组成一棵构件树,最终渲染成完整的页面。

这些要素使得各个构件可以灵活地适应不同的应用场景,开发者可以通过这些构件的组合编排出一个完整的微应用。

属性、事件、构件树,这些概念对于有了解 HTML 的同学会觉得有些熟悉:HTML 元素的属性和事件、以及由这些元素组成的 DOM 树。那么构件与 HTML 元素有什么关系呢,敬请期待本系列下一篇:《基于 WebComponents 的构件》。

在模块化开发中,我们常常与各种依赖打交道:dependenciesdevDependenciespeerDependencies 等等。错误的声明可能导致流水线失败,或者是偶现的、难以调查的 bug,甚至导致整个平台界面空白。

EasyCore 是一个图数据库,具备一致性、高可用、内存占用小等特点。接下来,一步步解开EasyCore的面纱。