最近这几年低代码这个词很火我想对于大多数程序员来说都不算陌生,从百度搜索指数也可以看出,其搜索量从21年开始有了一个明显的增长。
那低代码或者无代码技术目前有哪些应用场景呢?其底层的技术是什么样的?其如何与当今大火的 AIGC 结合呢?正好在过去一年的工作中我作为项目的核心技术负责人从 0 到 1 经历了我们公司内部低代码平台的建设过程,在这其中也有了许多心得希望通过这篇文章分享给大家。
注:如某些图片展示不清或者在PC端更加建议大家点击左下角的“阅读原文”去语雀进行查看。
一、技术方案
整体流程
一般来讲一个低代码平台前端核心由三部分组成分别为:组件面板、画布、配置面板。
类似这样:
这样:
正常的搭建过程一般是从左侧组件面板中拖拽组件到画布中,之后选中该组件使用右侧的配置面板对该组件的样式等内容进行配置,通过使用不同的组件最终直到一个完整的页面生成,过程类似搭积木,当然在搭建的过程中也需要有预览功能等模拟浏览器真实环境对搭建产物进行调试。
那么我们拖拽的每个组件究竟是如何拼装成页面的?其实本质上我们是将一个页面抽象为若干组件,这些组件以相应的关系拼接在一起最终构成了一个完整的页面,举个简单的例子:
比如如上的苹果官网首页,从上到下我们大致可以将其分为四个组件:导航栏 Nav 组件、背景 Background组件、Link 组件导航组件、以及最底层承载所有组件的 Root 根组件。
如果需要描述这些组件之间的关系我们可以使用类似如下的一个扁平树形结构(没有使用多层嵌套结构而使用扁平树形结构的原因是扁平化结构便于操作):
const tree = {
root:{
child:[nav,background],
parent:null,
},
nav:{
child:[],
parent:root
},
link:{
child:[]
parent:background
},
background:{
child:[link],
parent:root
}
}
我们可以发现该树的结构是一个以组件名作为 key,parent 和 child 作为属性值的对象,核心描述了每个组件之间的父子关系。当然也可以有多种拆分方式,根据实际的业务情况来定。
有了描述所有组件之间父子关系的树,要构成完整页面我们还需要有用来拼装完整页面的组件(可以是一个普通 React 或者 Vue 组件),以及一个渲染器逻辑用来递归遍历这棵树将所有的组件拼装在一起构成完整页面。
这样我们低代码搭建平台生成完整页面的流程就很清晰了如下图所示:
当我们访问低代码搭建平台,通过拖拖拽拽生成的页面时本质上是生成了一份 json,这份 json 描述了页面中所有使用的组件以及这些组件之间的父子关系,当我们保存或者发布页面时这份 json 会被进行保存。
通常由 node 的 bff 层根据这份 json 来拼装出完整的 html,具体细节是首先遍历这份 json 得到页面中所有使用到的组件名称比如:nav组件、root组件,由组件名称根据固定格式(就是具体组件资源发布的源)拼装出对应的组件 cdn 地址,之后将 json、组件 cdn 资源、渲染器 cdn 资源插入在一个 html 中,这样便形成一个我们从用户端访问的由低代码搭建平台生成的html页面。
html结构大致如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="keywords" content="" />
<meta name="description " content="" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>低代码页面</title>
</head>
<body>
<script id="content" type="application/json">
{
root:{
child:[nav,background],
parent:null,
},
nav:{
child:[],
parent:root
},
link:{
child:[]
parent:background
},
background:{
child:[link],
parent:root
}
}
</script>
<div id="root"></div>
</body>
<script type="text/javascript" src="https://xx-cdn/root.js"></script>
<script type="text/javascript" src="https://xx-cdn/link.js"></script>
<script type="text/javascript" src="https://xx-cdn/background.js"></script>
<script type="text/javascript" src="https://xx-cdn/nav.js"></script>
<script type="text/javascript" src="https://xx-cdn/render.js"></script>
</html>
当用户访问页面时首先所有的 js 资源会被加载,当最后 render.js 加载完成后首先其会从 id 为 content 的 script 标签中拿到 json tree,之后根据这份 json tree 从 root 组件开始使用如 React.createElement 递归将所有的组件拼装在一起组成完整的页面。
讲完了页面拼装的大致流程后还有两个问题需要被解答:
-
我们是通过 script 标签的方式加载组件资源的,那么我们究竟是如何在遍历 json tree 时根据具体的组件名称拿到对应的组件物料? -
我们的组件资源是如何进行开发并发布到 cdn 上?
要回答这两个问题实际上我们要讲一下具体的组件构建以及发布流程,当我们低代码平台被发布时本质上会发布两套资源对应的是独立的两套 webpack 构建脚本,一套是低代码搭建平台的前端项目,在低代码搭建平台发布时项目会被正常构建打包并署到相应的服务器上用来为搭建平台的前端提供服务,而搭建平台中所有使用到的组件资源会在低代码搭建平台构建的同时被另外一套脚本进行打包构建为单独的组件资源,发布到 cdn 上,最终会得到类似如下的组件cdn资源地址:
https://xx-cdn/render.js
相信讲完了组件构建和发布的具体流程后第二个问题已经基本可以回答了,那么第一个问题我们的渲染器在工作时究竟是如何通过具体的组件名称拿到对应的组件引用的?回答这个问题需要具体讲讲组件资源构建的方式了,在 webpack
构建脚本的 output
配置中有一个 libraryTarget
配置当我们取其值为 window
时构建后的资源在被加载完成后会自动被挂载到 window
全局,我们可以通过相应的“window.组件名”的方式来获取到相应的构建资源。
output: {
path: "dist",
library: "[name]",
filename: "[name].js",
libraryTarget: "window",
},
除了所有的组件资源需要被挂载在 window 上外,低代码组件的构建脚本和其他常规前端项目的构建脚本还有两点差异,一、每一个组件需要被单独打包构建,也就是说每一个组件需要作为单独的一个入口文件,二、在对所有组件资源进行打包构建时需要配置 optimization 来将所有组件中使用到的公共资源等抽离出来放在单独的vender、common、manifest文件中,来减少每个组件构建的体积,同时在html中也需要引入相应的公共资源,所以一个低代码构建的html页面应该如下所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="keywords" content="" />
<meta name="description " content="" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>低代码页面</title>
</head>
<body>
<script id="content" type="application/json">
{
root:{
child:[nav,background],
parent:null,
},
nav:{
child:[],
parent:root
},
link:{
child:[]
parent:background
},
background:{
child:[link],
parent:root
}
}
</script>
<div id="root"></div>
</body>
<script type="text/javascript" src="https://cdn/vendor.js"></script>
<script type="text/javascript" src="https://cdn/common.js"></script>
<script type="text/javascript" src="https://cdn/manifest.js"></script>
<script type="text/javascript" src="https://cdn/root.js"></script>
<script type="text/javascript" src="https://cdn/link.js"></script>
<script type="text/javascript" src="https://cdn/background.js"></script>
<script type="text/javascript" src="https://cdn/nav.js"></script>
</html>
在讲完通过低代码搭建平台构建完整的低代码页面流程后,接下来我们从整体上来看看我们低代码搭建平台的技术方案是什么样的,我们究竟是如何实现组件的拖拽、组件配置的动态更新、组件渲染、以及低代码搭建平台的前端项目架构是如何进行设计的来保持较好的可迭代性和可维护性,低代码搭建平台的核心难点是什么?
详细技术方案
要解决组件拖拽、通过配置面板更新组件状态、以及生成 json tree 并根据 json tree 渲染组件等能力我们可以基于浏览器的原生 api
进行实现,但考虑到经济性和成本性原因我们直接使用开源框架 craftjs[1] 比较方便。
craftjs 其本质上是一个网页编辑器框架,基于 React 技术栈,核心实现了组件在画布中创建、自定义组件配置面板、组件状态动态更新、操作记录回滚等能力,提供相应的 api 供我们开箱即用,这里有相应的 deom 教程[2],对于React 熟悉的朋友可以很容易可以熟悉起来构建一个属于自己的简单的网页编辑器。具体 craftjs
底层是如何实现的后面我会给大家一一解析,我们先将重点放在整体流程上。
虽然有了像 craftjs 这样的拖拽框架可以帮助我们快速实现低代码搭建的平台的底层能力,但距离一个可商用的低代码平台还很远,所以在其上我们封装了三大通用能力:组件布局及拖拽、组件事件配置、多页面路由系统,等来实现针对营销领域页面具有的搭建页面 UI 复杂、对设计稿还原度要求高,需要支持组件配置事件来实现页面跳转、请求接口、组件联动等能力,同时还需要支持页面级子路由切换,实现多页面搭建能力(具体详细实现方法会在后面详细介绍)。
组件布局及拖拽
从业务角度讲对于低代码组件我们通常有两种布局方式,一是自由布局,简单讲就是组件需要根据最终鼠标拖动的位置来放置,这种布局主要用在营销、官网等需要对设计稿进行一定的还原同时布局比较复杂的场景。
具体实现上也相对比较简单,低代码组件的布局方式本质上是由组件自身的属性来决定的,也就是说当我们设置一个组件容器为相对定位,设置其子组件为绝对定位,那我们就可以通过设置子组件的 top 和 left 属性来更新一个组件的位置,具体如果要实现子组件根据鼠标的位置进行定位,我们只需要监听子组件的 onMouseDown
事件来根据鼠标相对父容器的位置设置子组件对应的 top
和 left
的值便好。
至于我们拖拽组件的边缘改变组件的大小尺寸我们可以在 re-resizable[3] 的基础之上封装进行实现,其有相应的回调可监听一个节点边缘被拖拽,同时可拿到节点边缘被拖拽的位置,我们根据该位置动态更新组件的 width
和 height
便好。
<Resizable
size={{ width: this.state.width, height: this.state.height }}
onResizeStop={(e, direction, ref, d) => {
this.setState({
width: this.state.width + d.width,
height: this.state.height + d.height,
});
}}
>
Sample with size
</Resizable>
但是要注意我们监听组件本身的onMouseMove
事件修改组件的位置,和监听组件的 onDragstart
拖拽事件这两者是存在冲突的,原理就是onMouseMove
是onDragstart
的子事件,也就是说触发onDragstart
一定会触发onMouseMove
事件,要解决也比较简单,首先要阻止事件的冒泡,以及阻止onMouseMove
的默认事件也就是使用preventDefault
,这里可能是比较小的点但在我们当时开发的过程中也是存在很多这样的坑。
另一种是自动布局,也就是根据 html 的块元素、行内元素等特点进行正常布局,这种布局主要用在对 UI 已经有一定的规范性标准,对还原度要求不高重逻辑的搭建场景,比较典型的就是表单搭建。
其实现就更简单了我们不需要做任何额外操作,在使用 craftjs 的基础之上组件会自动被放置在距离鼠标最近的容器内,具体最终在哪个位置跟 html 的默认布局决定。
总结:
其实讲了这么多低代码组件布局的核心就是按照正常的 html+css
布局方式来实现的,背后没有什么复杂的技术,也就是说 html+css
能实现的布局方式所有的低代码组件都能实现,但具体要实现到什么程度还需要根据实际的业务场景来看,如果是针对于内部系统比较多的官网、营销页面从0到1的搭建,那实现功能比较强的布局方式还是很有必要的,但对应的成本也会上升很多。
但如果是独立建站等对客的系统,实现比较复杂的布局能力其实意义不大,因为在对客系统的核心竞争力是模版能力,重点是可以提供种类较多、符合实际使用场景的模版,用户很难有从0到1去搭建一个页面的诉求,更多的是基于现有的模版进行修改和替换。
最后总的来说一个低代码组件的布局方式和一个普通 html 元素的布局方式一样,重点是如何设计并且实现出符合实际业务场景的布局方式,同时需要具有较好的交互性,便于用户去理解和使用。
组件事件系统
讲完了组件布局方式等问题后我们再来看看如何去让一个组件支持事件配置,简单来说就是需要支持按钮可配置点击后跳转指定地址、打开弹窗、调用接口等。
要实现该功能有两个前提和一个难点,一个前提是该功能必须具备通用性,也就是每个组件都需要支持事件配置,同时也可以注册自己的事件到全局供其他组件去调用,第二个前提是事件系统必须具备可扩展性,也就是一个组件可以配置多个不同的事件,比如点击、初始化。
该功能的难点是我们要实现一个组件可以调用接口、可以打开弹窗等功能也就意味着我们每个组件需要支持方法的调用,我们实现的方式是将该方法作为组件的 props
属性,在组件渲染的时候将该 props
传入组件中,组件中 props 中取出相应的事件方法,自己来决定在什么情况下调用,比如点击、初始化等。至于该 props 如何进行存储呢?同时要方便修改我们想当然的是存储在 json tree 上,以 Link 组件击后跳转到百度为例:
const tree = {
root:{
child:[nav,background],
parent:null,
},
nav:{
child:[],
parent:root
},
link:{
child:[]
parent:background,
props:{
onClick:()=>{
window.href = "http://www.baidu.com"
}
}
},
background:{
child:[link],
parent:root
}
}
const Link: CraftComponent = (props) => {
const { onClick } = props;
// 通过 useRegister 声明该组件需要支持 onClick 事件,这样配置面板为该组件开发 onClick事件配置入口
useRegister({ events: ["onClick"] }, []);
return (
<Button
onClick={debounce(() => onClick && onClick(), 300)}
>
Link组件
</Button>
);
};
这样看貌似没有什么问题,我们在搭建过程中通过组件配置面板为该组件配置 onClick
事件,组件自己决定如何使用该事件,但这样最大的问题是存在于 json tree 上的方法是无法被序列化的,也就是我们是无法通过 json
格式来存储 js 中的方法,除非去存储一个类似字符串格式的方法,这样扩展性太差了。
其次是如果我们要存储一个完整的方法,那当我们需要修改跳转地址从百度跳转到阿里巴巴这也是很难做到的,很明显我们要通过形参的方式来让跳转事件变得可配置化,同时不能再将具体的执行方法存储在 json tree 上了。
最终我们实现事件可配置化的方案如下:
其实原理很简单,就是本质上我们将一个组件的事件方法拆分成两部分,一部分是行为方法如果是跳转行为则为这样:
{
actionName: "打开链接", // 行为名称,在为组件配置行为时做展示使用
type: "fn", // 事件处理函数类型如果是fn,则为方法,如果是query则是调用接口等
params: ["url"], // 事件处理函数入参,该值表明该行为有什么参数需要用户配置
handle: (url) => { // 事件处理函数
window.href = url
},
}
一部分是事件 schema 类似这样:
// 数组结构便于扩展性,每个组件可能配置多个事件,每个事件中可能有多个行为
events:[
{
type:"onClick", // 事件触发的方式,可以是 onClick、onHover、init等
actions:[
{
type:"fn", // 行为类型,fn代表方法,query代表调用接口
actionName: "打开链接", // 行为名称,核心字段代表具体要使用那个行为,和上图的行为方法名对应
params:[{ // 行为方法的参数是什么,根据用户配置事件时生成
key:"跳转url",
value:"www.baidu.com"
}],
}
]
}
]
在我们为某个组件配置事件时本质上就是最终生成了一份事件 schema,结构类似如上,核心描述了该组件触发事件的方式等,最终该事件 schema 会被存储在 json tree 上,当组件根据 json tree 渲染时我们会对该事件 schema 进行解析,从全局 store 中拿到相应的行为,最终根据行为和事件 schema 中配置的行为参数进行组合生成完整的事件方法通过 props 传递给该组件。
最终生成的行为方法如下:
// 事件 schema 和事件行为将组合成类似如下的事件方法
const onClick = () => {
(url){
window.href = url
}("www.baidu.com")
}
// 通过 props 传递给组件
<Link onClick={onClick} />
时序图如下:
多页面路由系统
低代码搭建页面需要支持多级页面搭建,首先要在配置侧有比较合理的多级页面配置方式,以 wix 举例:
所有路由的展示应该是一个树型结构,对应的不同页面相当于我们是存储多份 json tree 我们对不同页面的切换本质上是使用不同的 json tree 来渲染页面,所以我们最终存储的 json tree 应该是多个 json tree 的组合类似如下:
pages:{
main:{
json:{
...tree1
}
},
signin:{
json:{
...tree2
}
}
}
当我们需要切换不同的页面时首先我们要监听页面路由的变化,具体是使用 hash 路由还是 history 路由可由实际业务场景决定,在路由变化时我们需要切换使用不同的 json tree ,具体方法我们可以利用 craftjs 中提供的actions.deserialize
来传入不同的 json tree 来实现,整体流程就是这样当然还有很多细节这里就先不细说了。
craftjs 底层原理
讲完了上层技术方案后我们再来看看 craftjs 底层究竟是如何实现一个组件被拖拽到画布中,同时如何实现组件的渲染以及通过配置面板来更新组件状态的。
其实要实现一个组件的拖拽很简单,我们用原生js来实现一个 demo 看看:
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title></title>
<style>
.droptarget {
float: left;
width: 100px;
height: 35px;
margin: 15px;
padding: 10px;
border: 1px solid #aaaaaa;
}
</style>
</head>
<body>
<p>在两个矩形框中来回拖动 p 元素:</p>
<div class="droptarget">
<p draggable="true" id="dragtarget">拖动我!</p>
</div>
<div class="droptarget"></div>
<p style="clear:both;"><strong>注意:</strong>Internet Explorer 8 及更早 IE 版本或 Safari 5.1 及更早版本的浏览器不支持 drag 事件。</p>
<p id="demo"></p>
<script>
/* 拖动时触发*/
document.addEventListener("dragstart", function(event) {
//dataTransfer.setData()方法设置数据类型和拖动的数据
event.dataTransfer.setData("Text", event.target.id);
// 拖动 p 元素时输出一些文本
document.getElementById("demo").innerHTML = "开始拖动 p 元素.";
//修改拖动元素的透明度
event.target.style.opacity = "0.4";
});
//在拖动p元素的同时,改变输出文本的颜色
document.addEventListener("drag", function(event) {
document.getElementById("demo").style.color = "red";
});
// 当拖完p元素输出一些文本元素和重置透明度
document.addEventListener("dragend", function(event) {
document.getElementById("demo").innerHTML = "完成 p 元素的拖动";
event.target.style.opacity = "1";
});
/* 拖动完成后触发 */
// 当p元素完成拖动进入droptarget,改变div的边框样式
document.addEventListener("dragenter", function(event) {
if ( event.target.className == "droptarget" ) {
event.target.style.border = "3px dotted red";
}
});
// 默认情况下,数据/元素不能在其他元素中被拖放。对于drop我们必须防止元素的默认处理
document.addEventListener("dragover", function(event) {
event.preventDefault();
});
// 当可拖放的p元素离开droptarget,重置div的边框样式
document.addEventListener("dragleave", function(event) {
if ( event.target.className == "droptarget" ) {
event.target.style.border = "";
}
});
/*对于drop,防止浏览器的默认处理数据(在drop中链接是默认打开)
复位输出文本的颜色和DIV的边框颜色
利用dataTransfer.getData()方法获得拖放数据
拖拖的数据元素id(“drag1”)
拖拽元素附加到drop元素*/
document.addEventListener("drop", function(event) {
event.preventDefault();
if ( event.target.className == "droptarget" ) {
document.getElementById("demo").style.color = "";
event.target.style.border = "";
var data = event.dataTransfer.getData("Text");
event.target.appendChild(document.getElementById(data));
}
});
</script>
</body>
</html>
所以其实实现拖拽是很简单的首先我们需要设置被拖拽元素的 draggable
属性为true
,之后我们有dragstart、drag、dragend、dragenter、dragover、dragleave、drop等事件来监听拖拽过程的整个生命周期,另外得提一下的是在拖拽过程中我们可以从拖拽的event
事件对象中通过event.dataTransfer.setData
传递一些数据,同时在拖拽的事件对象中也有相应的api
来修改拖拽的指示器样式等。
讲完了如何从底层实现拖拽能力,我们再来看看 craftjs 内部是如何维护一棵 json tree 的,同时当我们通过配置面板修改一个组件的属性的时候,组件究竟是如何进行更新的。
这里直接给大家讲具体的原理,其实很简单,在 craftjs 内部其实维护了一棵和我们最终存储的 json tree 十分类似的树,唯一不同的区别是该树上存储了每个组件的具体实例类似这样:
const tree = {
root:{
child:[nav,background],
parent:null,
type:Root // 组件实例
},
nav:{
child:[],
parent:root,
type:Nav // 组件实例
},
link:{
child:[],
parent:background,
type:Link // 组件实例,
},
background:{
child:[link],
parent:root,
type:Background // 组件实例
}
}
要通过这棵树渲染出完整的页面,我们需要首先从 root 节点开始进行渲染,首先通过 React.createElement(Root,props,children)
来渲染根节点,对应的 children
的值为 root
节点中 child 对应的节点,也就是这样:
[React.createElement(Nav,props,children),React.createElement(Nav,props,Background)]
通过这样的不断递归最终我们只需要去将Root节点挂载在页面中,就完成了所有组件的渲染工作。这里具体代码我就不贴了,有兴趣的可以去看 craftjs 的源码,可以从这里[4]开始看核心渲染原理。
通过将 json tree 渲染出来后,我们还需要通过配置面板来动态修改组件的状态,其实也非常简单我们只需要对这棵树进行一次代理,也就是使用 Proxy
,在他的任意属性变动时去让整棵树重新渲染一次便好,在 craftjs
底层是使用了 immer
对整棵树进行了代理,具体如何去实现这里展示一下我个人实现的一个类似 craftjs
中通过 useNode
使用 setProps
的伪代码:
import React, {
FC,
ReactElement,
createContext,
useMemo,
useState,
} from "react";
import { produceWithPatches } from "immer";
const initState = {
root: { props: { num: "red" } },
button: { props: { num: "green" } },
};
export const CraftProvider = createContext(null);
const Test: FC<{ children: ReactElement }> = ({ children }) => {
const [data, setData] = useState(initState);
const dispatch = (fn) => {
const [nextState, patches, inversePatches] = produceWithPatches(
data,
(draft) => {
fn(draft.root.props);
}
);
console.log(patches, inversePatches, "inversePatches");
setData({ ...nextState });
};
const actions = useMemo(() => {
const setProps = (fn) => {
dispatch(fn);
};
return {
setProps,
};
}, [data]);
return (
<CraftProvider.Provider
value={{
data,
actions,
}}
>
{children}
</CraftProvider.Provider>
);
};
export default Test;
import React, { FC, useContext } from "react";
import { CraftProvider } from "./Test";
const TestChild: FC = () => {
const context = useContext(CraftProvider);
const { data, actions } = context;
return (
<div>
<div>{data.root.props.num}</div>
<button
onClick={() => {
actions.setProps((props) => {
props.num = Math.random();
});
}}
>
change
</button>
</div>
);
};
export default TestChild;
其实本质上很简单,我们是通过 immer
对整棵树代理一次,通过在最外层包裹一下 Context
下发数据以及修改数据的方法,在数据变化时组件状态便会动态更新。该 demo 和 craftjs 不同的是 craftjs 是重新渲染整棵树,传入新的 props,但原理上都一样。这一部分的源码在这里[5]。
配置面板
之后在配置面板部分,我们出于可复用性考虑我们首先抽象出了一层属性组件,包括位置、背景填充、尺寸等,开发新的组件可零成本复用这些已抽象好的组件。同时出于可扩展性考虑,在属性组件的基础之上我们又抽象了一层原子组件,主要是对输入、多选、单选、滚动条等在基本属性组件无法满足需求的场景下进行使用,类似如下可以复用一个数字输入原子组件对自己的宽属性进行修改:
{
props:"width",
type:"inputNumber",
label:""宽
}
最终渲染结果如下:
组件拆分及开放能力
以上技术架构已可以满足基本的低代码应用场景了,但随着组件增多低代码项目的整体大小将会不断地趋向于无限大,同时多个组件维护在一个项目里导致项目的可维护性逐步降低,最好的做法是将所有的组件独立拆分出去,通过统一的低代码脚手架进行独立开发、独立调试、独立发布。
在组件发布后我们通过统一的低代码资源管理平台对所有的组件进行统一的管理,同时还能在低代码组件资源管理平台圈选特定业务范围的组件资源构成组件资产包,相应的低代码平台通过资产包 key 拉取相应的资产包渲染出符合不同业务场景的低代码组件物料,真正实现低代码平台的开放能力,不再局限于只有低代码平台的维护者才可以对低代码物料进行开发,整体架构就是上图中的绿色部分。
最终所有的组件就不再需要统一维护在低代码平台中,而是通过 Monorepo 组件仓库进行统一维护,在低代码搭建平台渲染时,通过统一的接口异步拉取所有组件资源进行渲染,加载组件资源方法如下:
function loadJS(src: string, clearCache = false): Promise<HTMLScriptElement> {
const p = new Promise<HTMLScriptElement>((resolve, reject) => {
const script = document.createElement("script", {});
script.type = "text/JavaScript";
if (clearCache) {
script.src = src + "?t=" + new Date().getTime();
} else {
script.src = src;
}
if (script.addEventListener) {
script.addEventListener("load", () => {
resolve(script);
});
script.addEventListener("error", (e) => {
reject(e);
});
}
document.head.appendChild(script);
});
return p;
}
二、项目难点
PC及H5两端适配
对于常见的低代码平台来说,要实现一套产物适配两端一般有两种方案,一种是类似于 Shopify[6] 建站平台,没有绝对定位组件,通过规范化 UI 在组件层面将 H5 和 PC 的适配兼容掉,提供足够丰富的模版让用户只需在其基础之上进行文案、图片的替换等工作便可实现建站需求。
第二种是营销或者官网搭建的场景,组件需要进行绝对定位,页面 UI 复杂度高,这类场景和我们当前平台的现状是十分符合的,要解决 PC 及 H5 适配一般也有两种方法,一种是类似凡科建站 PC 及 H5 分别使用两套搭建产物,通过在运维层面拿到 UserAgent 进行重定向到相应的页面上,同时要考虑产品层面两端的搭建产物进行关联。
第一种方案从交互角度来讲是比较不友好的,从用户的角度出发,用户一定是更加希望我搭建一个 PC 的页面可以给我自适配 H5 的页面出来,但问题的难点是我们组件中拥有绝对定位的场景,我们很难确定一个规范在 PC 端某个位置的组件在 H5 端应该在什么位置,后来通过反复思考以及参考 Wix 搭建平台,最终我们确定所有组件的样式配置在 PC 及 H5 可以进行独立配置,但所有逻辑部分可通用,同时在用户搭建某一端页面时我们会给出一套另一端默认的样式配置,如果用户不满意可以再次进行修改。
该方案最终完美的解决了存在绝对定位的组件,在两端如何进行适配,可做到较好的设计稿还原,同时交互层面可使一套搭建产物通用部分在两端适配,提高搭建效率。
这里再提一下我们如何去实现搭建产物的响应式布局,在使用低代码平台搭建的过程中我们使用的单位一般都是 px,这样要实现最终搭建产物的自适应就比较困难,这里直接说最终方案。
实现搭建产物的响应式我们的方案是基于 rem,在页面渲染时将 json tree 中所有 px 的单位统一转换为 rem的单位,同时在搭建过程中,如果是需要覆盖的样式,在代码里直接通过 rem 作为单位,在搭建过程中 H5 端画布宽度以 375 作为标准,也就是该宽度下 html 字体大小为 100px,在 PC 端以 900 为标准。相应的页面渲染时也需要根据标准来动态计算根 html 字体的大小。
如何实现表单搭建
低代码平台要实现表单搭建能力,核心是需要实现表单校验、表单联动等复杂操作,这这一块我们的整体思路是基于 formilyjs[7] 来实现,如果对 MVVM 设计模式了解的同学可以很清晰的明白 formilyjs 本质上就是基于 ViewModel
层定义了一套 Schema 协议来渲染和控制组件状态,类似如下:
{
type: "object",
properties: {
"name": {
title: "",
"x-component": "Input",
"x-decorator": "Field",
"x-component-props": {
},
"x-validator": {
required: true,
},
},
"age": {
title: "",
"x-component": "InputNumber",
"x-decorator": "Field",
"x-component-props": {
},
"x-validator": {
required: true,
min: 0,
max: 50,
},
},
},
};
为什么要讲 formilyjs 本质上是做了一层ViewModel
层的事情呢?我想聪明的朋友已经想到了,其实我们低代码实现方案的本质和 formilyjs 十分类似也是实现了一层ViewModel
的事情,只不过 formilyjs 是专注于表单搭建场景需要考虑表单校验、联动等细节,定义一套 json schema 驱动表单控件,而 craftjs 是针对于页面编辑场景,需要考虑组件添加、拖拽等细节,定义一套 json tree 驱动组件,在最底层其实是同一套思想。
那最终我们在低代码平台中要实现表单搭建就很简单的了,我们知道我们通过 craftjs 去修改一个组件时组件会进行重新渲染,那在组件重新渲染的基础之上我们再去更新 formilyjs 的 schema,让 formilyjs 控制的组件进行重新渲染,其实就可以达到表单搭建能力,前提是所有表单组件必须要在 formilyjs 表单的上下文中。
整体架构思想其实就是我们通过 craftjs 的 json schema 控制 formilyjs 的 json schema,再去控制具体的表单控件:
三、国内外主流低代码平台应用场景
讲完了技术我们再从业务的角度来看看国内外现在主流低代码平台的一些业务场景及技术特点。
阿里 Low-Code Engine
阿里低代码引擎[8]是一套开箱即用的低代码平台开发框架,其定义已不仅仅是低代码平台而是一个专注于生产低代码平台的引擎,核心提供了组件开发脚手架、定义了物料接入规范,甚至还有造物平台可对低代码物料进行管理、通过配置化方式生成低代码组件。
同时其提供了出码能力,有很高的使用自由度,可以在产物的基础之上进行二次编辑,但缺点是交互复杂度比较高,上手成本大。
适用场景:公司内部中后台系统,供技术人员使用。
轻流、码匠
轻流[9]是一个可商用的针对企业报销、CRM、设备管理等开发的一个中后台无代码开发平台,交互友好,上手成本低,可通过图形化的方式实现逻辑编排。
码匠[10]也是类似针对中后台领域,底层基于国外开源框架 appsmith[11] 具有优秀的拖拽能力,搭建平台和数据库打通,可以快速实现全栈应用。
shopify、wix
shopify[12] 是世界最受欢迎的低代码建站平台之一,提供丰富的模版,可以快速搭建出属于自己的商店,拥有友好的 SEO、商品管理等能力,是外贸企业建站的首选。
wix[13] 同样是针对建站领域,其和 shopify 不同点是其组件布的局能力更强,拥有更自由的搭建能力,可对设计稿有更好的还原度。
webflow
webflow[14] 是更偏向于官网搭建领域的低代码平台,拥有最强大的布局、定位能力,可以实现官网复杂的 UI 和交互效果,上线时间长老牌低代码平台,估值超1亿美金。
微软 powerapps
微软的低代码[15] 平台在使用上就像是使用使用 word 一样,具有复杂且全面的功能,但对新手很不友好,可能只是微软出于战略角度开发的一款产品。
网易数帆、腾讯微搭
网易数帆[16]和腾讯微搭[17]可以放在一起看,都是做建站方向,虽然功能齐全,但交互十分不友好,没有什么出彩点。
ZION 无代码
ZION[18]无代码是专注于小程序领域的低代码搭建平台,可以实现一个完整小程序应用的搭建,有自己的能力特色和护城河,画布大小可以无效缩放,适用营销、留资、客户运营等场景。
总结
在看了这么多低代码搭建平台后个人的思考主要有如下:
如果是对客的低代码搭建平台核心不在于功能有多复杂、能力有多强大,而是其在垂直领域是否足够深入、交互是否友好,有自己的特点才有存在的意义,这其中比较好的是拥有快速生成任意低代码平台的阿里 lowcode engine、拥有可靠建站能力的 shopify、拥有最强大布局能力的 webflow、拥有完整小程序搭建能力的 ZION。
四、低代码&AIGC
低代码平台和AIGC结合一般有两种方案,一种是通过大模型技术生成符合低代码格式的低代码组件,同时生成相应的样式配置,最终通过低代码搭建平台进行二次编辑,但该方案在目前还是太超前,技术难点高,还没有商业化的案例。
另外一种方案是大模型核心负责页面样式、文案等配置,可提供针对不同领域场景的配色、主题方案,再由相应的桥阶层来转化为低代码的 json tree,最后再由 json tree 来结合相应的低代码组件渲染页面,相当于组件是确定的,但组件的样式等配置由大模型来生成。
参考
craftjs: https://craft.js.org/docs/overview
[2]deom 教程: https://craft.js.org/docs/guides/basic-tutorial
[3]re-resizable: https://www.npmjs.com/package/re-resizable
[4]这里: https://github.com/prevwong/craft.js/blob/develop/packages/core/src/render/Frame.tsx
[5]这里: https://github.com/prevwong/craft.js/blob/develop/packages/utils/src/useMethods.ts
[6]Shopify: https://www.shopify.com/zh/free-trial/3-steps?term=shopify&adid=581435053886&campaignid=16161337917&branded_enterprise=1&BOID=brand&gclid=EAIaIQobChMIyIv7nq___wIVUQx9Ch3R2AXrEAAYASAAEgIo3fD_BwE&cmadid=516586854;cmadvertiserid=10730501;cmcampaignid=26990768;cmplacementid=324494812;cmcreativeid=163722649;cmsiteid=5500011https://www.shopify.com/zh/free-trial/3-steps?term=shopify&adid=581435053886&campaignid=16161337917&branded_enterprise=1&BOID=brand&gclid=EAIaIQobChMIyIv7nq___wIVUQx9Ch3R2AXrEAAYASAAEgIo3fD_BwE&cmadid=516586854;cmadvertiserid=10730501;cmcampaignid=26990768;cmplacementid=324494812;cmcreativeid=163722649;cmsiteid=5500011
[7]formilyjs: https://formilyjs.org/zh-CN
[8]阿里低代码引擎: https://lowcode-engine.cn/index
[9]轻流: https://qingflow.com/index/home
[10]码匠: https://majiang.co/
[11]appsmith: https://www.appsmith.com/
[12]shopify: https://www.shopify.com/free-trial?term=shopify&adid=583895477024&campaignid=15436644439&branded_enterprise=1&BOID=brand&gclid=EAIaIQobChMInvGw1tmAgAMVsQytBh0MTwX9EAAYASAAEgKBS_D_BwE&cmadid=516586854;cmadvertiserid=10730501;cmcampaignid=26990768;cmplacementid=324494812;cmcreativeid=163722649;cmsiteid=5500011
[13]wix: https://manage.wix.com/account/sites?referralAdditionalInfo=Route
[14]webflow: https://webflow.com/?utm_source=google&utm_medium=search&utm_campaign=general-paid-branded&utm_term=keyword-targeting&utm_content=branded-ads-core&utm_source=google&utm_medium=search&utm_campaign=SS-GoogleSearch-Brand-US&utm_term=aud-936979375361:kwd-11668981_webflow_e_615901391963__&gclid=EAIaIQobChMInJft89qAgAMVzAKtBh0avgHQEAAYASAAEgLwzfD_BwE
[15]微软的低代码: https://powerapps.microsoft.com/zh-cn/
[16]网易数帆: https://sf.163.com/
[17]腾讯微搭: https://cloud.tencent.com/product/weda
[18]ZION: https://www.functorz.com/