海拍客 低代码搭建平台-“梵高”的设计与实践

2022年09月13日 1,087次浏览

芦杰(芦酱)

1 梵高简介

梵高是海拍客自研的低代码搭建平台,通过可视化拖拽和表单配置的方式,可以快速搭建出页面,传统模式下需要 3天 才可以完成开发的应用,用梵高 2小时 就能完成
image.png

2 背景

海拍客内部有很多后台系统,包括配置,OPS,管理,监控等等。现有的前端资源不足以覆盖完所有的需求,经常出现串行排队的现象
哪怕人员再高效,熟练,哪怕需求再简单,哪怕页面风格高度统一,但是沟通,开发,联调,测试,发布,这一大串流程走下来,还是会有些心有余而力不足
因此,我们迫切需要创造一个强大高效的平台,用来规模化,标准化,高效化地搞定这些事情,提高研发产能
低代码搭建平台“梵高”应运而生

2.1 最初的尝试

最开始尝试时,我把重心放在统一UI和交互上,基于统一的UI和交互,封装了很多业务的原子组件,再基于组件封装特定的业务区块,如下图就是一个“详情”区块
image.png
然后,通过一个可视化的平台,将区块全部展示出来,用户可以自由选择区块拼凑成一个页面,如下图
image.png
一个页面搭建完成后,点击确认,会生成一个页面的完整模板 js 代码到本地,后面的步骤跟前端开发一样,如下图,就是使用了搜索区块和列表区块生成的页面模板
image.png
有了低代码平台以后,我们的开发步骤如下,黑色部分被平台消化掉了,但是红色部分还需要人工介入:
image.png
因为我们的区块代码都高度封装过,理想情况下,只需要做些配置,略微写点 js 代码,就可以完成一个简单的页面的开发。哪怕是后端,只需要简单学习下前端的技术栈。也能快速上手
然而现实很骨感,在真正使用上时,它的提效是非常有限的。它没有做到全链路覆盖,后续的开发,联调,测试,发布,对于非前端人员,就算只需要写一点点 js 代码,上手成本还是非常高的,哪怕对于一个前端而言,它的提效也是非常有限的
业务方不愿意使用,前端也不愿意使用,陷入一个非常尴尬的境地
最终,项目以失败告终

2.2 改进

经过第一次尝试,我总结了失败的原因,发现全链路覆盖,一站式解决是最刚需的,并且要大幅度降低前端技术栈的比重。所有的配置,关联,逻辑处理都做到表单可视化。但是也具备代码开发的出口,在一些非常复杂,灵活度高的场景,可以通过写一些“代码”来做到低代码的支持
image.png
经过又一番折腾,我实现一个新的低代码可视化搭建平台,“梵高”,它具备以下功能:

  • 通过可视化拖拉拽,完成应用的布局和设置,“预览”直接查看当前搭建页面情况,所见即所得
    image.png
  • 通过表单式的配置填写,完成应用内不同模块的逻辑关联,实现数据融合
    image.png
  • 提供多种关联,事件与跳转配置,数据间自由切换,组件间自由交互,使应用轻易获得强大交互能力
    image.png
  • 与现有的发布体系打通,一站式开发,打包,发布,一个平台 all in 所有事情,不需要频繁切换开发环境,增大学习成本
    image.png

3 梵高的实现

下面将详细介绍下梵高的实现和设计思路

3.1 架构设计

梵高的整个架构如下,所有的前端应用都采用 react,服务端使用 node
image.png

3.2 平台端和核心层

3.2.1 配置信息 解析

任何一个配置平台,它最核心的原理都是类似的,拥有一套标准的配置信息(schema),它是低代码的骨架:
image.png

  • 输入端配置,输出一套 schema(对应低代码的平台搭建)
  • 输出端解析 schema,还原相应的内容(对应发布到上线的页面)

