前端知库

低代码可视化搭建平台怎样设计?物料、画布、拖拽、协议和出码各负责什么?

搭建平台围绕一份页面协议(schema)工作:物料描述组件能配置什么,画布把物料拖进来、编排成 schema,渲染器按 schema 和注册过的组件画出页面,出码模块把同一份 schema 转成源码。阿里低代码引擎把它拆成入料、编排、渲染、出码四个模块,协议的作用是让编辑器、渲染和出码能各自升级。

参考答案

简答

比如运营要自己搭一个活动页:从左边的组件列表把按钮、图片拖到中间画布,在右边面板里改文字和颜色,点发布就上线。做这样的平台,核心是一份描述页面的 JSON,也叫 schema 或协议,所有模块都围绕它工作。

阿里开源的低代码引擎把平台拆成四个模块。入料把现有的组件按物料协议描述出来,说明它叫什么、有哪些属性;编排就是在画布上拖拽、改属性,本质是不断生成符合搭建协议的 schema;渲染按 schema 和注册过的组件画出页面;出码把同一份 schema 转成源码。

为什么要单独定一份协议?引擎文档的说法是,它约束编辑器的输出,同时作为渲染和出码的输入,让这三者解耦、可以各自升级。物料协议也一样,组件只要补一份描述就能接入,不用为搭建平台重写一遍。

要注意几点:节点要有稳定的 id,拖拽移动时保持不变;渲染器只用注册过的组件,没登记的组件名不能当代码执行;schema 里能写表达式和函数,就要有权限和审核,渲染时的安全边界要单独设计。

平台由哪几部分组成

阿里低代码引擎的架构文档把引擎内核分成四个模块,每个模块和协议的关系都很清楚:

  • 入料:把外部物料,比如大量的 npm 组件,按“低代码引擎物料协议规范”描述出来,通过引擎 API 注册后在编辑器里使用。文档强调这里只是增加描述,不是重写组件,所以能最大程度复用已有的组件。
  • 编排:在编辑器里对物料做布局、增删改、写 JS/CSS 或逻辑编排,本质是不断生成符合“低代码引擎搭建协议规范”的页面描述。
  • 渲染:把编排生成的页面描述渲染成视图,要处理数据流、生命周期、事件绑定、国际化等。
  • 出码:把页面描述转换成另一种 DSL 或编程语言的代码。

所以这四个模块不直接互相调用,而是通过两份协议衔接:物料协议描述“有哪些积木、每块积木能怎么配”,搭建协议描述“这一页用积木搭成了什么样”。

物料怎样描述

物料协议里,一个组件的描述是一份 JSON,主要有三部分:基础信息,包括组件名、标题、描述、包信息等;属性信息,包括参数、说明、类型、默认值;以及能力配置,用来优化编辑体验。

右侧属性面板就是根据这份描述生成的。引擎把编辑属性用的控件叫设置器(Setter),比如字符串属性配一个输入框,颜色属性配一个取色器。组件开发者只写描述,不用为每个组件手写一个配置面板。

页面协议长什么样

搭建协议的组件树里,每个节点对应源码里 render 函数返回的一段 JSX,常用字段有:id 是组件唯一标识,由引擎生成,组件移动时要保持不变;componentName 是组件名,首字母大写,要能在 componentsMap 里找到对应的 npm 包;props 是属性;children 是子节点;另外还有 condition 控制是否渲染、loop 控制循环渲染。

属性值可以是普通 JSON,也可以是表达式或函数,用 { "type": "JSExpression", "value": "this.state.user.name" } 表示变量绑定,用 JSFunction 表示事件处理函数。协议顶层还有 version、componentsMap、componentsTree、i18n、dataSource 等字段。

下面用一个简化版走一遍渲染、拖拽和出码。组件注册表只登记了 Page 和 Button:

JavaScript
// 页面 schema:只描述用哪个组件、传什么属性、子节点是什么。
const schema = {
  componentName: 'Page',
  props: {},
  children: [
    { id: 'n1', componentName: 'Button', props: { text: '提交' } },
    { id: 'n2', componentName: 'Script', props: { code: 'alert(1)' } },
  ],
};

// 物料注册表:画布和渲染器只认这里登记过的组件。
const materials = {
  Page: (props, children) => `<main>${children.join('')}</main>`,
  Button: (props) => `<button>${props.text}</button>`,
};

