10+核心功能点!低代码平台实现不完全指南 🧭(下)

一、前言

低代码平台通过可视化编排、简单配置以及少量的代码,实现快速开发和交付应用程序,受到了广大企业和开发者的青睐。当前,低代码平台已经演进出了图元编排、流程编排、页面编排和数据编排等多种形式,广泛应用于不同领域和行业,例如低码页面搭建、低码数据研发、多维表格、低码 AI 应用开发和业务流程自动化等。页面搭建是低代码领域最为常见的应用场景,本文也将以该场景为切入点,深入探讨如何通过低代码的方式实现页面的编排构建,包括组件拖拽、画布布局、组件渲染、组件联动、脚本执行、物料加载等核心功能。如需了解更多低码建站内容,可参考业界优秀产品 Retool(低码产品1️⃣哥)、Appsmith(开源)、ToolJet(开源)、Lowcode Engine(阿里,React 技术栈)、TinyEngine(华为,Vue技术栈)、tmagic-editor(腾讯)等。本文分为上下两篇,当前为下篇。

  • 上篇:组件物料、组件拖拽、组件布局、组件渲染、组件加载、属性设置
  • 下篇:脚本执行、组件联动、沙箱隔离、辅助操作、撤销重做、多页路由

二、整体概述

以 Lowcode Engine 为例,低代码平台中用户进行页面搭建的核心交互界面主要分为 4 个区域:

  • 画布区域:采用所见即所得的设计理念,对组件可进行编排组合,并且实时渲染;
  • 物料区域:组件资源集中管理区,用户可以选择合适的组件并拖拽到画布中使用;
  • 属性区域:在属性区域用户能够便捷地调整组件属性,还能设置组件的各种交互行为;
  • 工具栏:提供全局操作和辅助功能,例如撤销/重做、保存/发布、尺寸切换、预览等;

操作流程如下:用户在低代码平台完成组件拖拽、布局编排与属性配置,整个过程如同搭建积木------当设计完成后,系统自动生成满足协议规范的 JSON Schema(领域特定语言DSL) ,此即低代码平台的最终输出产物之一。页面渲染器则通过解析该 JSON Schema 进行页面的渲染,精准还原用户在编辑器中配置的页面结构与交互逻辑,实现设计态与运行态的一致。注意,页面渲染器也是可视化编辑器中画布区域的核心内容,用于内容的渲染。

⚠️ 低代码平台的另一种产物形态是出码,即 JSON Schema 转化为可二次开发的源代码,但二次开发之后是不可逆的,本文暂不讨论该场景。

三、功能实现

代码平台要想通过拖拽+配置的方式实现页面的搭建和渲染在实现过程中其实涉及非常多的功能点,下篇我们将重点探索其中的核心功能点:脚本执行、组件联动、沙箱隔离、辅助操作、撤销重做、多页路由等,剖析其设计与实现。在功能实现之前,首先需要确定的就是协议规范,也就是 JSON Schema 的格式。协议规范是低代码平台的基石,贯穿整个低代码的生命周期,离开协议规范所有的功能实现都是白扯,如果协议设计不好,想要扩展低代码平台是很困难的,甚至需要推翻重来。参考 Lowcode Engine 的协议:

  1. 《低代码引擎搭建协议规范》:定义通过用户拖拉拽搭建产出的协议,用于页面渲染
  2. 《低代码引擎物料协议规范》:规范低代码引擎渲染物料组件内容及其属性配置的标准
  3. 《低代码引擎资产包协议规范》:定义资产包的标准,用于多个物料的统一管理和消费

3.1 脚本执行

在属性/事件设置中我们经常会自定义脚本,主要分为两种形式:

  1. JSExpression - 表达式类型,用于变量绑定
JSON 复制代码
{
  "type": "JSExpression",
  "value": "this.state.count + 1"
}
  1. JSFunction - 函数类型,用于事件处理函数
JSON 复制代码
{
  "type": "JSExpression",
  "value": "function onClick() {\n  this.setState({\n  isShowDialog: true\n  });\n}"
}

