关于低代码平台的思考

最近两年前端低代码平台的流行趋势越来越明显,本文是我关于低代码平台的实现原理、应用场景等方面的思考。

<!--more-->

参考

1. 什么是低代码

我过去参加了Web开发、App开发和游戏开发。其中游戏开发使用了引擎提供的IDE、动画编辑器、行为树等的工具,除此之外,基本上所有项目都是纯手写代码。

作为一个程序员,我的工作产出就是代码。而在开发中,不可避免会遇见很多重复的页面。

1.1. 中后台页面

举个例子,比如管理后台的数据列表页面,包含一些类似的页面结构和功能

  • 顶部搜索表单
  • 新增按钮
  • 数据表格
  • 数据操作列,如编辑、查看详情、删除等按钮
  • 底部分页组件

尽管每个数据页面的内容不尽相同,我们却需要编写相似的代码

  • 接入CRUD接口
  • 新增、编辑等弹窗表单
  • 分页切换、刷新数据

一种改善的方案是将这些操作抽象成一个组件,暂时取名叫做curdTemplate

这个组件需要接收getListpostputdelete等资源接口,以及一些子节点插槽如搜索表单、数据列、编辑表单,然后再内部封装各个接口的调用。

没错,我在后台的项目里面就是这么干的,起初原本只是想少写一点分页查询的逻辑,随着更多数据页面的使用,这个组件的功能变得越来越复杂,比如

  • 提供一个immediate表示是否首次渲染就调用getList
  • 搜索表单期望拿到重置分页参数的功能
  • 根据结果动态展示不同的数据列

为了兼容多个数据页面,不得不频繁地扩展这个组件的功能。

除了封装组件,还可以使用Hooks、composition Api等功能更细致的拆分每个功能,然后组合复用。

后来了解到amis这个框架,它采用了声明式数据的方法来描述一个页面,比如要实现下面这个数据列表页面

只需要编写下面这样的JSON

{
    title: "浏览器内核对 CSS 的支持情况",
    remark: "嘿,不保证数据准确性",
    type: "page",
    body: {
        type: "crud",
        draggable: true,
        syncLocation: false,
        api: "https://3xsw4ap8wah59.cfc-execute.bj.baidubce.com/api/amis-mock/sample",
        keepItemSelectionOnPageChange: true,
        autoGenerateFilter: true,
        footerToolbar: ["statistics", "switch-per-page", "pagination"],
        columns: [
            {
                name: "id",
                label: "ID",
                width: 20,
                sortable: true,
                type: "text",
                searchable: {
                    type: "input-text",
                    name: "id",
                    label: "主键",
                    placeholder: "输入id",
                },
            },
            {
                name: "browser",
                label: "Browser",
                searchable: {
                    type: "select",
                    name: "browser",
                    label: "浏览器",
                    placeholder: "选择浏览器",
                    options: [
                        {
                            label: "Internet Explorer ",
                            value: "ie",
                        },
                        {
                            label: "AOL browser",
                            value: "aol",
                        },
                        {
                            label: "Firefox",
                            value: "firefox",
                        },
                    ],
                },
            },
            {
                name: "platform",
                label: "平台",
                popOver: {
                    trigger: "hover",
                    body: {
                        type: "tpl",
                        tpl: "就是为了演示有个叫 popOver 的功能",
                    },
                },
                sortable: true,
                type: "text",
            },
            {
                name: "grade",
                label: "CSS 等级",
                type: "select",
                options: ["A", "B", "C", "D", "X"],
            },
            {
                type: "operation",
                label: "操作",
                width: 100,
                buttons: [
                    {
                        type: "button",
                        actionType: "ajax",
                        label: "删除",
                        confirmText: "您确认要删除?",
                        api: "delete:https://3xsw4ap8wah59.cfc-execute.bj.baidubce.com/api/amis-mock/sample/$id",
                    },
                ],
            },
        ],
    },
};

就这样,一个CURD页面只需要声明配置,不需要再额外编写代码就可以实现了。开发者不需要了解底层用的到底是React还是Vue。

除了CURD页面,中后台另外一个重复性较高的页面就是表单页面,需要组合大量的表单组件来编写页面,社区提供了一些诸如”表单生成器“之类的工具,也是采用类似的声明式数据来渲染表单,这里不再展开。

