我的低代码框架是如何生成源码的?

8,220 阅读7分钟

前言

最近痴迷于低代码,了解了下lowcode-engine的思想,所以打算手撸下低代码的生成源码功能,起初觉得很难,但是解决问题的思路还是挺简单的。下面是我实现的不同思路,难免出错,但我想表达的仅仅是思路而已。本文将用UIDL和正则两种思路实现。

下面附上我写的链接,有兴趣的可以简单看下,ccj-007/easy-lowcode: easy-lowcode快速学会低代码框架原理 (github.com) 项目的技术栈用了React18 + Vite + codeMirror + Koa2,状态的管理目前只是简单用用useContext

实际效果

动画.gif

方法一. 正则实现

1. 构建项目依赖树

image.png

在正式开始前,你需要理解下项目的依赖树,你可以简单理解为如下

/**
 * 这里分为低代码组件的依赖树、代码主体的依赖树、导入依赖的树,也就意味着你可以抽象成一个配置生产最终的代码,并跨平台
 */
const COMPONENTS_TREE = {
'Button': `<div id={{id}}><button>{{children}}</button></div>`
}   

//这里考虑codeMirror的预览,所以没有tab
const FILE_TREE = {
'root': [{
  'App.js': `
{{importURL}}
const App = (props) => {
  return (
    <>
      {{compContent}}
    </>
  )
}
export default App`
}]
}

const IMPORT_TREE = ["import React from 'react'"]

const REACT_MAP = {
  COMPONENTS_TREE,
  FILE_TREE,
  IMPORT_TREE
}
export default REACT_MAP

在FILE_TREE中,root代表项目的根目录, App.js代表一个项目文件,文件是最小单位,当然你还可以细分到代码层级。这里不考虑这种复杂程度,默认文件是最小单位,意味着每个文件,你需要有不同的属性去控制,比如IMPORT_TREE和COMPONENTS_TREE等,所以其实App.js的key对应的value用一个对象来控制更合适。

