一个基于远程模块的低代码平台

在之前关于低代码平台的思考这篇文章中,我提出了一种使用”动态渲染异步组件“来规避组件化低代码平台扩展性较差问题的思路,基于这个思路,又研究了在线预览Vue组件使用vite加载远程模块等问题的实现,最终实现了一个支持加载远程组件的低代码平台,非常有意思,本文主要记录相关的思路和实现原理。

<!--more-->

整个项目已发布到github上,同时提供了Docker compose开发环境。

1. 本地组件的一些问题

在基于组件的低代码平台中,首先需要编辑器提供相关的组件,然后操作者就可以通过拖拽等形式来搭建一个页面

这种方案是目前比较流程的,但在使用过程中也遇见了一些问题

扩展组件

当现有组件无法满足需求时,就需要开发人员去扩展组件库,这个扩展的过程大概分为了开发->测试->打包上线->用户更新编辑器等几个步骤,流程比较长。

参数配置的局限性

基于组件的另外一个问题是某些定制化的需求,比如特定的数据来源、用户判断等,都需要将相关逻辑封装在组件里面,然后暴露出某些配置项,这导致组件的扩展性也非常差。

比如某个按钮组件的点击逻辑,走配置的话,只能预先将支持的点击策略封装成函数,然后映射成可以配置的枚举值,

const clickHanler = {
    1: openPage,
    2: showMessage
    // ...
}

当clickHanler无法满足需求时,也需要深入组件去迭代。

有些编辑器提供了传入js代码,然后eval运行来扩展配置项,这就要求使用者需要具备代码能力,或者将配置交给开发人员去实现了。

动态加载的性能问题

出于性能考虑,在生成的页面上,不会编辑器的所有组件都进行加载,而是采用()=>import(xxx)动态加载组件的形式。

动态加载的本质是通过JSONP的方式加载远程模块文件,对于某些复杂页面,可能会配置多个组件,比如20个,这导致需要发送20个js请求出去,可能会被浏览器同域名请求数量限制等影响页面

也许可以通过SSR解决这个问题。

2. 编辑器

使用vite加载远程模块这篇文章中我提到了远程组件的使用场景,其主要作用是将编译的的步骤延迟到用户访问页面之前再进行

  • 远程组件,可以保持与本地编写代码基本一致的扩展性
  • 延迟编译,可以减少了开发->打包->发布的时间

当然,编写远程组件的缺点也很明显,最大的问题是无法复原本地开发环境,后面会讨论一下。

基于远程模块的页面编辑器这个思路,我实现了一个vue-page-builder,下面演示一下大概的操作,同时介绍相关的实现原理。

2.1. 流程演示

首先创建一个组件

然后进去编辑界面,左侧是组件源码,右侧是组件配置源码,可以开始编写代码了。

目前只实现了基本的代码编辑器,理想情况下是希望能达到与本地一样的开发体验。

组件代码编写并保存之后,就可以在编辑器的组件列表看见对应的组件了

也可以在控件配置区域传入相关的配置

看起来就跟这个组件在本地开发的一样,但这所有的过程都是在线完成的,也就是说,我们只要新建了一个组件,在编辑器那边就可以直接看见对应的组件,而无需再经过提交代码->打包->发布的流程。

基于远程组件的设定,开发人员就可以任意扩展远程组件列表,远程组件也是可以依赖远程组件的!

所有数据都是保存在一个后台服务器上面的,我们称之为【data server

2.2. 预览原理

整个流程是基于vite-plugin-remote-module实现的,该插件提供了一个虚拟模块loadRemoteComponent用于加载远程模块

借助这个接口,我们可以实现一个RemoteWidget组件,用来加载动态的远程模块

<script lang="ts">
// @ts-ignore
import {loadRemoteComponent} from '@vite-plugin-remote-module';

import {defineComponent, h, onMounted, shallowRef} from "vue";