对于输入端,输出端,schema 的解析逻辑应该是基本相同的,因此我们可以将对于 schema 的解析封装到一个独立的 core 里去,在运行时动态解析(runtime),这种实现非常平滑,自然
然而,这种纯 runtime 的实现有几个问题

  • 我们的线上页面需要在渲染前解析 schema,为了保证能正常解析,需要提前注入所有的依赖模块,例如所有的组件,这就导致了资源的浪费,且影响首屏渲染时间(我们的搭建平台有几十个组件,如果某个页面只使用了其中的1,2个,但是我们却要把所有的组件提前加载进来,浪费资源)
  • 很多 schema 的配置是在搭建时就已经完全静态的,比如所使用的组件,模块;组件的某些配置,完全可以将这些配置的解析前置化
  • 完全 runtime 的鲁棒性要非常好,防止各种情况导致的页面挂掉,也需要兼容各种情况,这对 runtime 的实现要求,后续的维护成本都是极高的

综上所述,我们将 schema 的解析分为两部分,静态编译 与 运行时解析。对于大部分的静态配置,我们在编译打包的时候就直接转换成代码,而对于动态配置,我们则通过引入的 core 动态解析,如下图
image.png

3.2.2 组件接入与渲染编排

3.2.2.1 组件接入

如果把低代码平台当做一个“工厂”,组件则是所有的“原材料”,搭建的页面则为“商品”,因此,如何处理好组件的接入,关系到整个“工厂”稳定高效的运转
每个组件都有自己需要的属性(props)和事件(event),我们可以用结构化的范式去描述这些属性和事件, 像以下就是一段组件的配置信息,我们把这些描述放到一个配置文件中,伴随着组件源代码

props: {
        title: {
            title: '标题',
            fieldType: FieldType.Input,
            defaultValue: '标题'
        },
        initSubmit: {
            title: '初始化提交',
            fieldType: FieldType.Switch,
            desc: '页面初始化时是否进行提交',
            defaultValue: false
        },
        submitTitle: {
            title: '查询标题',
            fieldType: FieldType.Input,
            defaultValue: '查询'
        },
    },
    event: {
        triggers: [
            {
                name: '查询',
                topic: 'submit',
                desc: '主查询事件',
                params: {
                    params: {
                        type: 'object',
                        desc: '填写表单的键值对,如 {title:1, status:2,pageNo:1},pageNo:1 是固定项,用来将表格重置到第一页'
                    }
                }
            }
        ]
    },
    apis: [
        {
            name: 'form',
            desc: '表单实例,用于获取,操作表单数据,具体参考 https://ant.design/components/form-cn/',
            type: 'attribute'
        },
        {
            name: 'submit',
            desc: '提交事件,可以通过手动调用 submit 来触发提交事件',
            type: 'method'
        }
    ]

我将组件的配置分为4个部分:

props属性如标题,类型等
data数据源配置远程取数,如远程搜索下拉配置数据源
event事件如按钮的点击事件
api组件API在代码中可以通过组件实例获取到的api

当组件被拖拽进编辑区后,平台会解析当前组件的配置文件,生成对应的配置表单
对于不同的配置类型,都会有不同的表单类型相对应,从简单的文本输入框、数字输入框、下拉选择器到复杂的回调函数编辑器,表达式编辑器,组合型编辑器等等
表达式、回调函数本质上就是一种低代码能力的体现,通过代码最大限度地来扩展组件的属性和行为
image.png

3.2.2.2 组件加载

由于组件都是脱离平台单独开发的,在拖拽一个组件后,平台是如何做到动态渲染对应组件的呢?
首先,组件在打包时,我采用的是 umd 的打包方式,这样便会将组件实例挂载在 window 上
在渲染时,通过 React.lazy 封装一个加载组件,使用 script 标签去获取远程资源,并用 Promise 封装,具体的实现如下

 if (window[packageName]) {
        return window[packageName]?.default || null
      }

      return lazy(() => new Promise((resolve, reject) => {
        const script = document.createElement('script')

        script.onload = () => {
          resolve(window[packageName])
        }

        script.onerror = () => {
          reject(new Error('组件加载失败'))
        }

        script.crossOrigin = 'anonymous'
        script.src = cdn
        script.type = 'text/javascript'

        document.body.appendChild(script)
      }))

