从DSL 到渲染出vue3组件的几种方式

将 DSL(Domain-Specific Language,领域特定语言,通常在前端表现为 JSON 或 XML 格式的数据结构)渲染为 Vue 3 组件,是低代码(Low-Code)、动态表单、CMS 页面可视化搭建等场景的核心诉求。

在 Vue 3 中,实现从 DSL 到组件渲染主要有以下几种方式,按推荐程度和常用场景排序:


假设我们有如下的基础 DSL 结构:

1
2
3
4
5
6
7
8
9
10
11
{
"type": "div",
"props": { "class": "container" },
"children": [
{
"type": "MyButton",
"props": { "type": "primary", "text": "点击我" },
"children": []
}
]
}

1. 递归动态组件(<component :is="xxx">

这是最符合 Vue 模板开发直觉、使用最广泛的方式。通过编写一个递归的 Vue 组件,解析 DSL 的每一个节点。

  • 实现原理:利用 Vue 的 <component :is="..."> 动态挂载组件,并结合 v-for 和组件自身的递归调用来渲染子节点。
  • 代码示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!-- DslRenderer.vue -->
<template>
<!-- 动态渲染当前节点 -->
<component :is="resolveTag(dsl.type)" v-bind="dsl.props">
<!-- 如果有文本内容 -->
<template v-if="dsl.props.text">{{ dsl.props.text }}</template>

<!-- 递归渲染子节点 -->
<template v-if="dsl.children && dsl.children.length">
<DslRenderer
v-for="(child, index) in dsl.children"
:key="index"
:dsl="child"
/>
</template>
</component>
</template>

<script setup>
import { resolveComponent } from 'vue';
// 预先引入自定义组件
import MyButton from './MyButton.vue';

const props = defineProps({
dsl: Object
});

// 解析标签(区分原生 HTML 标签和 Vue 组件)
const resolveTag = (type) => {
const htmlTags = ['div', 'span', 'p', 'h1', 'section'];
if (htmlTags.includes(type)) {
return type;
}
// 如果全局注册过,也可以按需获取
const componentsMap = { MyButton };
return componentsMap[type] || type;
};
</script>
  • 优点:纯模板语法,心智负担小;天然支持 Vue 的指令(v-if, v-show);易于集成现有的 UI 组件库。
  • 缺点:对于极其复杂的巨型 DOM 树,递归组件的实例开销较大。

2. 渲染函数(Render Function / h 函数)

直接跳过模板编译阶段,使用 Vue 3 的底层 API h() 生成虚拟 DOM (VNode)。这是性能最好、最灵活的方式,也是大多数成熟开源低代码引擎(如阿里 LowCodeEngine 的 Vue 渲染器)的底层选择。

  • 实现原理:编写一个递归的 JavaScript 函数,遍历 DSL 并不断调用 h() 生成 VNode 树。
  • 代码示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// renderDsl.js
import { h, resolveComponent, Text } from 'vue';
import MyButton from './MyButton.vue';

const componentsMap = { MyButton };

export function renderDsl(node) {
if (!node) return null;

// 1. 获取组件类型
let tag = node.type;
// 如果不是原生标签,则从映射表或全局获取
if (!['div', 'span', 'p'].includes(tag)) {
tag = componentsMap[tag] || resolveComponent(tag);
}

// 2. 处理子节点
const children = [];
if (node.props?.text) {
children.push(node.props.text); // 纯文本节点
}
if (node.children && node.children.length > 0) {
children.push(...node.children.map(child => renderDsl(child)));
}

// 3. 返回 VNode
return h(tag, node.props, children);
}

使用时包装在一个函数式组件或普通组件的 setup 中:

1
2
3
4
5
6
7
8
9
10
11
<script>
import { defineComponent } from 'vue';
import { renderDsl } from './renderDsl';

export default defineComponent({
props: ['dsl'],
setup(props) {
return () => renderDsl(props.dsl);
}
});
</script>
  • 优点:性能极高(没有模板解析开销,直接生成 VNode);极度灵活,可以轻松实现复杂的逻辑流转(如动态插槽、事件拦截)。
  • 缺点:代码可读性稍差(尤其是多层嵌套时),需要开发者非常熟悉 Vue 的 VNode 设计规范。

3. JSX / TSX 语法

本质上是第 2 种方案(渲染函数)的语法糖。利用 Babel 将 JSX 编译成 h() 函数,使代码看起来更像 HTML 结构。

  • 代码示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// DslRenderer.tsx
import { defineComponent } from 'vue';
import MyButton from './MyButton.vue';

const componentsMap: Record<string, any> = { MyButton };

export const DslRenderer = defineComponent({
props: {
dsl: { type: Object, required: true }
},
setup(props) {
const renderNode = (node: any) => {
const Tag = componentsMap[node.type] || node.type;

return (
<Tag {...node.props}>
{node.props.text && node.props.text}
{node.children?.map((child: any) => renderNode(child))}
</Tag>
);
};

return () => renderNode(props.dsl);
}
});
  • 优点:比手写 h() 可读性好太多,React 开发者可以零成本平滑迁移。
  • 缺点:需要配置构建工具(Vite/Webpack)支持 JSX;部分 Vue 独有的模板指令(如 v-model 的修饰符、v-slot)在 JSX 中的写法较为反直觉。

4. 运行时模板编译(Runtime Compiler)

将 DSL 解析并拼接成一段 Vue template 字符串,然后利用 Vue 的运行时编译器将字符串动态编译为组件。

  • 实现原理:DSL -> Template String -> compile() -> Component
  • 代码示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { defineComponent } from 'vue';
// 注意:必须使用带有编译器的 Vue 构建版本 (vue.esm-browser.js 或配置 alias)

// 假设我们写了一个将 DSL 转为 HTML 字符串的函数
function dslToTemplate(dsl) {
// 生成类似 "<div class='container'><MyButton type='primary'>点击我</MyButton></div>" 的字符串
// 省略具体的字符串拼接逻辑
return `<${dsl.type} ...>...</${dsl.type}>`;
}

export const DynamicComponent = defineComponent({
props: ['dsl'],
setup(props) {
// 动态生成 template 字符串
const templateStr = dslToTemplate(props.dsl);

// 返回包含动态 template 的组件对象
return {
template: templateStr,
components: { MyButton }
}
}
});
  • 优点:可以将传统的 HTML 字符串片段直接变活,对处理服务端下发的富文本/旧版微前端架构比较有用。
  • 缺点
    • 严重警告:带来极大的安全风险(XSS 攻击),绝对不要渲染不可信的 DSL。
    • 性能较差:在浏览器里运行完整的 Vue 编译器,非常消耗性能。
    • 体积增加:打包时必须引入 Vue Compiler 版本,会增加约 100kb+ 的包体积。

5. 构建时编译(AST 生成 Vue SFC)

这种方式不属于“运行时渲染”,而是属于“代码生成(Code Generation)”。通常用于 D2C(Design to Code,设计稿转代码)工具中。

  • 实现原理:在 Node.js 环境下,读取 JSON DSL,利用 Babel 或直接拼字符串的方式,生成 .vue 文件(单文件组件 SFC),然后交给 Vite/Webpack 正常打包。
  • 使用场景:比如各种低代码平台中的“导出 Vue 源码”功能,或者企业内部的 CLI 脚手架工具。
  • 优点:运行时零负担,生成的代码可二次开发。
  • 缺点:无法应对运行时 DSL 发生改变的场景(即无法做到配置即刻生效)。

💡 总结与建议:我该选哪种?

方式 性能 灵活性 维护难度 适用场景
1. 递归动态组件 中等 极低(推荐) 中后台动态表单、配置驱动的基础页面构建。
2. h 渲染函数 最高 最高 大型低代码平台核心渲染器、需要极致性能的复杂树状组件。
3. JSX/TSX 团队有 React 背景,需要手写复杂渲染逻辑的场景。
4. 运行时编译 极低 危险 极特殊的富文本解析、兼容老旧系统(强烈不推荐常规业务使用)。
5. 构建时编译 - - - 设计稿转源码工具(D2C)、自动生成样板代码。

最佳实践闭环
绝大多数现代前端业务中,如果你在做一个动态表单或中小型低代码系统,推荐使用 第1种(递归动态组件);如果你在做一个商业级、面临复杂交互与插槽嵌套的低代码引擎,请毫不犹豫地选择 第2种(Render Function) 或 第3种(JSX)

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器