export default defineComponent({
  name: "remoteWidget",
  props: {
    url: {
      type: String,
      default: ''
    },
    passedProps: {
      type: Object,
      default: () => {
        return {}
      },
    },
  },
  setup(props, context) {
    const compRef = shallowRef(null)

    onMounted(() => {
      if (!props.url) return
      loadRemoteComponent(props.url).then((comp: object) => {
        compRef.value = comp
      })
    })

    return () => {
      if (!compRef.value) return h('div', {class: 'remote-loading'}, ['远程模块加载中...'])
      return h(compRef.value, props.passedProps, context.slots)
    }
  }
})
</script>

<style scoped lang="scss">
.remote-loading {
  height: 100px;
  line-height: 100px;
  text-align: center;
}

</style>

RemoteWidget组件有两个props

  • url远程组件的地址
  • passedProps透传给远程组件的props

vite-plugin-remote-module的本质是在viteserver.middlewares中添加了一个中间件,当请求到未被下载的远程模块时,会先将远程模块下载到本地,然后再返回本地模块内容。

基于这个限制,整个编辑器实际上是以vite dev启动的一个开发环境服务器,称之为【editor server】,这样才能实现可以随时通过vite server去加载新增的远程模块。

由于编辑器是内部系统,直接部署vite server到线上,直接使用理论上是可以的。可能需要考虑部署在线上多人访问编辑器时的性能问题。

但对于编辑器生成的页面,肯定不能再启动vite server来提供预览的功能,而是要提供生产环境的、可以被线上访问的编译后的资源。

3. 延迟编译

编辑器生成的页面,得到的实际上是一堆序列化的JSON内容,用于描述当前页面的各种配置

对于用户端而言,需要通过id拿到当前页面的配置,然后渲染出对应的页面。

与编辑器预览区不同的是,这个页面是在生产环境上被用户访问的,因此我们需要在这里实现整个流程中”延迟编译“的部分

假定我们通过页面编辑器,得到的页面链接是http://localhost:9988/template?id=2,在用户访问这个页面后,需要

  • 根据id找到页面配置
  • 读取配置内容,加载远程模块,编译,得到最终的html、css、js代码,返回页面,通过缓存编译结果
  • 下次访问时,如果缓存未失效,则直接返回返回结果

3.1. 编译

所以肯定需要一个新的server来实现这些功能,我们称之为【compile server

其代码类似于

router.get("/template", async (ctx: any) => {
  const id = ctx.request.query.id

  let html = readCache(id)
  if (!html) {
    // 写入内容,然后返回html
    const sfc = json2sfc(content)
    html = await build(id, sfc)

    writeCache(id, html, new Date().toString())
  }
  ctx.type = "html";
  ctx.body = html
});

通过json2sfc将配置内容转换成SFC文件,然后再使用vite build打包,就可以得到静态资源了。

这里需要注意的是,rollup动态import是基于blob全量加载本地模块,因此在本地打包时,如果远程模块未被下载,就会报错。要想打生产包,我们需要提前将RemoteWidget里面动态加载的组件下载到本地。

由于我们知道页面的配置,也就知道了当前页面依赖的远程组件,我们可以将其先解析出来,然后作为静态依赖加入,这样就可以让vite-plugin-remote-module提前去下载各种依赖,最后再打包了

export default function json2sfc(vnode: string): string {
  const data: IRemoteWidget = JSON.parse(vnode)
  const remoteWidgetList = findRemoteWidgetList(data.children.filter(row => row.type === 'RemoteWidget'))

  // 提前加载需要的动态模块
  let importStr = ''
  let widgetMapStr = '{'
  remoteWidgetList.forEach((url: string, index: number) => {
    const name = `RemoteWidget${index}`
    importStr += `import ${name} from '@remote/${url}'\n`
    widgetMapStr += `'${url}':${name},`
  })
  widgetMapStr += '}'

  return `
<script>
import {h, defineComponent} from 'vue'
import AbstractContainer from '@/abstractContainer.vue'

${importStr}

const remoteWidgetMap = ${widgetMapStr}

export default defineComponent({
  name: "App",
  render(){
     const widget = ${vnode}
     return h(AbstractContainer,{widget,remoteWidgetMap})
  }
});
<\/script>

<style scoped lang="scss">

<\/style>`
}