const FILE_TREE = {
'root': [{
  'App.js': {
      content:`
        {{importURL}}
        const App = (props) => {
          return (
            <>
              {{compContent}}
            </>
          )
        }
        export default App`,
        componentTree: '...',
        importTree: '...',
        //这里甚至你可以定义权限、组件版本等等。
   }
 ]
}

首先你要明白前端要维护一个不同框架的文件依赖树,对象的key为路径,value为对应的源码,源码通过前端拖拽生成

2. 简单的拖拽

image.png 注意你所有的操作都是处理全局的描述对象,这个对象描述越深入,意味可控性越强。所以拖拽、编排的核心其实就是我页面的每一项组件的顺序。为了统一的规范化,这个描述对象你最好是满足json Schema的协议的对象,通过json来前后端请求和响应。

3. 前端组装树

这个文件依赖树的组装是在前端实现的,意味你要抽离比如以import的文件依赖树,代码主体的文件以依赖树,低代码组件的文件依赖树。这样你就可以通过维护不同的树结构,你可以实现页面的资产管理,低代码组件的管理等。在拖拽的结束后你需要用正则替换不同低代码组件的内容,然后拼接到树上。

image.png

4. 后端解析树生成源码

上面在前端以正则的方式构建依赖树,比较简单粗暴。更优雅的方式是通过前端维护一个映射关系的变量,后端做解析来实现,然后在node后端将前端返回的映射的变量通过handlebars、ejs等模板引擎解析。是不是会更优雅些呢?这也就是第二种实现思路

项目用koa2构建,下面要简单写一个保存json的接口,和返回源码的下载的接口。

/**
 * 前端构建文件依赖树,给后端以生成对应的文件并请求返回
 */
const Koa = require('koa')
const json = require('koa-json')
const onerror = require('koa-onerror')
const bodyparser = require('koa-bodyparser')
var cors = require('koa2-cors');
const index = require('./routes/index.cjs')

const app = new Koa()

app.use(cors({
  origin: 'http://127.0.0.1:5173'
}))
onerror(app)

app.use(bodyparser({
  enableTypes: ['json', 'form', 'text']
}))
app.use(json())
app.use(require('koa-static')(__dirname + '/public'))

/**
 * routes
 */
app.use(index.routes(), index.allowedMethods())

app.on('error', (err, ctx) => {
  console.error('server error', err, ctx)
});

app.listen('7001', () => {
  console.log("7001");
})

这里后端用的koa2,注意下koa-static这个包可以让你直接通过浏览器路径返回public目录下的静态文件,但是这里我们需要二进制传输给前端,前端通过fetch返回的blob对象处理后下载文件。

const router = require('koa-router')()
const fs = require('fs')
const path = require('path')
const prettier = require('prettier')

let codeTree = {}
const dir = path.join(__dirname, '../public/App.js')

/**
 * @description 根据最新的依赖树更新模板文件
 */
router.post('/setCodeTree', async (ctx, next) => {
  const { codeObj } = (ctx.request.body)
  codeTree = codeObj
  const source = codeTree.root[0]['App.js']
  //得到依赖树写入模板
  const code = prettier.format(source, { semi: false, parser: "babel" });
  const writer = fs.createWriteStream(dir)
  writer.write(code)
  ctx.body = { code: 200, data: null, message: 'success' }
})

router.get('/project', async (ctx, next) => {
  ctx.set('Content-Type', 'multipart/form-data')
  ctx.body = fs.createReadStream(dir)
})

module.exports = router

当前端的全局描述对象变化时,就需要请求一次/setCodeTree,存储最新的json, /project返回构建好的代码,当然我这里少了一步,要解析options找到对应的框架的目录,然后递归遍历生成对应的项目文件,并通过node的fs模块把代码写入进去。

5. 前端下载

image.png

前端通过对应框架字段和文件依赖树请求后端,然后在后端找到对应的模板并出码,以流的形式返回给前端下载文件

链路

源码 -> 拆分不同结构树 -> 前端来组装替换 -> 后端将树遍历递归写入到文件中 -> 前端请求下载

方法二: UIDL

个人觉得这种方式是最好的,但是上手成本前期会比较高,通过一定的规范抽象成一个具体的语法树,来跨平台。第一种方法其实组装的过程可以放到后端,同时第一种方法也不是那么优雅,也没有那么灵活,你需要不断的抽离划分,然后替换,其实本质也在做ast的。同时社区也有对应的包来管理这么个描述对象,也就是统一的类似抽象语法树ast来描述我们的前端项目。

DSL

image.png

什么是DSL(领域专用语言), 一般DSL分为内部DSL和外部DSL,打个比方,json是外部DSL,是独立的语言非后期构造出来的,我们基于json生成一种新的内部DSL,比如叫jsonPlus,那么中间必需多了一层解析器,将jsonPlus转为json。像mdx转为markdown,像jsx转为dom。中间都有一个解析器来转换。他的目的就是简化,让不同语言可以互通,但是缺点就是灵活度低。

  • 内部 DSL(从一种宿主语言构建而来)
  • 外部 DSL(从零开始构建的语言,需要实现语法分析器等)

UIDL

我们将通用格式命名为 “用户界面定义语言”(UIDL)。 它由人类可读的 JSON 文档表示,这是许多编程语言原生支持的格式。尽管一开始 UIDL 的作用似乎仅限于描述 UI 元素及其关系,但我们也可以使用它来描述用户交互、流、事件以及基于组件体系结构和动态数据驱动应用程序的更复杂的 UI 模式。

映射

可能有点抽象,简单来说你在react要修改类名用className,vue用的是class,用户只要修改json对象的class字段,如果你运行在react环境下,我就映射成className,在vue的环境下我就映射成class。这也就是跨平台的核心,映射。

teleport-code-generators 代码生成器

github.com/teleporthq/…

//这个json对象描述了一个组件
{
  "name": "My First Component",
  "node": {
    "type": "element",
    "content": {
      "elementType": "text",
      "children": [
        {
          "type": "static",
          "content": "Hello World!"
        }
      ]
    }
  }
}
import ReactGenerator from '@teleporthq/teleport-component-generator-react'

const uidl = { ... } // your sample here

const { files } = await ReactGenerator.generateComponent(uidl)
console.log(files[0].content)

// 解析出react代码
import React from 'react'

const MyFirstComponent = (props) => {
  return <span>Hello World!</span>
}

export default MyFirstComponent
import VueGenerator from '@teleporthq/teleport-component-generator-vue'

const uidl = { ... } // your sample here

const { files } = await VueGenerator.generateComponent(uidl)
console.log(files[0].content)

//解析出的vue
<template>
  <span>Hello World!</span>
</template>

<script>
export default {
  name: 'MyFirstComponent',
}
</script>

我们可以导入不同的包生成不同框架的源代码,是不是很简单?上面只是组件生成器,甚至还有项目生成器,打包配置器。

链路

源码 -> 定义UIDL -> teleport-code-generators生成代码 -> 后端将树遍历递归写入到文件中 -> 前端请求下载

总结

通过两种方式基本明白了实现了思路,其实就是通过映射转换成一个文件依赖树,修改源码我们只要根据拖拽的低代码组件做一个映射字段的替换就行。

在真实运用到生产中,一般会根据你对应项目和路由返回一个json对象,或者一个完整的项目对象,一般在不同环境你前端要有一个渲染器的renderer包来解析渲染。但当你有了出码这个功能,其实也可以实现服务端渲染了,少了层解析的步骤后,是不是速度比你运行时环境要快了很多

文章中有缺陷在所难免,也希望能得到大家的指正,觉得不错可以点赞关注收藏