那么如何将这些字符串变成可实际执行的内容呢?以 Lowcode Engine 为例,转换主要分为以下几个步骤,主要就两点:通过new Function创建沙箱函数 + 通过with绑定作用域,具体参考 parse.ts

  1. this 替换为 __self
  2. 使用new Function(...)创建沙箱函数
  3. with(__exports__) { with(__scope__) { ${code} } }
  4. 绑定作用域并执行函数
javascript 复制代码
const yourCode = `
function onClick() {
  this.setState({
    isShowDialog: true
  });
}`;
const functionCode = `
use strict;
var __self = arguments[1]; // 对应了传入的scope
return ${yourCode};
`;
const fn = new Function("__exports__", "__scope__", `with(__exports__) { with(__scope__) { ${functionCode} } }`);
fn(exports, scope || {});

除了new Function([arg1[, arg2[, ...argN]],] functionBody)可以执行字符串的函数,还可以通过eval(codeString)来解析并运行字符串形式的 JavaScript 代码,但不推荐,风险高,可以直接访问当前作用域,而 new Function 相对较安全,进行了环境隔离,仅支持访问全局作用域。

3.2 组件联动

低代码平台中的组件绝非单纯的视觉堆砌,更重要的是组件之间的联动,例如一个组件的操作改变另一个组件的值,一个组件触发另一个组件的事件等等,这样才能构建动态可交互的页面。要想实现组件 A 联动组件 B 主要有两种方式:

  1. 发布订阅模式:组件 A 触发 emit 一个事件,而组件 B 监听 on 一个事件,可以基于事件总线 EventBus 实现;
  2. 全局数据模式:
    • 组件 A 触发事件更新全局数据,而组件 B 中使用了全局数据,从而实现更新的效果,可使用状态管理库;
    • 组件 A 和组件 B 的实例挂在全局,组件 A 可以直接根据组件 B 的实例调用对外暴露方法;

在 Retool 这样的低代码平台中事件联动会进行表单的配置,极大降低了上手的成本,要如何实现这样的效果呢?需要同时满足「触发条件」与「接收条件」:

  • 触发条件:组件 A 知道自己能抛出什么事件(Event)。
  • 接收条件:组件 B 告诉平台我能做什么动作(Action)。

组件 B 若想被其它组件"遥控",必须提前声明两件事:

步骤 目的 具体实现(以 Vue 3 为例)
① 暴露可调方法 让外部真正"调得动" 使用 defineExpose({ doSomething })
② 声明元数据 让外部"知道能调什么" 在组件描述协议(Component Descriptor)里补充 actions 字段
xml 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(0)
function increment() {
  count.value++
}
function decrement() {
  count.value++
}
defineExpose({
  decrement,
  increment
})
</script>
JSON 复制代码
{
  "supports": {
    "actions": [
      {
        "label": "增加值",
        "value": "increment"
      },
      {
        "label": "减少值",
        "value": "decrement"
      }
    ]
  }
}

A 组件要配置要操作的事件在事件面板中需要设置 3 项内容:

输入项 来源 作用
Event 组件描述协议中的 events 字段 枚举「我能抛什么事件」:onChange、onFocus、onBlur ...
Action 平台内置动作列表 枚举「我能做什么」:控制其他组件、发起请求、执行 JS ...
Action 配置 动态表单 根据选中的 Action 类型,再填写具体参数
  1. 控制其他组件:从 componentTree 中列出当前画布所有组件,选中需要操作的组件,根据组件的描述协议列出可用 actions,然后进一步选择和配置;
  2. 发起请求:列出数据源里已配置的所有 Query,然后选择需要执行的即可;
  3. 执行 JS:代码编辑器中直接写任意 JavaScript 执行代码即可;

最终组件联动的 JSON Schema 参考如下:

JSON 复制代码
{
  "eventName": "onChange", // 触发事件名
  
  // 控制其他组件场景
  "actionType": "component", // 动作类型
  "component": "button-XXX", // 组件识别ID
  "action": "setValue", // 组件对外可执行的操作
  "value": {
    "type": "JSExpression",
    "value": "Hello", 
  },
  
  // 发起请求场景
  "actionType": "dataSource", // 动作类型
  "value": "fetchData", // 请求识别ID
  
  // 执行js场景
  "actionType": "custom", // 动作类型
  "customJS": {
    "type": "JSFunction",
     "value": "function(...args) {\n  console.log("hello")\n}"
  }
}