1.2. 用户端页面

在用户端,出现重复页面频率较高的场景就是活动页面,在日常运营或活动期间,往往需要开发大量"静态"页面。

这些页面往往只包含图文素材,一些动画效果及少量的交互逻辑,其主要目的是用于展示,业务逻辑和交互逻辑较弱,页面的生命周期较短,因此这些页面可以使用一些可视化的工具拖拽生成。

目前业界大部分页面可视化工具,都用为了解决这种场景的。

  • 微信公众号文章编辑器功能有限,催生了秀米等第三方"所见即所得"的富文本编辑器,可以使用UEditor等开源库,面向的用户主要是运营
  • 易企秀、婚礼纪等带动画效果的多屏H5页面制作工具,主要是由平台提供丰富的模板框架,然后提供一些可以修改图片和文字的占位控件,面向的用户主要是无开发能力的平台用户

不过Dreamweaver的主要用户是网页开发者(当时可能还没有前端这个概念),而这些页面可视化工具主要目的是解决非前端开发者制作页面效率的问题。

可视化编辑器最终产物实际上也是跟amis类似的描述数据,最后在运行时渲染成相关的页面。

找到了一些可视化编辑的工具

  • h5-dooring
  • Outsystems
  • Mendix
  • iVX | 跨平台lowcode解决方案

1.3. 小结

从上面两个场景可以看出,低代码是指少用代码Low-Code,甚至不用代码No-Code,仅通过拖拽模块的方式实现应用开发。

与传动代码IDE不同的是,低代码平台需要提供一个更高度的、偏向业务的IDE,开发者并不需要用传统的手写代码方式进行编程,而是可以通过图形化拖拽(可视化编辑器)、参数配置(amis等)等更高效的方式完成开发工作。

2. 低代码实现原理

前面提到,可视化编辑器的最终产物也是描述数据,因此我们先从描述数据入手,先考虑如何设计相关的字段和结构。

2.1. 页面描述数据

从一段HTML开始

<h1>hello title</h1>
<table>
    <th>
        <td>id</td>
        <td>姓名</td>
    </th>
    <tr>
        <td>1</td>
        <td>aaa</td>
    </tr>
    <tr>
        <td>2</td>
        <td>bbb</td>
    </tr>
</table>

上面的HTML标签描述了一个标题和一个表格,换成JSON描述

[
    {
        type: "h1",
        props: {
            text: "hello title",
        },
    },
    {
        type: "table",
        props: {
            columns: [
                { label: "id", prop: "id" },
                { label: "姓名", prop: "name" },
            ],
            data: [
                { id: 1, name: "aaa" },
                { id: 2, name: "bbb" },
            ],
        },
    },
];

如何把这段JSON反序列化,渲染成HTML呢?编写一个接收这种配置的组件

const PageTemplate = (configList) => {
    return (
        <>
            {configList.forEach((config) => {
                const { type: Comp, props } = config;
                return <Comp {...props} />;
            })}
        </>
    );
};

只要实现了H1Table两个组件,就可以将页面页面完整的渲染出来了

const H1 = ({ text }) => {
    return <h1>{text}</h1>;
};
const Table = ({ columns, data }) => {
    return (
        <table>
            <th>
                {columns.map((column) => {
                    return <td>{column.label}</td>;
                })}
            </th>
            {data.map((row) => {
                return (
                    <tr>
                        {columns.map((column) => {
                            return <td>{row[column.prop]}</td>;
                        })}
                    </tr>
                );
            })}
            <tr></tr>
        </table>
    );
};

这就是描述性数据的基本原理,可以看见,描述数据可以生效的前提是:

在运行环境下实现了声明的对应组件

2.2. 可视化编辑器

可视化编辑器最主要的功能是无需让用户手写JSON代码,而是通过拖拽等形式生成最后的描述性文件。

包含最基础的三个部分

  • 组件列表区
  • 预览区
  • 单个选中组件的配置区

至于组件布局,目前有两种主流的模式

  • 流式布局,基于文档流来排列组件位置,实现和操作都比较简单
  • 绝对定位,在画布上通过position:absolute控制组件的位置,编辑自由度更高