3.2.2.3 渲染编排

一个组件渲染到编辑区,需要具备以下功能:

  • 响应hover,点击事件,点击后渲染组件配置表单
  • 响应组件拖拽,高亮编辑区
  • 渲染子组件

这些功能只有在平台端才会用到,发布上线后是不需要这些的,因此,这些与平台端高度耦合的能力应该与组件本身相隔离
我的做法是,平台端在渲染每个组件时,会先对每个组件包裹一层高阶组件 Wrapper,在高阶组件 Wrapper 封装上面的能力,然后再将包裹好的组件渲染到编辑区,保证渲染到编辑区的组件都具备上述功能,它们的关系如下:
image.png
通过这样的设计,组件只关心自己的核心逻辑,跟平台相关的都通过包裹组件来做
在页面布局上,我们将布局的控制权交给了各个组件。为实现拖拽布局功能,我封装了一个 Slot 的功能组件,它可以被任意组件引入,渲染出来就是一个拖拽区,可以响应我们在 Slot 上配置的组件类型
如我们在搜索框某个位置引入一个拖拽的插槽位,我们可以在搜索框组件中这样使用:

<div className={style.title}>
                <h1 className={style.h1}>{props.title}</h1>
                <Space className={style.extra}>
         			{/* 插槽位 */}
                    <Slot
                        type={ModuleType.ACTION}
                        belong="extraSlot"
                        fatherId={renderId}
                    />
                    {Object.keys(slot?.extraSlot || {}).map(key => (
                        <Wrapper renderId={key} dynamic key={key} data-key="extraSlot">
                            <Action />
                        </Wrapper>
                    ))}
                </Space>
            </div>

它在页面中渲染的效果如下图:
image.png
通过与上述的包裹组件组合,我们可以自由定义组件的插槽位来响应子组件,并自由设计子组件的布局

3.2.2.4 组件配置状态管理

一个组件渲染到编辑区后,它包含以下状态:

  • 与其他组件的渲染关系,包括父子级,兄弟级
  • 自身的配置状态,如属性配置,数据源配置,事件配置

一个页面需要N个组件,整个状态树是非常庞大的。那么,它们的状态是如何管理的呢?
由于包含层级关系,最直观的是使用树的数据结构,如下图,但是这种结构是非常复杂的,删除,编辑,新增,需要采用深度优先遍历或者广度优先遍历来找到对应的节点,每次遍历的复杂度都为O(n),并往上一层层更新它们的引用,才能触发 React 的渲染(采用了memo来优化性能)
image.png
为了解决上述的问题,每个组件拖到编辑区后,都会有一个唯一的渲染 id。我采用一个 map,用渲染 Id 做 key,映射到每一个在编辑区上的组件配置。使查找节点的时间复杂度从 O(n) 降到了 O(1)。另外,组件渲染时,也通过 map 获得对应的配置渲染。这样子,当组件配置更新时,只需要更新当前组件,即可触发 React 的渲染
而在树的设计上,选用了双向链表的结构,基于双向链表的特性,我们能从任意一个节点,还原整颗组件树,配合 map 快速定位当前组件,都极大地方便了我们的操作
新增时同编辑,只需要通过 map 找到当前组件,在将新组件增加到当前组件下即可,时间复杂度由 O(n) 降到 O(1)
删除相对复杂一点,由于需要从 map 上删除对当前组件及其子组件的引用,因此需要从当前组件开始进行深度优先遍历或者广度优先遍历,时间复杂度最大为 O(n)
新的结构如下图
image.png

3.2.3 逻辑编排

