☰
基于Vue和Element的低代码平台:Schema驱动的中后台页面引擎实践
2026/9/26 0:26:55 网站建设 项目流程

简介:低代码开发正成为企业数字化转型中提升交付效率的关键手段。其核心并不在于拖拽交互,而在于页面如何被结构化描述、如何在运行时被统一解析与渲染。通过定义一份可序列化的JSON Schema,将组件类型、属性、事件与数据绑定抽象为统一数据结构,再利用Vue的递归渲染能力,即可构建出编辑态与运行态完全一致的低代码引擎。Element Plus组件库的成熟生态,为表单、表格、弹窗等中后台高频场景提供了丰富的可配置基础。该方案能够有效降低企业中后台系统的开发维护成本,支持通过可视化配置快速生成业务页面,同时保持架构扩展性。本文从Schema建模、渲染器实现、编辑器交互到组件封装,系统剖析了基于Vue与Element构建低代码平台的完整技术路径。

1. 基于Vue和Element的低代码平台设计定位与价值

低代码平台在团队里最容易夭折,不是拖拽做不出来,而是“Vue和Element怎么抽象成可配置页面”这个建模问题没想清楚:后台存什么结构、前端按什么渲染、编辑器和线上如何保持同一套逻辑。很多项目做一半就翻车,编辑器存一套组件树、运行时又自己拼一套,用户配完页面刷新就走样。

基于Vue和Element的低代码平台,本质是一套Schema两端消费:用JSON描述组件类型、属性、插槽、事件,设计器负责编辑Schema,运行时用Vue递归渲染机制把它变成Element页面。不用把每个页面固化成.vue文件,也不走“拖拽生成完整代码再发布”的高成本链路。

下面按Schema建模、渲染器、编辑器、高级封装四条线拆开,穿插默认值合并、字段路径绑定、刷新丢失这些真实场景的踩坑点,适合想自建内部中后台低代码引擎的团队,也适合评估现有低代码方案时用来对照架构差异。

2. 低代码平台的Schema字段建模与组件注册机制

后台存什么,决定了低代码平台的天花板。常见做法是整棵组件节点树直接落JSON,因为页面跑起来时渲染器读取的也是同一份JSON,接口、版本、权限都能围绕这一种结构来设计。下面先定义节点最小集,再把Vue组件注册进去作为扩展能力。

2.1 页面节点树的最小Schema定义与字段分工

一个节点只做四件事:标识用什么组件、暴露哪些props、绑到哪里、子节点放哪些东西。以下是最小可运行的Schema,对应一个搜索表单加查询按钮:

{ "root": { "component": "el-form", "props": { "inline": true }, "children": [ { "id": "input-1", "component": "el-input", "props": { "placeholder": "请输入姓名", "clearable": true }, "bind": "query.name" }, { "id": "btn-1", "component": "el-button", "props": { "type": "primary" }, "events": { "onClick": "handleSearch" }, "children": [ { "id": "span-1", "component": "span", "props": { "text": "查询" } } ] } ] } }

每个字段对应渲染器里的一个读取分支:component从注册中心解析真实Vue组件;props原样透传成组件属性;bind是数据模型字段路径,输入框和模型之间双向绑定靠它;events保存事件名与处理函数标识;children是任意深度的子节点。

按钮文案为什么要放在子节点里而不是props里?这是可扩展性的一个重要取舍。如果给按钮定义了一个textprops,那以后要在按钮左边加图标,就得再造一个iconprops,再写一套图标的渲染逻辑;把文本和图标都当成span或el-icon这类子节点,插入图标就是在 children 数组里追加一个节点,渲染器代码一点不用动。节点结构统一以后,很多事情都变成“数组操作”,而不是给渲染器加分支。

2.2 组件注册中心:把Vue组件映射成可配置能力

Schema 里component是字符串,运行时需要注册中心把它翻译成组件对象。注册中心同时服务三端:设计器的组件面板管“有哪些组件可拖”,属性面板管“这个组件能改哪些属性”,渲染器负责真正画出来。

import { markRaw } from 'vue' const registry = new Map() export function registerComponent ({ type, component, defaults = {}, settings = [] }) { if (registry.has(type)) { console.warn(`[registry] 重复注册 ${type},保留已有定义`) return } registry.set(type, { component: markRaw(component), defaults, settings }) } export function getComponentConfig (type) { return registry.get(type) }

