一个基于远程模块的低代码平台
在之前关于低代码平台的思考这篇文章中,我提出了一种使用”动态渲染异步组件“来规避组件化低代码平台扩展性较差问题的思路,基于这个思路,又研究了在线预览Vue组件和使用vite加载远程模块等问题的实现,最终实现了一个支持加载远程组件的低代码平台,非常有意思,本文主要记录相关的思路和实现原理。
整个项目已发布到github上,同时提供了Docker compose
开发环境。
本地组件的一些问题
在基于组件的低代码平台中,首先需要编辑器提供相关的组件,然后操作者就可以通过拖拽等形式来搭建一个页面
这种方案是目前比较流程的,但在使用过程中也遇见了一些问题
扩展组件
当现有组件无法满足需求时,就需要开发人员去扩展组件库,这个扩展的过程大概分为了开发->测试->打包上线->用户更新编辑器等几个步骤,流程比较长。
参数配置的局限性
基于组件的另外一个问题是某些定制化的需求,比如特定的数据来源、用户判断等,都需要将相关逻辑封装在组件里面,然后暴露出某些配置项,这导致组件的扩展性也非常差。
比如某个按钮组件的点击逻辑,走配置的话,只能预先将支持的点击策略封装成函数,然后映射成可以配置的枚举值,
const clickHanler = {
1: openPage,
2: showMessage
// ...
}
当clickHanler无法满足需求时,也需要深入组件去迭代。
有些编辑器提供了传入js代码,然后eval运行来扩展配置项,这就要求使用者需要具备代码能力,或者将配置交给开发人员去实现了。
动态加载的性能问题
出于性能考虑,在生成的页面上,不会编辑器的所有组件都进行加载,而是采用()=>import(xxx)
动态加载组件的形式。
动态加载的本质是通过JSONP的方式加载远程模块文件,对于某些复杂页面,可能会配置多个组件,比如20个,这导致需要发送20个js请求出去,可能会被浏览器同域名请求数量限制等影响页面
也许可以通过SSR解决这个问题。
编辑器
在使用vite加载远程模块这篇文章中我提到了远程组件的使用场景,其主要作用是将编译的的步骤延迟到用户访问页面之前再进行
- 远程组件,可以保持与本地编写代码基本一致的扩展性
- 延迟编译,可以减少了开发->打包->发布的时间
当然,编写远程组件的缺点也很明显,最大的问题是无法复原本地开发环境,后面会讨论一下。
基于远程模块的页面编辑器这个思路,我实现了一个vue-page-builder,下面演示一下大概的操作,同时介绍相关的实现原理。
流程演示
首先创建一个组件
然后进去编辑界面,左侧是组件源码,右侧是组件配置源码,可以开始编写代码了。
目前只实现了基本的代码编辑器,理想情况下是希望能达到与本地一样的开发体验。
组件代码编写并保存之后,就可以在编辑器的组件列表看见对应的组件了
也可以在控件配置区域传入相关的配置
看起来就跟这个组件在本地开发的一样,但这所有的过程都是在线完成的,也就是说,我们只要新建了一个组件,在编辑器那边就可以直接看见对应的组件,而无需再经过提交代码->打包->发布的流程。
基于远程组件的设定,开发人员就可以任意扩展远程组件列表,远程组件也是可以依赖远程组件的!
所有数据都是保存在一个后台服务器上面的,我们称之为【data server】
预览原理
整个流程是基于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
的本质是在vite
的server.middlewares
中添加了一个中间件,当请求到未被下载的远程模块时,会先将远程模块下载到本地,然后再返回本地模块内容。
基于这个限制,整个编辑器实际上是以vite dev
启动的一个开发环境服务器,称之为【editor server】,这样才能实现可以随时通过vite server
去加载新增的远程模块。
由于编辑器是内部系统,直接部署vite server到线上,直接使用理论上是可以的。可能需要考虑部署在线上多人访问编辑器时的性能问题。
但对于编辑器生成的页面,肯定不能再启动vite server来提供预览的功能,而是要提供生产环境的、可以被线上访问的编译后的资源。
延迟编译
编辑器生成的页面,得到的实际上是一堆序列化的JSON内容,用于描述当前页面的各种配置
对于用户端而言,需要通过id拿到当前页面的配置,然后渲染出对应的页面。
与编辑器预览区不同的是,这个页面是在生产环境上被用户访问的,因此我们需要在这里实现整个流程中”延迟编译“的部分
假定我们通过页面编辑器,得到的页面链接是http://localhost:9988/template?id=2
,在用户访问这个页面后,需要
- 根据id找到页面配置
- 读取配置内容,加载远程模块,编译,得到最终的html、css、js代码,返回页面,通过缓存编译结果
- 下次访问时,如果缓存未失效,则直接返回返回结果
编译
所以肯定需要一个新的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
需要提供所有远程组件都支持的开发环境,比如SCSS
、TypeScript
等功能。
最终就可以得到一个完整的html和bundle js资源,生产环境的问题也就解决了!
缓存编译结果
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
}
小结
在上面的整个流程中,我们提到了三个服务器
data server
,一个纯后端服务器,与数据库交互,负责提供数据操作和持久化相关的接口,也是远程组件和页面配置保存的地方editor server
,编辑预览服务器,其本质是一个vite devServer
,负责编辑器预览区可以正常动态加载远程组件compile server
,编译服务器,负责编辑器输出的最终可以访问的页面
借助vite-plugin-remote-module
,可以在预览区灵活的加载远程模块。
借助预声明,可以在vite build前替换动态加载的远程模块,相当于打包了一个本地项目。
目前整个项目存在的最大问题是:在线编辑组件的开发体验,无法与本地开发相提并论。这也是接下来需要解决的问题,也许可以参考Gitpod一样,通过remote ssh等方法在本地同步浏览远程代码,开发人员只需要最终确认保存一下就行了。
这也符合了我对低代码平台最初的设想:对没有前端能力的人来说,这个编辑器可以使用丰富的组件库0代码配置页面;在用户前端能力增强后,可以创建和维护组件库,依然非常方便。这一切都是基于vite,vite确实比较好用!
整个项目已发布到github上,同时提供了Docker compose
开发环境,如有问题,欢迎指正。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。