function render(node) {
  const component = materials[node.componentName];
  if (!component) return `<!-- 未注册:${node.componentName} -->`;
  const children = (node.children ?? []).map(render);
  return component(node.props, children);
}
console.log(render(schema)); // <main><button>提交</button><!-- 未注册:Script --></main>

// 拖拽移动节点:只改 schema,id 保持不变,再重新渲染。
function moveNode(tree, id, toIndex) {
  const list = tree.children;
  const from = list.findIndex((item) => item.id === id);
  const [node] = list.splice(from, 1);
  list.splice(toIndex, 0, node);
}
moveNode(schema, 'n2', 0);
console.log(schema.children.map((item) => item.id).join(',')); // n2,n1

// 出码:同一份 schema 转成源码文本。
function toJsx(node) {
  const children = (node.children ?? []).map(toJsx).join('');
  const props = Object.entries(node.props)
    .map(([key, value]) => ` ${key}=${JSON.stringify(value)}`)
    .join('');
  return `<${node.componentName}${props}>${children}</${node.componentName}>`;
}
console.log(toJsx(schema.children[1])); // <Button text="提交"></Button>

第一行输出里,Script 没有渲染成任何东西,只留下一条注释。为什么不按名字去找一个全局函数执行?因为 schema 可能来自别人的编辑,渲染器只认注册表里的组件,没登记的名字一律不执行,这条边界在《低代码和 Schema 驱动 UI 怎样保持可维护与安全?》里有展开。

拖拽以后,n2 移到了最前面,但两个节点的 id 都没变。选中状态、撤销记录、属性面板都靠 id 找节点,所以协议要求移动时保持 id 不变。

最后的 toJsx 和 render 读的是同一份 schema,只是一个输出页面,一个输出源码。这就是“一份协议、渲染和出码各自实现”的意思。

画布和拖拽怎样实现

真实场景里节点有成百上千个,每个都要支持增删改、移动、插入子节点,直接改 JSON 不方便。所以引擎仿照 DOM 设计了节点模型和属性模型,用 node.remove() 这样的接口编排,最后再 export() 出 schema。

画布采用设计态和渲染态的双层架构:设计器和渲染器处在不同的 frame 里,渲染器以单独的 iframe 嵌入。文档给的理由有两个:一是让渲染器有更干净、更接近生产的运行环境,编辑器自己的样式和脚本不会干扰页面;二是方便用户按接口约束替换自己的渲染器。

拖拽方面,引擎在初始化时监听主文档和 iframe 里 contentDocument 的鼠标、键盘、拖拽事件,这些监听区域叫感应区(Sensor)。感应区负责两件事:一是事件对象转换,比如 iframe 内外的坐标换算;二是结合每个节点的描述,判断它能不能作为容器、能不能放进来,算出最终的落点。拖拽本身用 HTML5 拖放还是 Pointer 事件实现,取舍见《怎样实现拖拽排序?HTML5 拖放 API 和 Pointer 事件怎么选?》。

出码模块做什么

出码的输入是 schema 和出码方案,输出是一棵源码目录树。出码方案指具体的项目框架,引擎文档举的例子是 Rax 和 Ice.js。引擎的出码核心库 @alilc/lowcode-code-generator 支持在 Node 环境运行,也提供在浏览器里运行的 standalone 模式。

为什么需要出码?搭建协议的设计目标之一是“生成标准源码”,明确每个属性和源码的对应关系,生成和手写没有差别的代码。这样搭好的页面可以导出成普通项目,继续用常规方式开发和维护。

常见追问

schema 里允许写 JSFunction,安全怎么保证?

能写函数的人就能在页面里执行任意代码,所以编辑权限本身要严格控制,发布前要审核,修改要留记录。只给运营用的场景,可以不开放函数,只允许选择预先注册好的动作。服务端的校验和权限不能依赖页面配置。

为什么画布要用 iframe,而不是直接渲染在编辑器页面里?

引擎文档的理由是给渲染器一个更干净、更接近生产的运行环境,同时方便替换渲染器。放在同一个页面里,编辑器的全局样式和脚本会影响被搭建的页面,所见就不等于所得。代价是拖拽要处理 iframe 内外两套事件和坐标换算。

协议升级了,旧页面怎么办?

搭建协议顶层有 version 字段,引擎文档说不同的版本号对应不同的渲染 SDK,保证不同版本的产物都能正常渲染。自己设计时,也要在 schema 里记录版本,升级时写迁移函数把旧数据转成新格式。