注意,组件 A 执行事件 X 操作组件 B,在渲染的时候除了组件 A 原本的 props,还可以额外注入统一的事件方法dispatchEvent,用于执行配置的 action,参考如下:

xml 复制代码
// 组件 A 中代码
<script setup>
const props = defineProps<{dispatchEvent:() => void}>();

const handleChange = (val) => {
  props.dispatchEvent('onChange', val);
}
</script>
javascript 复制代码
/**
 * @param scope 全局上下文
 * @param node 当前节点
 * @param events 组件事件列表
 * @param eventName 触发的事件
 * @param data 触发事件的参数
 */
const dispatchEvent = (scope, node, events, eventName, data) => {
  // 1. 找到events中与eventName匹配的事件列表
  // 2. 将事件结合上下文逐一进行执行,参考上文脚本执行
};

parsedProps.dispatchEvent = dispatchEvent.bind(scope, node, events); // 将这个作为props一项传入组件中进行回调调用

3.3 沙箱隔离

低代码平台页面搭建完成后,如果通过渲染 SDK 结合 JSON Schema 进行内容渲染整合至宿主应用的话,我们就需要做好沙箱隔离,不然就会面临一些风险,例如多个低代码模块同时存在但组件版本不一致发生冲突、全局变量会与宿主环境冲突改写、样式内容也会与宿主环境冲突影响。这些情况都要我们做好低代码组件和宿主应用之间 JS 和 CSS 层的隔离,沙箱存在非常多的方案,推荐阅读《面向微前端,谈谈 JavaScript 隔离沙箱机制的古往今来》

  1. 基于 Proxy 快照存储 + window 修改的实现
  2. 基于 Proxy 代理拦截 + window 激活/卸载的实现
  3. 基于普通对象快照存储的 window 属性 diff 实现
  4. 基于 iframe + 消息通信的实现
  5. 基于 ShadowRealm 提案的实现
  6. 基于 with + eval 的简单实现

wujie 框架使用 iframe + shadow DOM 的方案,进行了较好的隔离,其中 shadow dom 负责渲染,处理dom和样式的隔离,而 iframe 负责处理 js 逻辑,进行 js 的隔离,并且两者之间进行代理和劫持,JS 中的 DOM 处理能够正确反映在 shadow DOM 中。

3.4 辅助操作

我们前面提到过componentTree很重要哦,接下去我们所有在低代码平台上的配置操作其实本质上都是在修改这一份componentTree数据。那么组件层级调整、组件复制和组件删除等操作其实本质也是在修改这一个 componentTree数据:

  • 组件删除:删除id对应的节点数据;
  • 组件复制:复制对应id的节点数据,并插入到节点数组的后面;
  • 组件剪切:剪切操作本质上还是复制,只不过在执行复制后,需要将当前组件删除;
  • 组件层级调整:节点数据在数组中的位置或者在整个树结构中的位置;
  • 组件移到最前/最后:就是将当前组件的节点数据移动到数组开头/结尾;

组件的复制、剪切和粘贴除了可以通过按钮控件执行,还支持通过快捷键实现,因此就需要全局监听键盘操作:

javascript 复制代码
const keys = {
  ctrl: 'Control',
  cmd: 'Meta',
  c: 'c',
  v: 'v',
  x: 'x'
};

let isModifierDown = false;

window.addEventListener('keydown', (e) => {
  if (e.key === keys.ctrl || e.key === keys.cmd) {
    isModifierDown = true;
  } else if (isModifierDown && e.key === keys.c) {
    console.log('Copy action');
  } else if (isModifierDown && e.key === keys.v) {
    console.log('Paste action');
  } else if (isModifierDown && e.key === keys.x) {
    console.log('Cut action');
  }
});