逻辑编排是低代码能力的核心。在梵高中,所有的组件属性和动作都是可以进行动态配置,从而极大地提高了系统的灵活度和业务覆盖
如一个搜索框的标题,props.title,我们可以通过输入框设置一个固定值(静态配置),也可以通过编写 JS 表达式写一段 JS 代码来表示(动态配置)
一个按钮的点击事件可以通过编写一个 JS 函数来实现动作
image.png
image.png
平台编写的代码在运行时会注入到页面的上下文,它可以通过 this 上的运行时 api 获取各个组件的实例和组件放出的方法,也可以访问当前页面的全局状态,以及一些像格式转化,获取 URL 参数的工具函数
那么这些是如何实现的呢?首先,动态配置高度依赖运行时的页面上下文。因此,这部分的解析无论是在平台端还是在最终输出的页面,都是需要解析的,因此需要专门抽离到核心层 core 中去做

  • 当一个页面在创建的时候,根节点调用 core 中的 PageProvider,这个组件会创建帮助我们创建一个全局 store 对象,作为页面的顶层上下文,并通过 React 的 Context 注入到页面中
  • 每个组件都会调用 core 中的 useComponentRunTime 这个运行时 api ,该 api 通过 context 获取 store,并解析所有动态配置的函数,通过 bind 将 store 绑定到函数的上下文上
    image.png
    具体的代码实现如下:
    Store的实现
// core
export default class PageStore {
  /** 页面状态 */
  state: State = {
    urlParams: {},
    loading: {}
  }
  /** 数据集集合 */
  dataSourceMap: {
    [key: string]: RemoteData
  } = {}
  /** 组件实例集合 */
  instanceMap: {
    [key: string]: Instance
  } = {}
  /** 触发组件重新渲染 */
  forceRootUpdate: () => void
  /** 工具函数 */
  utils = utils
  ...
}

PageProvider 的实现

// core
const PageProvider: FC<PageProviderProps> = ({ children, ...options }) => {
  const value = usePageRuntime(options)

  // functions 是异步获取的,等functions 获取后在加载数据
  return (
    <PageContext.Provider value={value}>
      {(value.functions || !options.globalJsStr) ? children : null}
    </PageContext.Provider>
  )
}

export default PageProvider

usePageRunTime 的实现

// core
export default function usePageRuntime(options: Options) {
  const storeRef = useRef<PageStore>()
  const [, forceUpdate] = useState({})

  const { dataSourceConfig, globalJs, globalJsStr, fetchTransformGlobalJs } = options

  // 生成 store
  if (!storeRef.current) {
    // re-render
    const forceReRender = () => {
      forceUpdate({})
    }
    // 初始化页面 store
    storeRef.current = new PageStore(forceReRender)
  }

  // 数据源配置更新
  useMemo(() => {
    storeRef.current.updateConfig(dataSourceConfig)
  }, [options.dataSourceConfig])

  const functions = useGlobalJSController(globalJsStr, globalJs, storeRef.current, fetchTransformGlobalJs)

  return {
    store: storeRef.current,
    state: storeRef.current.state,
    functions
  }
}

3.2.4 远程取数

无论是平台端还是输出之后的页面,都需要具备远程取数能力。因此,取数能力也需要放在 core 中,作为 runtime 上下文的一部分进行封装,并且可以通过 JS 函数自定义取数前操作,取数后操作,以及 loading状态
在需要取数时,我们只需要通过特定的 API 来统一调用即可
取数的配置如下,我们创建了一个 debug 的数据源:
image.png
在创建 store 时,我们会解析配置的远程数据源
并封装成调用函数,调用时,可以通过页面上下文的 datsSource.map.[数据源名称].load 来调用,如下图:
image.png

4 服务端

当一个应用的页面全部搭建完成后,很自然的,我们要进行编译,打包 ,发布

4.1 编译

