Low Code 之路(一)

Low Code 之路(一)

背景

20年要做一些新的事情,终于可以脱离繁复的业务逻辑,主导一些更加有趣的事情。

先来聊下我之前负责的业务流程是什么样的,即传统的业务是如何进行的。

image-20210327155304930

当然这一套系统还包含其他的一些细节包括错误通知,处理流程等复杂的内容,但其核心就如图示

这种方案的问题

  • 每个国家对应一套form再对应一套代码,维护成本高
  • 服务端返回也是一一对应
  • 同一,因为使领馆常有变化,维护成本更高,几乎不可复用。
  • 代码独立性太高,故障率高

所以今年我主要投入到对这个流程的优化的工作中来, 主要的工作围绕着 建立一个通用表单, 建立一个通用填写脚本两个方面来进行,目标即使,就需要相应的配置文件,任何人都可以基于配置文件进行配置,简单的修改配置文件就能生成新的表单,和规定相应的爬取流程来对内容进行获取和填充。

image-20210327160340220

调研阶段

市面上的竞品

Formily

Formily 解决方案的本质是构造了一个 Observable Form Graph,在这个 Form Graph 中,我们抽象了整个表单领域模型,同时这个模型又是一个无限循环状态机。

读了下代码主要是基于RX的Observeable Form Graph状态机,基本是通过component type找到render的内容,然后通过一个基于Rx 的 所谓Form graph来维护全局的状态,读JSON 来render Form视图然后通过key来建立field relation,然后维护全局状态,主要的工作在对Form 数据结构及数据更新算法和一些性能优势,搞出一套updater tree 和 path match性能不错,而且是经过大量用户验证的,包括阿里内部验证的可靠的库。

Amis

amis 是一个低代码前端框架,它使用 JSON 配置来生成页面,可以减少页面开发工作量,极大提升效率。

初始化接口,数据链的设计,更偏向业务一些,包括表达式,联动, renderer都让人感到这是一个很reactive的库,简单易用,源代码没有看,想想大概差不多。

渲染过程就是根据节点 path 信息,跟组件池中的组件 test (检测) 信息做匹配,如果命中,则把当前节点转给对应组件渲染,节点中其他属性将作为目标组件的 props。需要注意的是,如果是容器组件,比如以上例子中的 page 组件,从 props 中拿到的 body 是一个子节点,由于节点类型是不固定,由使用者决定,所以不能直接完成渲染,所以交给属性中下发的 render 方法去完成渲染,{render('body', body)},他的工作就是拿子节点的 path 信息去组件池里面找到对应的渲染器,然后交给对应组件去完成渲染

FormRender

通过 JSON Schema 生成标准 Form,常用于自定义搭建配置界面生成

它提供一个 表单设计器,和基于JSON的formcreate,文档写的不是很好。。,代码里也是用global context维护状态,通过eval实现表达式,比较灵活,支持几种标准类型,通过schema type类型来确定渲染内容这个我不是很中意,也支持自定义type component,但是目前看bug比较多,更新策略也是全量更新,没有优化,性能差一些。

还有一些诸如formcreator等等库,方案都大同小异,同一上的调研确定了几点

  • 基于JSON Schema 的配置文件。
  • 提供 接口接入标准
  • 接口字段到schema的映射语法
  • 支持template
  • 更新粒度以path为依准

image-20210327163922088

一下是部分实现

store 是基于 Rx 的数据控制中心