下面是我在开发vue-page-builder时编写 的示例demo,采用的是流式布局,从左侧组件列表拖拽数据到中间预览区,然后选中某个组件,就可以开始配置了。

不能绕开的一个问题是:预览区如何与实际环境保持一致?

  • 一种方案是编辑器与实际环境使用同一套组件库,分别渲染并实现
  • 另外一种方案是通过iframe实现,与实际环境基本上一致

表面上看起来第一种方案非常直观,也比较容易理解,但存在比较明显的缺陷:对于同一套描述数据而言,如果存在多端预览,我们需要在编辑器保存多套预览组件;此外当新增组件时,还需要先更新组件库,再更新编辑器依赖,最后才能在编辑器上使用。

而通过iframe就可以完美解决这个问题,编辑器完全不用关心描述数据对应的到底是什么组件,只需要关心数据就行了,但由于预览区实际上还承载了拖拽交互的功能,因此还需要一些额外的工作来Hack实现跨iframe拖拽。

一种思路是通过一个透明的蒙层,以及已存在的各个组件的高度,模拟iframe中组件的排列,然后再当前页面实现拖拽排序,最后将新的组件列表通知iframe并更新预览。

这里不再详细展开,后面可以补全下demo代码

2.3. 动态数据

参考

如果我们期望能够配置一些动态的数据,比如某个div里面,期望展示用户昵称

<div>Hi, <%= userName %> </div>

按照我们设计的结构,应该是

{
    type: 'div',
    props: {
        text: 'Hi, ${username}'
    }
}

其中${username}处就是需要动态展示的数据,我们来考虑如何获取并渲染这个数据。

如果是我们通过代码编写的页面,这个数据基本上是从接口读取到的,然后保存到变量中

async fetchUserInfo(){
    const {data:{username}} = await getUserInfo()
  this.text = `Hi, ${usernmae}`
}

因此对于低代码而言,在拿到一份描述数据之后,渲染组件列表之前,还必须初始化这份描述文件所需的动态数据。

const globalData = await fetchGlobalData()
const config = paddingData(configList)
render(config)

对于fetchGlobalData,可以使用HTTP接口,或者借助云函数、serverLess等工具,将一个页面所需的动态数据都放在云函数里面处理并返回,这样页面上只需要消费数据就行了。

对于paddingData,实际上是需要找到描述数据中使用动态数据占位符的地方,最简单的是使用eval方法

下面这段配置文件,只需要执行上下文存在username变量,在执行完毕后,configList里面使用模板字符串的地方就会被自动替换

const { username } = globalData
const configList = [
    {
        type: 'div',
        props: {
            text: `Hi, ${username}`
        }
    },
]
// 得到填充数据后的configList

使用模板字符串还有一个好处,即可以在${}里面编写js代码,在后面"逻辑判断"的小节会进一步讲解。这种方案的缺点在于描述数据就不再是纯粹的JSON文件,而是一段JS代码了。

最后的render实际上就是上面根据configList渲染组件列表的逻辑,这里不再赘述。

2.4. 逻辑判断

除了动态数据之外,另外一个需要考虑的就是应用中的逻辑判断

结构化代码包含顺序、分支和循环,在模板中就类似于

  • 顺序
  • 分支,判断是否展示
  • 循环,遍历展示多个数据

可视化编辑器的表达能力远不及图灵完备的通用编程语言,参数配置也是如此。

因此,当需要在页面中进行一些判断时,很难通过纯粹的数据声明实现,比如

  • 表单项的联动选择
  • 组件根据某个数据决定是否显示、禁用等状态切换

如果我们决定组件实现组件之间的联动,不可避免需要实现组件之间的通信

  • 组件在可以注册它关心的数据
  • 组件可以在数据变化时改变自己

比如下面描述的这个按钮,在username值为admin时禁用

{
    type: "button",
    props: {
        text: `Hi, ${username}`,
    },
    expression: {
        disabledOn: `${username === "admin"}`,
    },
}

它就必须监听username的变化,并在其值为admin的时候,将自己的状态修改为disabled,这更像是Vue中的watch配置

watch: {
  username: (newVal) => {
      this.disabledOn = newVal === "admin";
  },
},

但描述数据中如果无法序列化函数等类型,那这里也是设计结构需要考虑的。

