ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于Vue和Element的低代码平台:Schema驱动的中后台页面引擎实践

2026/9/11 17:16:47 拓冰建站 浏览量
基于Vue和Element的低代码平台:Schema驱动的中后台页面引擎实践 简介低代码开发正成为企业数字化转型中提升交付效率的关键手段。其核心并不在于拖拽交互而在于页面如何被结构化描述、如何在运行时被统一解析与渲染。通过定义一份可序列化的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是新建节点时自动填充的默认propssettings是属性面板渲染的配置项。settings 的字段在设计器里直接循环出表单字段越简单越好字段类型说明keystring对应组件props上的属性名labelstring属性面板里显示的中文标签controlstring编辑控件类型input / select / switchoptionsarraycontrol 为 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 :isconfig?.component v-bindresolvedProps template v-ifnode.children node.children.length RenderNode v-forchild in node.children :keychild.id :nodechild :modelmodel :handlershandlers / /template /component /templateconfig拿注册表里的组件引用和默认值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 :loadingloading clickhandleClick{{ 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-ifconfig :isconfig.component v-bindresolvedProps template v-ifnode.columns node.columns.length el-table-column v-forcol in node.columns :keycol.prop || col.type :propcol.prop :labelcol.label :widthcol.width :typecol.type || default / /template template v-else-ifnode.children node.children.length RenderNode v-forchild in node.children :keychild.id :nodechild :modelmodel :handlershandlers / /template /componentcolumns 是典型“配置元数据”不进节点树。这样设计器里给表格做列编辑时可以直接用一个小型 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 classeditor-node :class{ is-selected: selectedId node.id } click.stopselectNode(node.id) RenderNode :nodenode :modelmodel :handlershandlers / /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-width90px el-form-item v-forsetting in currentSettings :keysetting.key :labelsetting.label el-input v-ifsetting.control input v-modeldraftProps[setting.key] blurcommitProps / el-select v-else-ifsetting.control select v-modeldraftProps[setting.key] changecommitProps el-option v-foropt in setting.options :keyopt :labelopt :valueopt / /el-select /el-form-item /el-formcommit 的代码只有一行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 localStorageconst SAVE_KEY lc_page_v1 watch(schemaTree, (val) { localStorage.setItem(SAVE_KEY, JSON.stringify(val)) }, { deep: true })localStorage 有5MB上限中后台页面Schema通常在几十KB直接全量存完全够。页面初始化时读出SAVE_KEYJSON.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-foract in actions :keyact.label sizesmall :typeact.type || default clickact.handler(row) {{ act.label }} /el-button /templateactions数组每项就三个字段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。按这个顺序多数问题在前两步就能定位。本文还有配套的精品资源点击获取