本文用于解决开发低代码组件时,需要编写额外配置文件的问题。具体实践部分给了最核心的解析逻辑,读者可以结合自己的场景使用。读者需要对 Typescript Compiler API 或者编译原理有一定了解。
背景
低代码平台至少需要物料区、设计区、配置区三部分,以 lowcode-engine 为例:
第三方开发者开发的物料包括元数据、渲染信息两部分,平台根据元数据渲染出左侧的组件名称、图标和右侧的配置(也就是属性、样式、方法等等),用户从物料区拖拽物料到渲染区,平台根据渲染信息渲染出 dom。截止目前,市面上所有的低代码平台都需要编写额外的配置文件来保存组件元数据,lowcode engine 需要编写一个 meta.ts ,还有的用 JSON,更加离谱的要开发人员编写代码用于渲染右侧的配置。lowcode engine 的 meta.ts 参见 组件描述协议,案例如下:
import snippets from './snippets';
export default {
snippets,
componentName: 'Alert',
title: '警告提示',
category: '反馈',
props: [
{
name: 'banner',
title: { label: '顶部公告', tip: '是否用作顶部公告' },
propType: 'bool',
defaultValue: false,
},
{
name: 'icon',
title: {
label: '图标',
tip: '自定义图标,`showIcon` 为 true 时有效',
},
propType: 'node',
},
{
name: 'message',
title: { label: '警告提示内容', tip: '警告提示内容' },
propType: { type: 'oneOfType', value: ['string', 'node'] },
},
{
name: 'showIcon',
title: { label: '显示图标', tip: '是否显示辅助图标' },
propType: 'bool',
},
{
name: 'type',
title: { label: '类型', tip: '类型' },
propType: {
type: 'oneOf',
value: ['success', 'info', 'warning', 'error'],
},
}
],
configure: {
supports: {
style: true,
events: [
{
name: 'onClose',
template:
"onChange(event,${extParams}){\n// 关闭时触发的回调函数\nconsole.log('onChange');}",
},
],
},
},
};
这段代码中 snippets
记录了左侧物料区需要的图标、组件名称、描述等,还有拖入后组件的默认 props
,其余代码记录了分组(category
)、组件标识(componentName
)、配置面板(props
以及 configure.supports.style
和 props.supports.events
)。
本文介绍一套方案,它基于 Typescript Compiler API,充分利用 Typescript 特性,无需编写类似的文件,lowcode engine 开发一个能显示的组件至少需要三个文件(组件代码、meta.ts 配置、snippets 实例),这套方案只需要一个文件。
思路
编写组件时,往往需要定义一堆 typescript 类型用于限制组件的 props,并且写上注释说明这个类型是什么用途,比如:
type LeftProps = {
// 宽度
width?: number;
// 样式
style?: CSSProperties;
// 类名
className?: string
}
/***
* 页面左侧插件扩展区
*/
export default class Left extends Component<GlobalStateProps & LeftProps> {
/**
* 组件位置
*/
placement: UIPluginPlacement = 'left'
render(): Node {
const { style, className } = this.props
return <div className={`${className}`} style={style}></div>
}
}
以上案例中,Left
组件接收的 props
是 LeftProps
类型,它有三个字段,分别是宽度width
、样式style
、类名 className
,这里面就记录了组件的元数据,我们可以使用 Typescript Compiler API 在打包时获取这些信息,得到组件元数据,这样的话组件就只需要编写一个文件了,我们的组件示例,如下:
/**
* 这是一个按钮组件
* @label 按钮
* @icon ./button.svg
*/
export class Button extends Widget {
/**
* @label 属性/按钮文案
*/
@reactive text = "按钮";
/**
* @label 样式/颜色+/背景色
*/
@reactive backgroundColor: Color = "#2096FE";
/**
* @label 样式/颜色+/字体颜色色
*/
@reactive color: Color = "#ffffff";
/**
* @label 单击
*/
onClick: () => void;
/**
* @label 修改按钮文本
* @param text 按钮内容
*/
setText(text: string){
this.text = text
}
render() {
jsx 信息
}
}
ts 编译这部分代码,提取 class 上的注释,得到组件名称(按钮
)、描述(这是一个按钮组件
)、图标路径(./button.svg
),解析 class 的属性 backgroundColor
,得到这个属性的中文名称(背景色
)、需要渲染成什么组件(解析 Color
颜色选择器)、渲染到什么位置(样式/颜色+/背景色
表示样式
tab 下的 颜色
分组中,这个分组默认展开+
),解析到 on
开头的没有函数会自动追加到 事件 tab
下,不允许修改位置、渲染到其他方法, setText
会变成对外暴露的方法,另外还可以得到参数、返回值类型。
案例中的组件以 class
的形式呈现,为什么这样设计?class
组件有一个非常好的特性就是继承,如果我开发的组件有类似功能,只需要继承一个父类就行了,不需要编写额外代码,比如显示/隐藏,只需要再最顶层添加属性就行了,如果不需要该属性追加注释 @hidden
就可以了。
class
中定义的属性都会从外部传入,相当于 React
组件的 props
,setText
中又对它们赋值这时候相当于 React
的 state
,为什么这样设计?组件所有非 private
的属性都是对外开放的,这样有个好处,用户编写代码直接修改组件属性就行了,比如修改按钮背景色,直接执行 this.按钮标识.backgroundColor = 'red'
就行了,这会带来额外的好处,不需要像 lowcode
那样,需要更新 dom 的属性都得定义到全局的 state
中,对象、数组还得执行this.setState({...style,backgroundColor:red})
,这一切都是为了降低组件的开发成本。
完整方案
我们使用 vite
开发物料,物料会被打包成 .js
文件,不然需要额外的编译才能使用,生产环境会极度依赖 vite
,怎么办?vite
使用 rollup
打包,我们编写一个插件干预 rollup
的 generateBundle
阶段,在这个阶段通过 emitFile
多生成一个记录元数据信息的文件就行了,npm 上的很多组件库有一个 .d.ts
文件,生成.d.ts
有以下好处:
- 相比其他文件(比如
json
),生成.d.ts
更加规范; - 另外如果需要在高代码中使用这个组件库,还能多一些提示信息;
- 可以结合
typescript language service
等实现更复杂的功能;
所以我们选择了生成 .d.ts
,.d.ts
只是类型声明,默认值怎么办?在组件渲染时从渲染信息中获取,也可以解析语句。
这样的话我们就得到了一个完整的解决方案:
- 开发时,组件使用
class
形式编写,编写时多写一些带tag
的注释,使用平台内置的类型; - 生产环境:
- 编译时,生成
.d.ts
、.js
、package.json
、.css
文件,.d.ts
记录了物料元数据,.js
记录了物料渲染信息,包括属性默认值(你可以针对这部分进行修改,生成一个.json
文件,存储组件元数据、属性默认值等); - 使用时,加载组件库,解析
.d.ts
,点击组件时,利用.d.ts
解析的数据渲染组件属性配置框,利用组件默认数据,填写组件默认值;
- 编译时,生成
- 本地环境:直接解析
index.tsx
;
核心代码
我们给一个更加详细的组件信息,以此为例解析:
/**
* 表示一个按钮
* @label 按钮
* @icon ./button.svg
*/
@Widget
export class Button extends Component {
/**
* @label 属性/字符串1
*/
private str1 = "初始值";
/**
* @label 属性/字符串2
*/
str2: string
/**
* @label 属性/数字
*/
@reactive num: number = 2;
/**
* @label 属性/对象
*/
obj: Omit<{ x: 12; y: string; xy: { x: number; y: string } }, "x">;
/**
* @label 属性/密码框
*/
password: Password;
/**
* @label 属性/布尔
*/
@reactive bool = true;
/**
* @label 属性/文本框
*/
multiLine: MultiLineText;
/**
* @label 样式/颜色
*/
color: Color = "#409eff";
/**
* @label 属性/文件
*/
url: Image = "http://127.0.0.1:8080/img.png";
/**
* @label 属性/下拉框
*/
select: OneOf<[{ label: "xxx"; value: "x3x" }]> = "x3x";
/**
* @label 属性/枚举
*/
radio: "large" | "medium" | "small";
/**
* @label 属性/元组
*/
tuple: [string, number] = ["初始值", 123];
/**
* @label 属性/标签元组
*/
tupleWithLabel: [元素1: string, 元素2: number];
/**
* @label 属性/数组
*/
arr: string[];
/**
* @label 属性/自定义editor
* @editor StringEditor
*/
editor = "";
/**
* @label 单击
*/
onClick: () => void;
/**
* @label 获取按钮内容
* @returns 文本
*/
getText = () => {
return this.str1;
};
/**
* @label 设置按钮内容
* @param str 文本
*/
setText(str: string) {
this.str1 = str;
}
render() {
return <div className="button"></div>;
}
}
首先我们要从获取 tsx
文件中获取属性:
import * as ts from "typescript";
const entry = "./src/widgets/lib/button.tsx";
// 我只是给了个简单例子,第二个参数、第三个参数可以获得更多能力
const program = ts.createProgram([entry], {});
const sourceFile: ts.SourceFile = program.getSourceFile(entry)!;
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(sourceFile)!;
// 获取导出的模块,如果是 export * from './xxx.tsx' 需要先确保已经加载
const exportedSymbol = checker.getExportsOfModule(symbol);
// 只解析第一个 export,真实场景需要遍历
const type = checker.getDeclaredTypeOfSymbol(exportedSymbol[0]);
// 获取属性类型
checker.getPropertiesOfType(type).map((property) => {
parseSymbol(property, checker); // 解析符号
});
/**
* 解析符号
* @param symbol 需要解析的符号
* @param checker 类型检查器
* @returns
*/
function parseSymbol(symbol: ts.Symbol, checker: ts.TypeChecker) {
const flags = symbol.getFlags();
const declaration = symbol.valueDeclaration;
......
}
解析符号
如果需要解析属性修饰符标志(public
、private
、protected
、static
、abstract
等),可以在 parseSymbol
中编写(或者单独抽离一个方法) :
if (declaration) {
const modifierFlags = ts.getCombinedModifierFlags(declaration);
if (modifierFlags & ts.ModifierFlags.Public) {
......
}
if (
modifierFlags & ts.ModifierFlags.Private
){
......
}
if (modifierFlags & ts.ModifierFlags.Protected){
......
}
if (modifierFlags & ts.ModifierFlags.Static){
......
}
if (modifierFlags & (ts.ModifierFlags.Readonly | ts.ModifierFlags.Const)){
......
}
if (modifierFlags & ts.ModifierFlags.Abstract){
......
}
if (modifierFlags & ts.ModifierFlags.Override){
......
}
}
解析注释,这部分用正则表达式也能得到相同效果,基于 typescript compiler api 解析:
if (declaration) {
const jsDoc: any = {
// 没有 tag 的注释
summary: ts.displayPartsToString(symbol.getDocumentationComment(checker)),
};
for (const tag of symbol.getJsDocTags()) {
const content = tag.text ? ts.displayPartsToString(tag.text) : "";
jsDoc[tag.name] = content;
}
// 特殊 tag
if (jsDoc.editor || jsDoc.icon) {
// 需要的逻辑
}
}
解析类型
接下来就是重点,先在 parseSymbol
中新增几行代码:
function parseSymbol(symbol: ts.Symbol, checker: ts.TypeChecker) {
const flags = symbol.getFlags();
const declaration = symbol.valueDeclaration;
if (declaration && flags & ts.SymbolFlags.Property) {
+ const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
+ parseType(type, checker);
}
}
function parseType(type: ts.Type, checker: ts.TypeChecker) {
const flags = type.flags;
}
对类型解析的逻辑在 parseType
,格式如下:
const flags = type.getFlags()
// 原生基础类型
if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never | ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.BigInt | ts.TypeFlags.ESSymbol | ts.TypeFlags.UniqueESSymbol | ts.TypeFlags.BooleanLiteral)) {
if ((type as ts.IntrinsicType).intrinsicName === "error") {
}
}
// 字面量
if (flags & ts.TypeFlags.Literal) {
const value = (type as ts.LiteralType).value
// 枚举字面量
if (flags & ts.TypeFlags.EnumLiteral) {
}
// 数字字面量
if (flags & ts.TypeFlags.NumberLiteral) {
}
// 字符串字面量
if (flags & ts.TypeFlags.StringLiteral) {
}
// 大整数字面量
if (flags & ts.TypeFlags.BigIntLiteral) {
}
}
// 对象
if (flags & ts.TypeFlags.Object) {
const objectFlags = (type as ts.ObjectType).objectFlags
// 类/接口
if (objectFlags & ts.ObjectFlags.ClassOrInterface) {
}
// 泛型
if (objectFlags & ts.ObjectFlags.Reference) {
if (checker.isArrayType(type)) {
}
if (checker.isTupleType(type)) {
}
}
// 匿名对象
if (objectFlags & ts.ObjectFlags.Anonymous) {
}
}
// 并集
if (flags & ts.TypeFlags.Union) {
}
// 交集
if (flags & ts.TypeFlags.Intersection) {
}
// 类型参数
if (flags & ts.TypeFlags.TypeParameter) {
}
// 索引访问
if (flags & ts.TypeFlags.IndexedAccess) {
}
// 键查询
if (flags & ts.TypeFlags.Index) {
}
// 条件
if (flags & ts.TypeFlags.Conditional) {
}
// 使用约束简化后的类型参数
if (flags & ts.TypeFlags.Substitution) {
}
// 模板字面量
if (flags & ts.TypeFlags.TemplateLiteral) {
}
如果类属性是成员方法,要继续调用 parseSymbol
进一步解析参数类型,所以上面的 parseType
格外长。
解析语句
对于初始数据,比如:str1 = "初始值";
或者函数的默认值,怎么解析? symbol
有 declaration
属性,declaration
的 initializer
就是初始化语句,这块代码过于繁琐,要解析 ts.SyntaxKind
下的很多阶段 ,我贴一部分出来。
// expression 就是 initializer
switch (expression.kind) {
case ts.SyntaxKind.NumericLiteral:
break
case ts.SyntaxKind.TrueKeyword:
break
// 二元运算符
case ts.SyntaxKind.BinaryExpression: {
switch ((expression as ts.BinaryExpression).operatorToken.kind) {
// 加法
case ts.SyntaxKind.PlusToken:
break
// 减法
case ts.SyntaxKind.MinusToken:
break
// 乘法
case ts.SyntaxKind.AsteriskToken:
break
// 除法
case ts.SyntaxKind.SlashToken:
break
// 取模
case ts.SyntaxKind.PercentToken:
break
........
}
经过上面的一系列操作就完成了属性到属性编辑器的映射。
问题及解决方案
内置类型不满足,如何扩展
我们定义了很多常用类型以及和平台 API 相关的类型,如果不满足用户可以使用,类似如下代码强制修改:
/**
* @editor HelloPropertyEditor
*/
强制把当前属性渲染成 HelloPropertyEditor
,其中 HelloPropertyEditor
如下:
export class HelloPropertyEditor extends PropertyEditor {
render() {
return <div><div>
}
}
多个类型属性编辑器共存
在 lowcode engine
中可以多个属性编辑器共存,比如按钮组件的内容部分:
它可以是插槽、字符串、变量,这个怎么实现?定义一个特殊类型 type Mixin<T> = T
,使用时变成 content: Mixin<Slot & string>
变量输入默认开启,所以不用手动声明。当解析到 Mixin
类型时,读取联合类型的所有 types
,按记录下来就行了。
内部类型如何被赋值为配置默认值
比如 color: Color = 'red'
,类型明明是 Color
,为什么可以赋值为字符串?这个也是利用联合类型,Color
的声明如下:
export type Reserved<T> = T & { __brand__?: never };
/**
* 颜色
* @editor ColorTypeEditor
*/
export type Color = Reserved<string>;
这样 Color
类型就可以被赋值为字符串了。
属性编辑器如何复用
比如每个组件都有个 ID 属性,我们不希望用户修改这个属性,但是允许复制,这个时候怎么办?
解析属性的 modifiers
得到 readOnly
就可以设置为只读,复制的话需要传入一个参数,然后判断是否支持复制功能,怎么实现?我们可以通过 "泛型" 来实现,定义一个泛型,尖括号里面的类型就是它的额外参数的初始的数据,比如我们可以定义 type Input<T extends {copyable?: true}> = Reserved< T & string>
,使用 id:Input<{copyable: true}> = uuid()
,解析时解析泛型的类型列表,得到初始配置上 {copyable: true}
,当然也可以使用注释。泛型尖括号中的数据太多会显得臃肿,不建议太复杂,如果过于复杂,可以使用 @editor
自定义属性编辑器。
属性之间如何联动
属性编辑器联动怎么实现?比如下拉框,我希望它支持字典,具体就是有两个下拉框分别表示字典类型和字典项,字典类型改变,字典项必须跟着刷新。
具体就是加一个注释 @change
后面就是需要执行的代码,两个组件之间传参使用事件机制,每个属性都默认监听了特定规则的事件(事件包括属性名称、所在物料名称等),值改变的时候触发就可以了。比如:
/**
* @change $property.updatePropertyEditor('dicItem',{dictType: this.dicType})
*/
dicType
组件之间获取值可以使用 updatePropertyEditor
传递,强制刷新,另外还可以自定义一些对象方法,用于获取组件信息。
Typescript 注释的妙用
注释可以声明和组件渲染无关的信息,内容可以是文本,甚至是代码,我们定义了很多 tag
/**
* @label 属性/文本
* @description 文本内容
* @hidden this.example === "medium"
* @change console.log($element,$document,$propertyPanel,$property,$value)
* @editor HelloPropertyEditor
* @order 1
*/
text = "xxx"
上面表示这个属性被渲染成 HelloPropertyEditor
组件,序号是 1
,被渲染到 属性
tab 下, 中文是 文本
,描述信息是 文本内容
,当 this.example === "medium"
隐藏,值改变时调用 console.log($element,$document,$propertyPanel,$property,$value)