这里的markRaw不能省。如果直接把组件塞进一个reactive对象,Vue会把组件实例的静态形状转成响应式代理,复杂组件在diff时会出现性能损耗甚至报“组件注册了但渲染不出来”之类的怪错。defaults是新建节点时自动填充的默认props;settings是属性面板渲染的配置项。

settings 的字段在设计器里直接循环出表单,字段越简单越好:

字段类型说明
keystring对应组件props上的属性名
labelstring属性面板里显示的中文标签
controlstring编辑控件类型:input / select / switch
optionsarraycontrol 为 select 时的可选项列表
visiblefunction根据当前节点props决定该配置行是否显示

2.3 新建节点的默认值合并策略

从组件面板拖一个组件到画布,第一步是生成节点对象。这里最容易犯的错是只写{ component: type },让用户拖完还要去属性面板挨个补属性。实际应该把注册时的defaults合并进来,拖出来的就是一个能直接用的实例。

function createNode (type, extraProps = {}) { const cfg = getComponentConfig(type) const defaults = cfg ? cfg.defaults : {} return { id: `n_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, component: type, props: { ...defaults, ...extraProps }, children: [] } }

合并时一定要展开新对象,不要写props: defaults。有人在代码里直接把 defaults 对象赋给props,结果全局共享同一引用:用户把第一个按钮的 type 改成 danger,所有新按钮的 type 全被改掉。这个坑在团队里出现过两次,排查到最后都是新对象没有展开。默认值尽量放在注册中心而不是写进 Schema,后续想统一调整团队习惯,比如按钮默认尺寸从 medium 改成 small,改一处注册配置即可,历史页面无需迁移。

3. 用Vue渲染器把Schema变成可交互页面

渲染器是低代码平台中最稳定的一层,设计器和运行态共用同一个渲染逻辑是底线,否则两端永远对不上。渲染器只做一件事:拿Schema产出VNode,节点自身的临时状态不留在组件里。

3.1 RenderNode递归渲染节点的最小实现

页面是树,渲染器也就必须是递归的。RenderNode是一个渲染当前节点再渲染其children的组件,本身不持有业务数据:

<!-- RenderNode.vue --> <script setup> import { computed } from 'vue' import { getComponentConfig } from '@/schema/registry' const props = defineProps({ node: { type: Object, required: true }, model: { type: Object, required: true }, handlers: { type: Object, default: () => ({}) } }) const config = computed(() => getComponentConfig(props.node.component)) const resolvedProps = computed(() => { const { props: nodeProps = {}, events = {} } = props.node const result = { ...config.value?.defaults, ...nodeProps } for (const [eventName, handlerName] of Object.entries(events)) { const fn = props.handlers[handlerName] if (typeof fn === 'function') { result[`on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`] = fn } } return result }) </script> <template> <component :is="config?.component" v-bind="resolvedProps" > <template v-if="node.children && node.children.length"> <RenderNode v-for="child in node.children" :key="child.id" :node="child" :model="model" :handlers="handlers" /> </template> </component> </template>

config拿注册表里的组件引用和默认值;resolvedProps把默认值、节点props、事件监听合并成最终要绑定的对象;模板里的动态<component :is>完成实际渲染,如果有children就递归渲染。三个入参里,model是整个页面的数据对象,靠它才能实现跨层级的双向绑定;handlers是运行时从外部注入的函数表,Schema 里只存函数名字符串,这样Schema全程可JSON化,发布后从接口读出来即可直接渲染。

这里有个值得注意的边界:如果getComponentConfig查不到组件,config.value会是 undefined。不要把这种情况直接交给浏览器渲染成空白。比较稳的做法是在父组件里检查config,有就渲染,没有就输出一个带type文本的占位块并 console.warn,方便在设计器里一眼看出是哪个type拼错。

3.2 事件绑定与Element组件的透传边界

events里存onClick之类的事件名,渲染时按“on + 首字母大写”拼成Vue监听器,再从handlers取真实函数。这样属性透传和事件绑定都走 resolvedProps 一条链路。

但 Element 组件里有一类逻辑不适合让用户每个页面都重复写。比如删除按钮固定要做二次确认、确认时要loading防止重复点击,这类就是低代码平台常做的“业务组件封装”:

<!-- components/ConfirmButton.vue --> <script setup> import { ref } from 'vue' import { ElMessageBox, ElMessage } from 'element-plus' const props = defineProps({ text: { type: String, default: '删除' }, confirmText: { type: String, default: '确认删除该行数据?' }, action: { type: Function, required: true } }) const loading = ref(false) async function handleClick () { await ElMessageBox.confirm(props.confirmText, '提示', { type: 'warning' }) loading.value = true try { await props.action() ElMessage.success('已删除') } finally { loading.value = false } } </script> <template> <el-button :loading="loading" @click="handleClick">{{ text }}</el-button> </template>

然后在注册中心注册confirm-button,设计器里拖出来配一个action函数名就行。ElMessageBox 的$confirm和 loading 被收敛在这个业务组件里,低代码平台交付后所有页面的删除行为完全一致,也不会出现连点两次发出两条请求的情况。这类组件和Element原组件的区别在于,它有明确的业务含义,不再作为通用组件开放,适合登记在平台自己的组件库里。

3.3 bind字段路径与v-model的双向绑定

用户拖一个 el-input,在属性面板里把 bind 写成query.name,运行时要把这个字符串变成Vue的modelValue和onUpdate:modelValue。对应的工具函数是一对路径读写:

// model-path.js export function getByPath (obj, path) { if (!path) return undefined return path.split('.').reduce((prev, key) => ( prev == null ? undefined : prev[key] ), obj) } export function setByPath (obj, path, value) { const keys = path.split('.') let target = obj for (let i = 0; i < keys.length - 1; i++) { if (target[keys[i]] == null || typeof target[keys[i]] !== 'object') { target[keys[i]] = {} } target = target[keys[i]] } target[keys[keys.length - 1]] = value }

把路径分解成 key 数组,逐层读取或写入。setByPath里那句“中间层不存在则新建空对象”是关键。用户只在属性面板写了form.name,没有手工先建出form,如果直接用obj.form.name = v就会报 undefined。有了这段兜底,中间对象会自动补上,设计上不需要额外初始化数据结构。

在resolvedProps里做的是:

if (node.bind) { result.modelValue = getByPath(props.model, node.bind) result['onUpdate:modelValue'] = (v) => setByPath(props.model, node.bind, v) }

el-select、el-radio-group 这类组件在Element里触发的事件名不同,但v-model的协议是统一的,所以渲染器不用区分控件类型,全部走modelValue + onUpdate:modelValue就能覆盖大多数表单组件;个别需要额外事件的组件再在业务封装里补。

3.4 el-table这类数组容器的列配置处理

el-table无法只用props描述,因为列是数组结构,而且列里常常还要嵌套操作按钮。常见做法是在Schema节点上增加一个columns扩展字段,渲染器针对带 columns 的节点做特判:

{ "component": "el-table", "props": { "data": "userList", "border": true }, "columns": [ { "prop": "name", "label": "姓名", "width": 140 }, { "prop": "role", "label": "角色", "width": 120 }, { "type": "selection", "width": 50 } ] }

渲染器模板里补一段:

<component v-if="config" :is="config.component" v-bind="resolvedProps"> <template v-if="node.columns && node.columns.length"> <el-table-column v-for="col in node.columns" :key="col.prop || col.type" :prop="col.prop" :label="col.label" :width="col.width" :type="col.type || 'default'" /> </template> <template v-else-if="node.children && node.children.length"> <RenderNode v-for="child in node.children" :key="child.id" :node="child" :model="model" :handlers="handlers" /> </template> </component>

columns 是典型“配置元数据”,不进节点树。这样设计器里给表格做列编辑时,可以直接用一个小型 el-table 把 columns 数组的每一项改掉,不用去操作挂在组件树上的 children。事件绑定上,@selection-change 也走上一节说的 events 机制,Schema里写onSelectionChange即可。多页保留勾选是el-table自身能力,低代码只需要把row-key和reserve-selection暴露成两个props设置项,不要在渲染器里自己维护选中集。

不同组件在Schema里如何表达,这里列个对照,脚手架初期照着建就够:

组件Schema中的表达适配要点
el-formprops + childrenmodel 指向全局数据对象
el-tableprops + columns列配置存扩展字段,selection列用type表达
el-dialogprops + childrenfooter区域节点用slot字段区分
el-imagepropspreview-src-list 可直接填图片URL数组

4. 设计器画布与属性面板的实现要点

Schema和渲染器都稳定以后,编辑器要做的是把Schema的增删改搬到可视化画布上。拖拽本身不难,难的是每改一个属性,画布不能整棵重建。下面的方案把“操作”和“渲染”分层:操作完成后只把变化写进响应式的schemaTree,渲染器的下一帧自然会更新。

4.1 共用渲染器与设计态交互层的区分

编辑器画布和运行态页面用同一个 RenderNode,这是保证所见即所得的最短路径。区别只在画布这层给每个节点包了一个可选择、可高亮的编辑层:

<!-- EditorNode.vue --> <template> <div class="editor-node" :class="{ 'is-selected': selectedId === node.id }" @click.stop="selectNode(node.id)" > <RenderNode :node="node" :model="model" :handlers="handlers" /> </div> </template> <style scoped> .editor-node { position: relative; } .editor-node::after { content: ''; position: absolute; inset: 0; border: 2px solid transparent; pointer-events: none; } .editor-node.is-selected::after { border-color: #409eff; } </style>

这种方案的优点是不侵入渲染器,运行时完全不会多出这一层DOM。@click.stop的作用是让点击事件先被编辑器捕获,用来选中节点而不是触发业务事件。设计态下还有一个坑:被选中的 el-select 展开、el-button 弹消息等交互在设计器里依然会触发。要彻底隔离,可以在提供给画布的 handlers 里放占位函数,只记录日志并高亮,不执行真实页面逻辑。交付时把这一条列入验收清单。

4.2 组件面板拖入画布:新增节点保持响应式

拖拽组件不必上来就引入复杂库,HTML5原生拖拽配合响应式数组就够用。组件面板的每一项设好draggable,拖起时往dataTransfer写入类型;画布监听drop,把新节点 splice 进目标容器的 children:

// ComponentPanel.vue function onDragStart (event, type) { event.dataTransfer.setData('application/x-component', type) event.dataTransfer.effectAllowed = 'copy' } // Canvas.vue function onDrop (event) { event.preventDefault() const type = event.dataTransfer.getData('application/x-component') if (!type) { return } const node = createNode(type) const parent = findNode(schemaTree.value, activeContainerId) parent.children.splice(dropIndex, 0, node) pushHistory() }

dropIndex可以根据鼠标位置和容器内子节点边界计算,先落一个最简单版本——统一追加到末尾,后续再按坐标调整。这里最重要的一行是parent.children.splice:因为schemaTree是 reactive 对象,数组 splice 会触发 Vue 响应式更新,渲染器自动在下一帧渲染出新节点。不要换成schemaTree.value = { ...newTree },整体换新对象会让当前编辑焦点和展开状态全部丢光。

4.3 属性面板动态表单与Schema写入时机

属性面板不针对某个具体组件写死,而是循环渲染注册时传入的settings。关键点是“写入时机”——如果用户每敲一个字母,画布立刻跟着变,输入框的光标会被diff过程重置。做法是让面板先改一份draftProps,失焦或切换下拉选项时才提交:

<!-- PropPanel.vue --> <el-form label-width="90px"> <el-form-item v-for="setting in currentSettings" :key="setting.key" :label="setting.label" > <el-input v-if="setting.control === 'input'" v-model="draftProps[setting.key]" @blur="commitProps" /> <el-select v-else-if="setting.control === 'select'" v-model="draftProps[setting.key]" @change="commitProps" > <el-option v-for="opt in setting.options" :key="opt" :label="opt" :value="opt" /> </el-select> </el-form-item> </el-form>

commit 的代码只有一行:

function commitProps () { Object.assign(selectedNode.value.props, draftProps.value) pushHistory() }

这里刻意不用selectedNode.value.props = { ...draftProps.value }。重新赋值会让 props 对象引用变化,渲染器diff时认为节点整体变了;Object.assign 只改具体 key,节点引用没变,触发的是更细粒度的更新,光标问题基本消失。需要实时反馈的属性,比如按钮颜色,可以单独在commitProps时立刻走一次nextTick,按属性名放进一个实时更新集合即可。

4.4 Schema快照、撤销与本地缓存

撤销对低代码平台来说是刚需。实现不用复杂算法,一个快照栈就够,重点在于什么时候入栈。对Schema做 deep watch 是不可取的,拖拽过程中的每次autosave都会入栈,历史记录会被无效快照刷爆。正解是把入栈动作绑定到明确的用户操作上:

const history = ref([]) let historyIndex = -1 function pushHistory () { history.value.splice(historyIndex + 1) history.value.push(JSON.parse(JSON.stringify(schemaTree.value))) historyIndex = history.value.length - 1 } function undo () { if (historyIndex <= 0) return historyIndex-- schemaTree.value = JSON.parse(JSON.stringify(history.value[historyIndex])) }

入栈的三个时机分别是:drop 新增节点、删除节点、属性面板 commitProps。下面的对照表方便接按钮时查漏:

操作入栈时机快照内容
拖入新组件drop 时插入后的整树
删除节点删除前删除前的整树
属性修改commitProps提交后的整树
调整顺序拖拽排序onDrop调整前整树

本地缓存另走一条线,用 watch + localStorage:

const SAVE_KEY = 'lc_page_v1' watch(schemaTree, (val) => { localStorage.setItem(SAVE_KEY, JSON.stringify(val)) }, { deep: true })

localStorage 有5MB上限,中后台页面Schema通常在几十KB,直接全量存完全够。页面初始化时读出SAVE_KEY,JSON.parse后赋给schemaTree,整个设计器恢复。如果页面规模超过500KB,再升级成indexedDB或后端版本表,但初版不建议为此上复杂存储。

5. 进阶:插槽抽象、按需加载与落地验证

到了交付阶段,再看三个直接把平台变好用的细节:表格操作列如何不写死在渲染器里、重型组件怎么按需加载,以及交付前到底该验证哪些东西。

5.1 表格操作列封装成可复用业务组件

操作列是低代码页面中出现率最高的区域。与其把按钮们一个一个塞进 children,不如封装一个 table-actions 业务组件,在 columns 里留一个{ "type": "actions" }的列:

<!-- TableActions.vue --> <script setup> defineProps({ row: { type: Object, required: true }, actions: { type: Array, default: () => [] } }) </script> <template> <el-button v-for="act in actions" :key="act.label" size="small" :type="act.type || 'default'" @click="act.handler(row)" > {{ act.label }} </el-button> </template>

actions数组每项就三个字段:label、type、handler。handler仍用函数名运行注入,Schema保持可JSON化。删除按钮的二次确认和loading,在业务组件里统一套一层ConfirmButton逻辑,就不会出现页面间行为不一致。popconfirm再加一个输入框这类复杂二次确认,也是同样做法:单独封装成一个业务组件注册进组件库,而不是去改低代码引擎。

5.2 用defineAsyncComponent做组件的按需分包

自建组件库会越堆越多,不能全打进首屏包。注册中心天然适合做异步化:组件只有被Schema引用时才真正加载。

import { defineAsyncComponent } from 'vue' registerComponent({ type: 'video-player', component: defineAsyncComponent(() => import('@/components/VideoPlayer.vue')), defaults: { controls: true }, settings: [ { key: 'src', label: '视频地址', control: 'input' } ] })

m3u8 播放器、地图、图表这类重量组件,都可以用这个方式注册。渲染逻辑不变,Renderer 里:is本来就能接收 Promise 组件对象,markRaw操作也不会破坏异步组件的加载。打开Network面板能看到,只有页面真正出现 video-player 节点时,对应 chunk 才会请求。

5.3 交付前的最小验证清单

最后按一套能跑完的步骤过一遍。列出最关键的五大项:

验证步骤操作预期结果
注册链接控制台执行 getComponentConfig('el-button')返回对象而不是undefined
默认值生效拖入 el-button 查看属性面板type字段默认是primary
双向绑定输入框bind设为query.name并输入数据源里query.name同步更新
刷新恢复改完页面刷新浏览器结构与localStorage中一致
异步加载页面放一个video-playerNetwork出现独立chunk请求

如果画布白屏,先排查注册有没有遗漏,再看 Schema 里的 type 是否和注册中心一致,最后检查 props 合并是否误改了全局 defaults。按这个顺序,多数问题在前两步就能定位。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询