其中

  • remoteWidgetMap的作用是将远程组件映射成已经被加载的本地组件,这样就不用再使用RemoteWidget来动态加载远程模块,也就避免了动态加载导致发送多个js资源请求的情况了。
  • importStr静态加载远程模块,

在build方法中,通过buildVite调用vite build完成打包

import {build as buildVite} from 'vite'

export async function build(id: string, content: string) {
  const folder = `/temp/${id}`

  const entry = `${folder}/index.html`
  const app = `${folder}/App.vue`

  const buildTargetFile = resolve(entry)
  process.env.BUILD_TARGET_FILE = buildTargetFile

  await fs.ensureDir(resolve(folder))

  const entryHtml = getEntryHTMLTemplate(app)

  await fs.writeFile(buildTargetFile, entryHtml)
  await fs.writeFile(resolve(app), content)

  // todo SSR编译
  await buildVite(getInlineConfig())
  // todo 把编译后的结果上传到cdn
  const html = await fs.readFile(resolve(`/dist/${entry}`))
  return html.toString()
}

这要求compile server需要提供所有远程组件都支持的开发环境,比如SCSSTypeScript等功能。

最终就可以得到一个完整的html和bundle js资源,生产环境的问题也就解决了!

3.2. 缓存编译结果

vite 默认编译包含数个远程组件的页面,大概需要花费数秒钟的时间,在响应返回前用户看到的是空白的页面。因此缓存就显得十分重要。

常规的做法是在编辑保存页面之后,就触发一次编译,然后缓存编译接口,将编译时机从用户首次访问提前到保存页面之后,这样就可以避免首个用户需要等待的情况了。

缓存是一把双刃剑,我们还需要保证组件和页面的编辑能得到最新的结果,因此需要一些策略

  • 当页面编辑后,需要缓存失效
  • 当页面依赖的远程组件改动后,需要缓存失效

dateList配置文件是从data server查到的相关文件的updatedAt,用来编辑

function readCache(id: string, dateList:string[]):string {
  const cache = cacheMap[id]
  if (!cache) return ''

  const {content, createdAt} = cache
  const isUpdated = dateList.find(date=>+new Date(createdAt) < +new Date(date))

  return isUpdated ? '' : content
}

4. 小结

在上面的整个流程中,我们提到了三个服务器

  • data server,一个纯后端服务器,与数据库交互,负责提供数据操作和持久化相关的接口,也是远程组件和页面配置保存的地方
  • editor server,编辑预览服务器,其本质是一个vite devServer,负责编辑器预览区可以正常动态加载远程组件
  • compile server,编译服务器,负责编辑器输出的最终可以访问的页面

借助vite-plugin-remote-module,可以在预览区灵活的加载远程模块。

借助预声明,可以在vite build前替换动态加载的远程模块,相当于打包了一个本地项目。

目前整个项目存在的最大问题是:在线编辑组件的开发体验,无法与本地开发相提并论。这也是接下来需要解决的问题,也许可以参考Gitpod一样,通过remote ssh等方法在本地同步浏览远程代码,开发人员只需要最终确认保存一下就行了。

这也符合了我对低代码平台最初的设想:对没有前端能力的人来说,这个编辑器可以使用丰富的组件库0代码配置页面;在用户前端能力增强后,可以创建和维护组件库,依然非常方便。这一切都是基于vite,vite确实比较好用!

整个项目已发布到github上,同时提供了Docker compose开发环境,如有问题,欢迎指正。