window.addEventListener('keyup', (e) => {
  if (e.key === keys.ctrl || e.key === keys.cmd) {
    isModifierDown = false;
  }
});

3.5 撤销重做

javascript 复制代码
snapshotData: [], // 编辑器快照数据
snapshotIndex: -1, // 快照索引
 
  // 撤销
undo(state) {
    if (state.snapshotIndex >= 0) {
        state.snapshotIndex--
        store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
    }
},
//重做
redo(state) {
    if (state.snapshotIndex < state.snapshotData.length - 1) {
        state.snapshotIndex++
        store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
    }
},
 
// 增加快照
recordSnapshot(state) {
    // 添加新的快照
    state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData)
    // 在 undo 过程中,添加新的快照时,要将它后面的快照清理掉
    if (state.snapshotIndex < state.snapshotData.length - 1) {
        state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
    }
}

在可视化低代码编辑器中,用户每一次操作,例如新增 / 删除 / 属性变更 / 事件变更 / 组件移动都会改变画布状态。为了让用户能够一键撤销或重做,我们需要保存操作记录。最直观的方案如上所示的方式,维护一个快照数组 snapshotData 和一个指针 snapshotIndex,操作变动不断往其中 push 数据,将当前的编辑器数据推入 snapshotData 数组,增加快照索引 snapshotIndex。

  • 撤销:索引-1,并且对应保存的数据恢复成当前的 JSON Schema;
  • 重做:索引+1,并且对应保存的数据恢复成当前的 JSON Schema;

注意,保存操作记录最简单的方法是每一个快照都保存为完整的 JSON Schema 数据,这样的优势是实现简单,存取方便,但是缺点也很明显,就是大量重复的数据,因此,我们可以做增量快照,只保存变更部分而非完整状态,例如参考 immer 库。

3.6 多页路由

所有路由的展示应该是一个树型结构,对应的不同页面相当于我们是存储多份 json tree 我们对不同页面的切换本质上是使用不同的 json tree 来渲染页面,所以我们最终存储的 json tree 应该是多个 json tree 的组合类似如下:

JSON 复制代码
{
  "pages": {
    "main": {
      "json": {
        ...tree1
      }
    },
    "signin": {
      "json": {
        ...tree2
      }
    }
  }
}

当我们需要切换不同的页面时,我们就要监听页面路由的变化,在路由变化时我们需要切换使用不同的 json tree。

四、总结

低代码平台通过将可视化设计与底层技术实现分离,极大地简化了应用开发流程。本文详细剖析了低代码平台的12个核心功能模块,揭示了平台背后的技术原理,本文分为上下两篇,当前为下篇,其余内容请阅读上篇。从实现来说,低代码就是有一个大型组件可以传入 props 来渲染,这个大型组件就是渲染器,低代码平台就是生产这个满足协议的 props 的,整体实现思路都可以从这个角度去思考。

参考资料

相关推荐
天下权几秒前
抛弃脚手架!手写极简Vue2实现原理
前端
张元清2 分钟前
Neant:0心智负担的React状态管理库
前端·javascript·面试
阳树阳树3 分钟前
小程序蓝牙API能力探索 1——蓝牙协议发展历史
前端
阿华的代码王国5 分钟前
【Android】PopupWindow实现长按菜单
android·xml·java·前端·后端
ygming14 分钟前
Q51- code295- 数据流的中位数 + Q52- code767- 重构字符串
前端
袋鱼不重16 分钟前
手把手搭建Vue轮子从0到1:4. Reactivity 模块的实现
前端·vue.js·源码
!win !16 分钟前
免费的个人网站托管-GitHub Pages篇
前端·开发工具
xw518 分钟前
免费的个人网站托管-GitHub Pages篇
前端·github
阿星AI工作室19 分钟前
扣子可以发布到小米搞钱了!手把手教程来了丨coze开发者瓜分亿级流量池指南
前端·人工智能·后端
小华同学ai20 分钟前
GitHub 开源爆款工具|MediaCrawler:程序员零门槛采集抖音/小红书/B站等社交评论,30K star 背后的场景实战揭秘!
前端·后端·github