2.5. 事件注册

页面与用户之间的交互通过事件触发,因此低代码必须提供一些基础的事件注册功能。

这个功能也可以参考组件列表,在运行时环境下预制各种事件处理函数,

const CLICK_TYPE = {
    TO_ANCHOR: 1,
    TO_PAGE: 2,
    SHOW_DIALOG: 3,
    SHARE: 4,
};

// 实现各种事件的处理函数
const clickHandler = {
    [CLICK_TYPE.TO_ANCHOR](anchor) {},
    [CLICK_TYPE.TO_PAGE]: (url) => {},
    [CLICK_TYPE.SHOW_DIALOG](dialogConfig) {},
    [CLICK_TYPE.SHARE](shareConfig) {},
};

在每个组件的配置项中添加实现事件相关的配置

{
    type: 'button',
    props: {
        text: `Hi, ${username}`
    },
    on:[
        {
            type: CLICK_TYPE.SHARE,
            params: JSON.stringify({imgUrl:'',title:'分享',desc:'邀请你参加活动'})
        }
    ]
}

在渲染组件的时候,获取配置项的on字段,然后根据type依次注册事件处理函数。

这种做法的缺点也很明显,无法灵活地自定义事件处理方法。

3. 组件库限制

从上面低代码的实现思路可以看出,低代码严重依赖于一套运行时组件库。

由于低代码实际上只是一堆声明描述数据,具体的页面完全依赖于运行时的组件,遇见当前组件库无法满足的功能时,需要组件先行实现,然后发版,更新依赖,最后才能进入编辑器配置。

换句话说,运行时组件的功能和种类,限制了页面的开发。

3.1. 自定义组件

既然提供了一个编辑页面的低代码编辑器,为何不再提供一个编写自定义组件的编辑器呢?

根据这个设想,编辑器需要提供一些最基础的组件,包括纯粹的HTML标签,由这些组件组合成新的自定义组件,然后直接发布到组件库。页面编辑器可以直接从自定义组件库拖拽编辑组件,就跟使用运行时的组件库一样。

自定义组件除了标签模板之外,还需要样式、逻辑等编辑功能,理论上才能满足业务需求。

3.2. 另外一种思路:动态渲染异步组件

我之前还设想过一种动态渲染异步组件的方案,其大致逻辑是:

页面框架与页面组件分离,组件通过js文件的形式单独开发和保存,由于是js文件,就可以由开发者自己编写逻辑;在页面访问时,由框架负责动态加载组件并渲染组件。

单个组件文件类似于下面的结构,不依赖任何全局模块,而是通过props的形式在运行时由框架传入

export default ({store,router,global})=>{
  // 一些自定义逻辑
    return (<div>custom component</div>)
}

需要解决的问题

  • 异步组件的实现,单独使用组件仓库来承载,systemJS加载模块
  • 加载异步组件,可以使用React.lazy + React.Suspense实现
  • 实现一个parser,解析内容和异步组件,处理渲染逻辑
  • 组件与主应用之间的通信

相比声明数据,编写代码组件就要灵活很多。与在项目中编写的代码不一样,这个文件只关心当前这个页面的逻辑,不会干扰项目代码,但缺点也很明显:不容易debug、版本追踪难以管理,不是纯粹的低代码了。

当然,这只是我某天突然的闪过的一个念头...

4. 小结

关于低代码,目前有两种比较对立的看法,一种认为这是未来的大势所趋,解放生产力,人人都是开发者;另一种认为低代码只是伪需求场景,只能用来开发逻辑简单、偏展示的基础页面,产生的也是无法维护的垃圾代码。

本文并没有像其他文章里面给出低代码的完整概念,而是从我自己的业务开发经验出发,整理了我遇见的可以使用低代码的场景,以及实现低代码需要解决的设计问题和大致思路。

我认为低代码在某些场景下是可以用来解放生产力的,甚至我们可以借鉴其中一部分思路来封装声明式的内部组件,比如表单配置器、curd模板等;

但低代码否值得投入大量开发精力,为非专业开发者甚至非开发者编写一套充满各种复杂配置项的schema编辑器,还是需要斟酌一下。毕竟术业有专攻,学习编辑器的使用、提需求扩展功能,都是有成本的嘛