上文提到,每个页面在搭建完成后,都会有一整个 schema 描述,包括页面信息,组件配置与层级结构,取数信息等等,这些信息分为静态的,动态的
编译阶段,就是编译所有静态的配置
基于 babel,我们可以将静态配置转换为抽象语法树(AST),应用模板代码也转为 AST,通过 AST 这个统一的结构,进行检查,优化,生成相应的工程源码,将运行时的大部分工作前置
具体的流程如下:
image.png
整个编译我分为3个模块,分别是 App,Page,Component,每个模块都包含两步,create(创建) 和 translate(编译):

  • App:负责一个应用
    • create:拉取应用模板,生成模板工程,根据 schema 创建 Page,执行 Page 的 create 和 translate
    • translate:解析 schema,转换为 AST,基于 AST 生成路由文件,修改配置文件,创建
  • Page:负责一个页面
    • create:创建页面目录,拷贝页面模板,根据 schema 创建 Component, 执行 Component 的 create 和 translate
    • translate:解析 schema,转换为 AST,基于 AST 生成页面入口文件,配置文件等
  • Component:
    • create:创建组件目录,拉取组件代码,根据 schema , 如果有子组件,则创建子组件的 Component ,执行子组件的 create 和 translate
    • translate:解析 schema,将静态配置直接注入到组件代码中,删除插槽组件,包裹组件的代码和对应依赖

从 App 开始,通过深度优先遍历,经由 Page,Component,直到最深节点的component 遍历完,才完成整个编译过程,具体的代码如下
编译入口

export default async function buildPage(config: Config): Promise<string> {
	// 生成 App,将 schema 导入
    const app = new App(config);
    await app.create();

    return await app.packApp();
}

App

export default class App {
    private appId: string;

    private appPath: string;
    private templatePagePath: string;


    constructor(public config: Config) {
        this.appId = uuid();
        this.appPath = path.join(process.cwd(), './.temp', this.appId);
        this.templatePagePath = path.join(this.appPath, `./src/container/${TEMPLATE_PAGE_PATH}`);
    }

    // 创建项目
    async create() {
        const { templatePagePath, appPath, config } = this;
        const { pages, useBeta } = config;

        const pageNames = Object.keys(pages);

        // 创建项目工程
        await createNpmPackage(APP_NAME, appPath, undefined, useBeta);

        // 继发编译
        for (const pageName of pageNames) {
            // 生成 Page
            const page = new Page(appPath, templatePagePath, pageName, config);

            await page.createPage();
            await page.translatePage();
        }

        await Promise.all([
            this.translate(), // 开始编译公共部分
            !pageNames.includes(TEMPLATE_PAGE_PATH) && fse.remove(templatePagePath), // 删除模板页面
        ]);
    }

    /**
     * 编译
     */
    public async translate() {
        await Promise.all([
            this.translateEnvConfig(),
            this.translateApp(),
            this.translateRouters(),
        ]);
    }

	...
}

Page

export default class Page {
    private pagePath: string;
    /** 页面配置 */
    private pageConfig: PageConfig;
    /** 组件下载目录 */
    private componentDownloadPath: string;


    constructor(public appPath: string, public templatePagePath: string, public pageName: string, public config: Config) {
        this.pagePath = path.join(this.appPath, `./src/container/${pageName}`);
        this.pageConfig = config.pages[pageName];

        this.componentDownloadPath = path.join(this.appPath, `./src/container/${pageName}/components`);
    }

    /**
     * 创建页面
     */
    public async createPage() {
        await this.createPageDir();
        await this.createComponent();
    }

    /**
     * 创建页面目录
     */
    private async createPageDir() {
        const { pageName, templatePagePath, pagePath } = this;

        // 页面名字不等于 Main,复制模板页面到要打包的页面目录
        if (pageName !== TEMPLATE_PAGE_PATH) {
            await fse.copy(templatePagePath, pagePath);
        }
    }

    /**
     * 编译页面
     */
    public async translatePage() {
        await Promise.all([
            this.translateIndex(),
            this.translateGlobalJs(),
            this.translateDataSourceConfig(),
            this.translateConfig(),
        ]);
    }