通过只有有效更新才能设置form

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export default class Manage<T extends HashObj> implements IManage<T> {
public $form: Subject<T> = new Subject<T>();
private _storeForm: T;
public formData: T;
private static instance: Manage<any>;
private readonly isFreeze: boolean = false;
private haveSetDefault = false;
public validations: Map<string, IValidation> = new Map<string, IValidation>();
private engine = new TemplateEngine();

get storeForm() {
return this._storeForm;
}

set storeForm(data) {
if (!this.isFreeze) {
this._storeForm = { ...data };
}
}

get data() {
return this.formData;
}
.....

更新粒度为path

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public notifyByPath = (path: string, changed: HashObj, other?: HashObj): void => {
console.log('I\'m in ', path, this.formData, this.validations);
const originPath = other!.path;
const validation = this.validations.get(originPath);
const newData = produce(this.formData, draft => {
set(draft, path, changed);
if (validation && !isEmptyArray(validation.rules)) {
const errors = validation.rules.map((validatorName: string) => {
let defaultValidatorFunc = Validators?.[validatorName];
let validatorFunc = this.actions?.[validatorName];
if (typeof validatorFunc === 'function') {
return validatorFunc(changed);
}
if (typeof defaultValidatorFunc === 'function') {
return defaultValidatorFunc?.(changed);
}

throw new Error('invalid validation in' + originPath);
}).filter(r => r !== ValidationResult.PASS);
const currentComp = draft.components.find(comp => comp.path === originPath)?.validation;
if (currentComp) {
currentComp.errors = errors;
set(draft, originPath + 'components', currentComp);
}
}
});

this.notify(newData);
};

整体的状态维护也是基于Context。

这样一个基础的状态管里就完成了,

接下来需要根据type渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public buildDataTree(dataStruct: HashObj, components: TAllComponents[]): TAllComponents[] | void {
if (!Array.isArray(components)) {
throw new TypeError('ComponentTree->buildDataTree: Wrong Type .Params Must Be Array');
}
const newComponents: TAllComponents[] = [];
for (let i = 0, len = components.length; i < len; i++) {
const componentItem = components[i];
if (isUndefined(componentItem)) {
throw new Error(`buildDataTree: Component Invalid`);
}

const { path, type } = componentItem || {};
const currentVal = get(dataStruct, path);

if (isUndefined(currentVal)) {
throw new Error(
`buildDataTree: current: wrong path ${path}, components should have corresponding component`,
);
}

if (!isObject(currentVal) || type === FormItemType.CUSTOM) {
const produceItem = produce(componentItem, draft => {
set(draft, 'value', currentVal);
this.buildComponentTree(draft);
});
newComponents.push(produceItem);
}
}
return newComponents;
}

public buildComponentTree(component: TAllComponents, componentConfig?: TComponentConfig) {
const { type, typeName } = component;
let componentType: FormItemType | string = type;
if (type === FormItemType.CUSTOM) {
if (!typeName) {
console.error('custom must have typeName');
}
componentType = typeName;
}
set(component, '$$component', this.components[componentType]);
}

public buildTree(schema: ISchema, componentLib: HashType<ReactElement>): TAllComponents[] | void {
this.setComponents(componentLib);
const { data, components } = schema;
if (!data || !components) {
throw new TypeError('ComponentTree::buildTree: Data Or Component Is Invalid');
}
return this.buildDataTree(data, components);
}

然后是validator

validator本意是要用户自己去确定哪些东西需要被校验,所以并没有写很多的校验方法,仅提供一个基础的校验。

设想是需要将其抽象为一个库,专门维护, compoennt也是一样。

1
2
3
4
5
6
7
8
9
10
11
12
import { ValidationResult } from '../../constant';
import { isNullOrUndefined, isEmpty } from '../../utils';


export class Validators {
public static required = (data: unknown): [ValidationResult.FAIL, string] | ValidationResult.PASS => {
if (isNullOrUndefined(data) || isEmpty(data)) {
return [ValidationResult.FAIL, '不能为空!'];
}
return ValidationResult.PASS;
};
}

支持模版字符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { HashObj, TTemplateResult } from '../../types/project';
import { get, isFunction, isString, isTotalWord, isUndefined } from "../../utils";
import { safeEval } from './safe-eval';

interface ITemplateEngine<T extends HashObj> {
execute(tpl: string, data: T, current: any): TTemplateResult|TTemplateResult[];
}
// optimization
interface IExpression<T> {
analyse(tpl: string, data: T, current: any): TTemplateResult;
}

abstract class TemplateExpression<T extends HashObj = HashObj> implements IExpression<T> {
static getSymbol(tpl: string) {
let [anchor, variable] = TemplateEngine.symbolReg.exec(tpl) || [];
if (!(anchor && variable)) {
console.error('Input Is Invalid: ' + tpl);
throw new Error('Input Is Invalid: ' + tpl);
}
return [anchor, variable];
}

abstract analyse(tpl: string, data: T, current: any): TTemplateResult;
}

class PureExpression extends TemplateExpression {
analyse(tpl: string): string {
return tpl;
}
}

class VariableExpression extends TemplateExpression {
analyse(tpl: string, data: HashObj, current: any): TTemplateResult {
let [, code] = TemplateExpression.getSymbol(tpl);
const action = get(data, ['actions', code]);
const property = get(data, code);
return isFunction(action) ? action(current, data) : property;
}
}

class CalculateExpression extends TemplateExpression {
analyse(tpl: string, data: HashObj): TTemplateResult {
let [, code] = TemplateExpression.getSymbol(tpl);
code = code.replace(TemplateEngine.varReg, (current: string) => {
let result = get(data, current);
if (['true', 'false'].includes(current)) {
return `!!${current}`;
}
if (typeof result === 'string') {
return `"${result}"`;
}
return result;
});
return safeEval(code) || '';
}
}

export default class TemplateEngine<T extends HashObj = HashObj> implements ITemplateEngine<T> {
public static readonly symbolReg: RegExp = /^{{(.+)?}}$/i;
public static readonly varReg: RegExp = /[A-Za-z.]+(?!["'a-z])/g;

static isTpl(tpl: string): boolean {
return TemplateEngine.symbolReg.test(tpl);
}

getExpressionHandler(tpl: string, data?: T): TemplateExpression {
if (!TemplateEngine.isTpl(tpl)) {
return new PureExpression();
}

const [, code] = TemplateExpression.getSymbol(tpl);

if (isTotalWord(code) && !isUndefined(data)) {
return new VariableExpression();
}
return new CalculateExpression();
}



public execute(tpl: string|string[], data: T, current?: any): TTemplateResult|TTemplateResult[] {
if (isString(tpl)) {
const handler = this.getExpressionHandler(tpl as string, data);
return handler.analyse(tpl, data, current);
}
return tpl.map(item => {
const handler = this.getExpressionHandler(item, data);
return handler.analyse(item, data, current);
})

}
}

支持一下几种case

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
describe('template-engine', () => {
it('should execute code', function() {
const tpl = '{{a}}';
const mockData = { a: 100 };
const tplEngine = new TemplateEngine();
const result = tplEngine.execute(tpl, mockData);
expect(result).toEqual(100);
});

it('safe eval work', () => {
const tpl = '1+1';
const result = safeEval(tpl);
expect(result).toEqual(2);
});

it("safe eval is safe", function() {
const tpl = "1";
const result = safeEval(tpl);
expect(result).toBe(1);
const tpl1 = "onchange";
const result1 = safeEval(tpl1);
expect(result1).toBe(undefined)
});

it("safe eval throw error", function() {
const tpl = "asd///"
expect(() => safeEval(tpl)).toThrow();
});

it("safe eval without window", () => {
const spy = jest.spyOn(utils, 'isUndefined');
spy.mockReturnValue(true);
const tpl = "1";
expect(() => safeEval(tpl)).toThrow();
spy.mockRestore();
})

it('execute expression should work', function() {
const tpl = '{{1 + 1}}';
const tplEngine = new TemplateEngine();
const result = tplEngine.execute(tpl, {});
expect(result).toEqual(2);
});

it('execute expression with variable should work', function() {
const tpl = '{{a + 1}}';
const tplEngine = new TemplateEngine();
const result = tplEngine.execute(tpl, { a: 100 });
expect(result).toEqual(101);
});

it('execute expression with two variable should work', function() {
const tpl = '{{a + b}}';
const tplEngine = new TemplateEngine();
const result = tplEngine.execute(tpl, { a: 100, b: 200 });
expect(result).toEqual(300);
});

it('ternary operator should work', () => {
const tpl = '{{a > 100 ? 1 : 2}}';
const mockData = { a: 100 };
const tplEngine = new TemplateEngine();
let result = tplEngine.execute(tpl, mockData);
expect(result).toEqual(2);
mockData.a = 101;
result = tplEngine.execute(tpl, mockData);
expect(result).toEqual(1);
});

it('call function should work', () => {
const tpl = '{{d}}';
const mockData = { a: { b: { c: 12 } }, actions: {d: (current: any) => current.b.c} };
const tplEngine = new TemplateEngine();
let result = tplEngine.execute(tpl, mockData, { b: { c: 12 } });
expect(result).toEqual(12);
});

it('call lang api should work', () => {
const tpl = '{{[a]}}';
const mockData = {
a: 1
};
const tplEngine = new TemplateEngine();
let result = tplEngine.execute(tpl, mockData, { b: { c: 12 } });
expect(result).toEqual([1]);
});

it('expression list will works', () => {
const tpl = ["{{a}}"];
const mockData = {
a: 1
};
const tplEngine = new TemplateEngine();
let result = tplEngine.execute(tpl, mockData, { b: { c: 12 } });
expect(result).toEqual([1])
});

it('compare should work', () => {
const tpl = '{{a === "a"}}';
const mockData = {
a: 'a'
};
const tplEngine = new TemplateEngine();
let result = tplEngine.execute(tpl, mockData, { b: { c: 12 } });
expect(result).toEqual(true);
});

it('compare should work', () => {
const tpl = '{{a.b === "a"}}';
const mockData = {
a: {
b: "a"
}
};
const tplEngine = new TemplateEngine();
let result = tplEngine.execute(tpl, mockData);
expect(result).toEqual(true);
});
});

最后暴露给开发者一些hooks

用来应对不同的场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { useContext, useEffect, useMemo, useRef } from 'react';
import { IContextParams, SingleContext } from '../core';
import { HashObj, IValidation } from '../types/project';

export function useFormChange<T extends HashObj>(path?: string): [T, (data: T) => void] {
const formContext = SingleContext.getContext<T>();
const { state, managerIns } = useContext<IContextParams<T>>(formContext);
return [state, (changed: HashObj, currentPath?: string) => managerIns.notifyByPath(path || currentPath || '', changed)];
}

export function useManage<T extends HashObj>() {
const formContext = SingleContext.getContext<T>();
const { managerIns } = useContext<IContextParams<T>>(formContext);
return managerIns;
}

export function useValidation<T extends HashObj>(path: string, validation?: IValidation) {
if (!validation) return;
const formContext = SingleContext.getContext<T>();
const { managerIns } = useContext<IContextParams<T>>(formContext);
useMemo(() => {
managerIns.registryValidation(path, validation);
}, []);
}

export function usePrevious<T>(value: T): T|undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]); // Only re-run if value changes
return ref.current;
}

总结

一个基于JSON Schema 的buildform就完成了,通过全量的接收数据统一了对接口的interface,每次请求通过参数读取config渲染表单,再通过表单渲染实现面向配置渲染页面,稳步线中~😄