    /**
     * 下载组件源码组件
     */
    private async createComponent() {
        const { componentDownloadPath, pageConfig: { renderConfMap, bindVariables }, config: { useBeta },
        } = this;
        const renderConfList = Object.entries(renderConfMap);

        // 过滤出自定义组件列表
        const customizedList = renderConfList.filter(([, { componentType }]) => componentType === 'customize');

        // 对组件去重
        const componentList = [...new Set(customizedList.map(([, { htmlName, cdn }]) => `${htmlName}|${getVersionFromCdn(cdn) || ''}`))];

        // 并行 3个下载
        const downloadList = [];
        let partDownloadList = [];

        for (let i = 0; i < componentList.length; i++) {
            const name = componentList[i];
            const [htmlName, version] = name.split('|');
            const sourcePath = path.join(componentDownloadPath, htmlName);

            partDownloadList.push(() => createNpmPackage(htmlName, sourcePath, version || undefined, useBeta));

            if (partDownloadList.length === 3 || i === componentList.length - 1) {
                downloadList.push(partDownloadList);
                partDownloadList = [];
            }
        }

        for (const methods of downloadList) {
            await Promise.all(methods.map(method => method()));
        }

        // 执行组件的初始化操作
        customizedList.forEach(([renderId, item]) => {
			// 创建组件
            const component = new Component(renderId, renderConfMap, componentDownloadPath, bindVariables);
            component.createComponent();

            item.component = component;
        });

        // 删除 @yt 组件下载目录
        await fse.remove(`${componentDownloadPath}/@yt`);
    }

	...
}

Component

export default class Component {
    public targetPath: string; // 目标目录
    public componentName: string; // 组件名字

    private htmlName: string;
    private indexPath: string;
    private configPath: string;

    constructor(public renderId: string, public renderConfMap: PageConfig['renderConfMap'], public componentDirPath: string, public bindVariables: BindVariable[]) {
        this.htmlName = renderConfMap[renderId].htmlName;

        this.componentName = `${formatPackageName(this.htmlName)}${renderId}`;

        this.targetPath = path.join(componentDirPath, this.componentName);
        this.indexPath = path.join(this.targetPath, './index.tsx');
        this.configPath = path.join(this.targetPath, './config.tsx');
    }

    createComponent() {
        const { htmlName, componentDirPath, targetPath, configPath } = this;

        const sourcePath = path.join(componentDirPath, htmlName);

        fse.ensureDirSync(targetPath);
        fse.copySync(path.join(sourcePath, './src'), targetPath);
        fse.removeSync(configPath);
    }

	...
}

4.2 打包

编译完成的代码,由于我们有获取源代码的需求,因此对于每次生成的源码,都需要存储副本,方便下载
我采用的方案是将编译好的源码进行压缩,然后根据版本上传到 OSS,打包的时候启动一个 Jenkis 任务, 从 OSS 上拉取压缩包,在解析,打包
整个打包步骤如下:
image.png

4.3 发布

发布相对来说是最简单的一环,梵高系统支持页面发布和应用发布,大同小异,都是copy index.html 到一个特定的 OSS目录,并用 nginx 指定到这个目录,后续的回滚,发布只需要更新 index.html即可。已页面发布为例,下面是整个发布的流程图:
image.png

5 总结

目前,梵高在内部已顺利上线,经过了一系列业务需求的挑战,当前能稳定覆盖大多数的后台业务,有了一定的产能,为公司带来了价值。但同样,它还有很长的路要走。
后续,我们需要围绕下面几个点优化该平台

  • 易用性:目前梵高对于用户来说,上手成本还是略高的,有较多的概念需要理解,因此,通过封装更多的模板,区块,优化UI,交互等手段增加易用性是必要的
  • 灵活性:为了覆盖更多的业务场景,我们的平台需要更加的灵活,防止出现进行不下去的情况,目前我们就增加了自定义组件等高阶用法来增加灵活性