前端页面引擎协议:由浅入深------从 30 行到 vform3 的演化之路
研究日期:2026-05-09(重写版) 方法:不是直接分析成品,而是从零开始推导------每增加一个特性,都先问"上一个版本有什么问题"。 核心案例:vform3(Variant Form 3),横向参照 AMIS / Formily。
目录
- [0. UI 的本质------一棵组件树](#0. UI 的本质——一棵组件树 "#0-ui-%E7%9A%84%E6%9C%AC%E8%B4%A8%E4%B8%80%E6%A3%B5%E7%BB%84%E4%BB%B6%E6%A0%91")
- [v0. 用 JSON 描述这棵树------协议的定义](#v0. 用 JSON 描述这棵树——协议的定义 "#v0-%E7%94%A8-json-%E6%8F%8F%E8%BF%B0%E8%BF%99%E6%A3%B5%E6%A0%91%E5%8D%8F%E8%AE%AE%E7%9A%84%E5%AE%9A%E4%B9%89")
- [v1. 最简渲染器------把 JSON 变成 HTML](#v1. 最简渲染器——把 JSON 变成 HTML "#v1-%E6%9C%80%E7%AE%80%E6%B8%B2%E6%9F%93%E5%99%A8%E6%8A%8A-json-%E5%8F%98%E6%88%90-html")
- [v2. 引入数据绑定------表单要有"记忆"](#v2. 引入数据绑定——表单要有"记忆" "#v2-%E5%BC%95%E5%85%A5%E6%95%B0%E6%8D%AE%E7%BB%91%E5%AE%9A%E8%A1%A8%E5%8D%95%E8%A6%81%E6%9C%89%E8%AE%B0%E5%BF%86")
- [v3. 引入容器组件------布局也是一种组件](#v3. 引入容器组件——布局也是一种组件 "#v3-%E5%BC%95%E5%85%A5%E5%AE%B9%E5%99%A8%E7%BB%84%E4%BB%B6%E5%B8%83%E5%B1%80%E4%B9%9F%E6%98%AF%E4%B8%80%E7%A7%8D%E7%BB%84%E4%BB%B6")
- [v4. 引入代码生成------从"运行时解释"到"编译输出"](#v4. 引入代码生成——从"运行时解释"到"编译输出" "#v4-%E5%BC%95%E5%85%A5%E4%BB%A3%E7%A0%81%E7%94%9F%E6%88%90%E4%BB%8E%E8%BF%90%E8%A1%8C%E6%97%B6%E8%A7%A3%E9%87%8A%E5%88%B0%E7%BC%96%E8%AF%91%E8%BE%93%E5%87%BA")
- [v5. 引入扩展机制------不写源码也能加组件](#v5. 引入扩展机制——不写源码也能加组件 "#v5-%E5%BC%95%E5%85%A5%E6%89%A9%E5%B1%95%E6%9C%BA%E5%88%B6%E4%B8%8D%E5%86%99%E6%BA%90%E7%A0%81%E4%B9%9F%E8%83%BD%E5%8A%A0%E7%BB%84%E4%BB%B6")
- [v6. 最终架构------vform3 的全貌](#v6. 最终架构——vform3 的全貌 "#v6-%E6%9C%80%E7%BB%88%E6%9E%B6%E6%9E%84vform3-%E7%9A%84%E5%85%A8%E8%B2%8C")
- [横向对比:vform3 的路径选择 vs AMIS vs Formily](#横向对比:vform3 的路径选择 vs AMIS vs Formily "#%E6%A8%AA%E5%90%91%E5%AF%B9%E6%AF%94vform3-%E7%9A%84%E8%B7%AF%E5%BE%84%E9%80%89%E6%8B%A9-vs-amis-vs-formily")
- [总结:JSON 页面引擎的演化规律](#总结:JSON 页面引擎的演化规律 "#%E6%80%BB%E7%BB%93json-%E9%A1%B5%E9%9D%A2%E5%BC%95%E6%93%8E%E7%9A%84%E6%BC%94%E5%8C%96%E8%A7%84%E5%BE%8B")
0. UI 的本质------一棵组件树
在讨论"怎么写 JSON"之前,先搞清楚我们要描述的东西到底是什么。
打开任何一个网页,不管它多复杂,在开发者眼里它都是一棵组件树:
scss
页面 (Page)
├── 顶栏 (Header)
│ ├── Logo (Image)
│ └── 导航 (Nav)
│ ├── 首页 (Link)
│ └── 关于 (Link)
├── 主体 (Main)
│ ├── 表单 (Form)
│ │ ├── 姓名 (Input)
│ │ ├── 性别 (Select)
│ │ └── 提交 (Button)
│ └── 侧栏 (Sidebar)
└── 底栏 (Footer)
这棵树的每个节点有三个特征:
- 它是什么 ------ 类型(Input? Button? Form?)
- 它有什么属性 ------ 配置(label 是什么?placeholder 是什么?宽高多少?)
- 它包含什么 ------ 子节点(Form 包含 Input + Button;Page 包含 Header + Main + Footer)
换句话说,任意 UI 都可以抽象为:
ini
ComponentNode = {
类型标识,
属性集合,
子节点列表 // 子节点本身也是 ComponentNode → 递归
}
这就是整个"页面引擎协议"的全部理论基础。接下来所有版本都在回答同一个问题:怎么用 JSON 把这棵树存下来,又怎么把它变回 UI。
v0. 用 JSON 描述这棵树------协议的定义
目标
把上面的组件树抽象,用 JSON 表达出来。这一步不涉及任何渲染逻辑------只是定义数据结构。
最简协议
任何一个 UI 组件,最少需要三个字段:
typescript
interface ComponentNode {
type: string // 类型标识:'input' | 'select' | 'button' | 'form' | ...
props: Record<string, any> // 属性集合:{ label, placeholder, disabled, ... }
children: ComponentNode[] // 子节点列表(递归)
}
用这个协议描述一个登录表单
json
{
"type": "form",
"props": { "title": "用户登录" },
"children": [
{
"type": "input",
"props": { "name": "username", "label": "用户名", "placeholder": "请输入用户名" },
"children": []
},
{
"type": "password",
"props": { "name": "password", "label": "密码", "placeholder": "请输入密码" },
"children": []
},
{
"type": "button",
"props": { "text": "登录", "style": "primary" },
"children": []
}
]
}
v0 的核心要点
这 20 行 JSON 已经完整描述了一个登录表单。注意几个关键设计:
1. type 是组件的"身份证"
不是 type: "el-input",不是 type: "v-text-field",而是 type: "input" ------ 一个与框架无关的语义标识 。至于 "input" 最终被渲染成 <el-input> 还是 <input> 还是 <v-text-field>,那是渲染器的事,协议不管。
2. children 使得结构可以任意嵌套
Form 的 children 是 [Input, Password, Button],每个又是完整的 ComponentNode。这意味着你可以在 children 里放任何东西------包括另一个 Form、一个 Grid、一个 Tab。递归是描述任意复杂 UI 的唯一手段。
3. props 是组件专属的"配置空间"
不同 type 的 props 可以完全不同。协议只约定 props 是一个键值对对象,不约定里面有什么------这是扩展性的基础。
v0 解决了什么?
- 存储 UI:把设计器里的表单存成一个 JSON 文件
- 传输 UI:把 JSON 通过 API 发给后端/其他客户端
- 编辑 UI:修改某个 Input 的 label → 改
children[0].props.label即可
v0 还没解决什么?
最关键的问题:这个 JSON 怎么变成屏幕上能看到的页面?
v1. 最简渲染器------把 JSON 变成 HTML
目标
给 v0 的 JSON 协议写一个消费者:输入 JSON,输出 DOM。
实现
javascript
// v1: 最简渲染器 ------ 输入组件树 JSON,输出 HTML 字符串
function render(node) {
if (node.type === 'input') {
return `<div class="field">
<label>${node.props.label}</label>
<input type="text" name="${node.props.name}"
placeholder="${node.props.placeholder || ''}" />
</div>`
}
if (node.type === 'password') {
return `<div class="field">
<label>${node.props.label}</label>
<input type="password" name="${node.props.name}"
placeholder="${node.props.placeholder || ''}" />
</div>`
}
if (node.type === 'button') {
return `<button class="btn-${node.props.style}">${node.props.text}</button>`
}
// 容器节点:先渲染外壳,再递归渲染 children
if (node.type === 'form') {
const childrenHTML = (node.children || [])
.map(child => render(child)).join('
')
return `<form class="vform">
<h2>${node.props.title || ''}</h2>
${childrenHTML}
</form>`
}
return `<!-- unknown type: ${node.type} -->`
}
// 使用
const formTree = {
type: 'form', props: { title: '用户登录' },
children: [
{ type: 'input', props: { name: 'username', label: '用户名' }, children: [] },
{ type: 'password', props: { name: 'password', label: '密码' }, children: [] },
{ type: 'button', props: { text: '登录', style: 'primary' }, children: [] },
]
}
document.body.innerHTML = render(formTree)
输出结果:
html
<form class="vform">
<h2>用户登录</h2>
<div class="field"><label>用户名</label><input type="text" name="username" /></div>
<div class="field"><label>密码</label><input type="password" name="password" /></div>
<button class="btn-primary">登录</button>
</form>
v1 的核心设计
递归渲染 :render(form) 调用 render(child),每个 child 又调用 render(grandchild)......直到叶子节点。这和 JSON 的嵌套结构完全对应。
类型分发 :if (node.type === 'input') 是最朴素的多态机制。每种 type 对应一种渲染方式。
v1 在真实场景下有什么问题?
| 问题 | 表现 |
|---|---|
| 无数据收集 | 用户填了表单,JS 拿不到值。HTML 字符串一旦插入 DOM,和 JS 变量就失联了。 |
| 无校验 | 必填字段空着也能提交。 |
| 无交互 | 选"男"时隐藏某个字段?做不到。 |
| 无响应式 | 想修改 label?不行------HTML 已经生成了,不会自动更新。 |
| 不可扩展 | 加一个新类型要改 render() 函数的 if 链。 |
最致命的是第一条:JSON 描述的是"结构",但渲染出来的 HTML 是"死"的------没有数据绑定。
v2. 引入数据绑定------表单要有"记忆"
要解决什么问题?
v1 把 JSON 变成了 HTML,但渲染完就失联了------用户填的内容没有被任何 JS 变量持有,你拿不到表单数据。
技术选型:为什么这里开始用 Vue?
v1 是纯 JS 字符串拼接。但"数据变化 → 自动更新 UI"这件事,手写太麻烦了。 我们需要一个响应式框架------Vue 的 reactive() 恰好提供这个能力。
从 v2 开始,所有代码示例都是 Vue 3 的。这不是说协议只能用在 Vue ------同样的思路用 React 的 useState / MobX 也能实现,只是响应式机制不同。
实现
在渲染之前,先扫描 JSON,为每个字段建一个数据槽位。然后渲染时把各个表单控件(input、select、datepicker......)和对应槽位绑定。
javascript
// v1: 加入数据模型 + Vue 响应式绑定
// 假设在 Vue 组件的 setup 中
import { reactive } from 'vue'
function useFormRenderer(formJson) {
// 第一步:扫描 JSON,建立数据模型
const formModel = reactive({})
for (const w of formJson.widgetList) {
if (w.options.name) {
formModel[w.options.name] = w.options.defaultValue ?? ''
}
}
// 现在 formModel = { username: '', gender: '' }
// 第二步:渲染时绑定 v-model
// → 不再用纯字符串,而是用 Vue 的 <component :is>
// → 每个字段组件内部写 v-model="formModel[field.options.name]"
return { formModel }
}
对应的 JSON 变化
JSON 里必须加 name 字段(数据字段名)和 defaultValue。
同时,v0/v1 用的简化协议和真实 vform3 有三处命名差异,这里统一对齐:
| v0/v1(教学简化版) | v2 起(vform3 真实命名) | 语义 |
|---|---|---|
props |
options |
组件属性集合 |
children |
widgetList |
子组件列表 |
| (无) | category |
区分容器/字段 |
为什么 vform3 叫 widgetList 而不是 children? 因为后续版本中,不同容器的子节点路径不同------grid 用 cols,tab 用 tabs,table 用 rows[].cols。widgetList 只是其中一种子节点路径。children 太"通用"了,会掩盖真实复杂度。后文统一使用 vform3 真实命名:
json
{
"type": "input",
"options": {
"name": "username", // ← 新增:数据字段名
"label": "用户名",
"defaultValue": "", // ← 新增:默认值
"placeholder": "请输入"
}
}
v2 的渲染模板
不再是纯字符串拼接,而是 Vue 的动态组件:
html
<el-form :model="formModel">
<template v-for="widget in widgetList">
<!-- 根据 widget.type 选择不同组件 -->
<input-widget v-if="widget.type === 'input'" :field="widget" v-model="formModel[widget.options.name]" />
<select-widget v-if="widget.type === 'select'" :field="widget" v-model="formModel[widget.options.name]" />
<!-- ... -->
</template>
</el-form>
v2 解决了什么?
- ✅ 数据收集:
formModel持有所有用户输入 - ✅ 响应式:Vue 的
reactive使得数据变化自动反映到 UI - ✅ 可以
formModel.username随时读取/写入
v2 还有什么问题?
- ❌ 还是无法嵌套布局。grid、tab、table 这些容器怎么表示?
- ❌ 组件类型判断仍然是一堆
v-if,每加一个类型都要改模板 - ❌ 数据模型构建太简单------如果容器里的字段名叫
username,它也能找到。但如果是深层嵌套(tab 里的 grid 里的 input)呢?
v3. 引入容器组件------布局也是一种组件
要解决什么问题?
表单不是只有垂直排列的字段。用户需要:
- 两列并排(grid 布局)
- 标签页分组(tab)
- 表格内嵌字段(table 布局)
这些"布局"本质上也是组件,只是它们内部可以放其他组件。
核心洞察:容器组件的递归结构
lua
grid 容器
├── col 1(容器)
│ ├── input(叶子)
│ └── select(叶子)
└── col 2(容器)
├── date(叶子)
└── input(叶子)
这棵树的关键特征是:只有叶子节点产生数据,但容器节点定义了结构。
在 JSON 里如何表示?
给每个节点加一个 category 字段:
json
{
"widgetList": [
{
"type": "grid",
"category": "container", // ← 标记这是容器
"cols": [
{
"type": "grid-col",
"category": "container",
"options": { "span": 12 },
"widgetList": [ // ← 容器内的子组件
{
"type": "input",
"category": "field", // ← 标记这是叶子字段
"formItemFlag": true,
"options": { "name": "username", "label": "用户名" }
}
]
},
{
"type": "grid-col",
"category": "container",
"options": { "span": 12 },
"widgetList": [
{
"type": "select",
"category": "field",
"formItemFlag": true,
"options": { "name": "gender", "label": "性别" }
}
]
}
],
"options": { "gutter": 12 }
}
]
}
v3 的渲染逻辑------递归才是关键
ini
v-for widget in widgetList
├── widget.category === 'container'
│ → 渲染容器组件(传入 widget 作为 prop)
│ → 容器组件内部再次 v-for 渲染自己的 widgetList
│ → 递归...
│
└── widget.category === 'field'
→ 渲染叶子组件
→ 绑定 v-model="formModel[widget.options.name]"
这就是 vform3 的 form-render/index.vue 里那 10 行模板的本质。
v3 的数据模型构建------必须递归遍历
之前的 buildFormModel 只能处理平铺的 widgetList。现在有了容器,必须根据容器类型走不同的子节点路径:
javascript
function buildFormModel(widgetList) {
widgetList.forEach(w => {
if (w.category === 'container') {
// 根据容器类型选择不同的嵌套路径
if (w.type === 'grid') {
w.cols.forEach(col => buildFormModel(col.widgetList))
} else if (w.type === 'tab') {
w.tabs.forEach(tab => buildFormModel(tab.widgetList))
} else if (w.type === 'table') {
w.rows.forEach(row => row.cols.forEach(col => buildFormModel(col.widgetList)))
}
// 每个新容器类型都要加一个分支
} else if (w.formItemFlag) {
formModel[w.options.name] = w.options.defaultValue
}
})
}
v3 解决了什么?
- ✅ 支持嵌套布局(grid / table / tab)
- ✅ 容器内可以放容器(递归)
v3 的问题?
| 问题 | 说明 |
|---|---|
| 渲染逻辑分散 | 每个容器组件各自写自己的子节点遍历,代码重复 |
| 数据模型构建脆弱 | buildFormModel 里 if/else 链随容器类型线性增长,每加一个容器都要改这个函数 |
| 运行时依赖重 | 用户部署的表单页面必须带着整个 vform3 渲染器------哪怕只是一个 3 字段的简单表单 |
| 无法导出独立项目 | 生成的表单不能脱离 vform3 的设计器环境单独运行 |
尤其是最后一点:如果用户只是想"设计好 → 导出代码 → 放进自己的 Vue 项目",为什么必须带着设计器的运行时?
v4. 引入代码生成------从"运行时解释"到"编译输出"
要解决什么问题?
v3 的运行时方案有一个根本缺陷:用户设计的表单永远离不开 vform3 引擎。 一个只有 3 个字段的简单表单,部署时也必须带着整个渲染器。
打个比方:设计师用 Figma 画完 UI,导出的是 PNG/SVG,不是 Figma 的运行时。 同理,用户用表单设计器画完表单,期望导出的是能独立运行的代码。
如果 JSON 本身是"源码",为什么不在构建时把它编译成最终的 Vue SFC 呢?
具体问题三条:
- 产物依赖渲染引擎 → 部署包体积大
- 每次加载都要解析 JSON → 有性能开销
- 无法脱离设计器环境独立运行 → 不能作为独立项目交付
v4 的核心思路:策略对象 + 模板生成
把 v3 的"运行时组件分发"改成"构建时代码拼接":
javascript
// 策略对象:每个 type 对应一个 HTML 模板生成函数
const elTemplates = {
'input': (widget, formConfig) => {
const opts = widget.options
return `<el-input v-model="${formConfig.modelName}.${opts.name}"
placeholder="${opts.placeholder}"
:disabled="${opts.disabled}" />`
},
'select': (widget, formConfig) => {
const opts = widget.options
return `<el-select v-model="${formConfig.modelName}.${opts.name}">
<el-option v-for="(item, index) in ${opts.name}Options"
:key="index" :label="item.label" :value="item.value" />
</el-select>`
},
// ...
}
// 同样,容器组件也有对应的模板生成函数
const containerTemplates = {
'grid': (widget, formConfig) => {
return `<el-row>
${widget.cols.map(col => `
<el-col :span="${col.options.span}">
${col.widgetList.map(cw =>
cw.category === 'container'
? containerTemplates[cw.type](cw, formConfig)
: elTemplates[cw.type](cw, formConfig)
).join('')}
</el-col>
`).join('')}
</el-row>`
},
// 'tab', 'table' 类似...
}
然后拼出完整的 .vue 文件
javascript
function generateSFC(formJson) {
const fc = formJson.formConfig
const widgetList = formJson.widgetList
// 1. 生成 <template>
const template = `
<template>
<el-form :model="${fc.modelName}" ref="${fc.refName}" :rules="${fc.rulesName}"
label-position="${fc.labelPosition}" label-width="${fc.labelWidth}px">
${widgetList.map(w =>
w.category === 'container'
? containerTemplates[w.type](w, fc)
: elTemplates[w.type](w, fc)
).join('\n')}
</el-form>
</template>`
// 2. 生成 <script>
const script = generateScript(fc, widgetList) // 遍历 widgetList 提取 defaultValues、rules、options
// 3. 生成 <style>
const style = generateCSS(fc)
// 4. 拼出完整文件
return `${template}\n\n${script}\n\n${style}`
}
v4 的设计要点
策略对象取代 if/else:
elTemplates是"如何渲染一个字段组件"的策略映射containerTemplates是"如何渲染一个容器组件"的策略映射- 加新组件 = 往策略对象里加一个函数,不改动任何现有代码
遍历逻辑集中:
genTemplate()统一处理 widgetList → HTML 模板字符串genVue2JS()/genVue3JS()统一处理 widgetList → JS data/rules/options- 不再分散在十几个 .vue 文件里
v4 的输出:一份完全独立的 .vue 文件
vue
<template>
<el-form :model="formData" ref="vForm" :rules="rules"
label-position="left" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" placeholder="请输入" clearable />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-select v-model="formData.gender">
<el-option v-for="(item, index) in genderOptions"
:key="index" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
</template>
<script>
export default {
data() {
return {
formData: { username: "", gender: null },
rules: { username: [{ required: true, message: '此项必填' }] },
genderOptions: [{ label: '男', value: '1' }, { label: '女', value: '0' }]
}
}
}
</script>
这份 .vue 文件不依赖 vform3 的任何运行时,是一个标准的 Vue SFC,可以直接放进任何 Vue 项目。
v4 解决了什么?
- ✅ 产物独立,零运行时依赖
- ✅ 策略对象解耦了类型和渲染逻辑
- ✅ 加新组件类型只需:1) 定义 schema,2) 在 elTemplates 加一个函数
v4 的问题?
- ❌ 只能生成代码,不能在设计器内实时预览
- ❌ 代码生成是"全量"的------改一个字段的 label,要重新生成整个 .vue 文件
- ❌ 如果用户想加一个自定义组件(比如"级联选择器"),需要改 vform3 的源码(在 elTemplates 里加函数)
设计器需要的是:同时支持实时预览(运行时渲染)和导出代码(构建时生成),且两套方案用同一份组件定义。
这就引出了 v5。
v5. 引入扩展机制------不写源码也能加组件
要解决什么问题?
v3 的策略对象(elTemplates)是硬编码在源码里的。如果某个用户想要一个"地图选点"组件,他必须:
- 改 vform3 源码,在
elTemplates里加函数 - 改设计器源码,在组件面板里加入口
- 改属性编辑器源码,加配置面板
这不可能规模化。
v5 的设计:三项分离
每个自定义组件需要提供三样独立的东西:
bash
自定义组件 = {
schema: { type, icon, options 默认值 }, // ← 告诉系统"这是什么"
renderer: Vue 组件(接收 field/widget prop), // ← 告诉系统"怎么渲染"
editor: 属性配置面板 // ← 告诉系统"怎么在设计器里编辑它"
}
Schema 定义
javascript
// 用户提供的 schema 文件
export const mapPickerSchema = {
type: 'map-picker',
category: 'field',
icon: 'map-location',
formItemFlag: true,
options: {
name: '',
label: '位置选择',
defaultValue: { lat: 0, lng: 0 },
mapProvider: 'amap', // 高德 or 百度
zoom: 12,
// ...
}
}
渲染组件
vue
<!-- map-picker-widget.vue -->
<template>
<el-form-item :label="field.options.label">
<div id="map-container" @click="pickLocation">
当前坐标:{{ modelValue.lat }}, {{ modelValue.lng }}
</div>
</el-form-item>
</template>
<script setup>
const props = defineProps({ field: Object, modelValue: Object })
// ...地图初始化逻辑
</script>
注册
javascript
// 用户在自己的项目里:
import { registerCustomWidget } from 'vform3'
import mapPickerSchema from './map-picker-schema'
import MapPickerWidget from './map-picker-widget.vue'
registerCustomWidget({
schema: mapPickerSchema,
renderer: MapPickerWidget,
editor: MapPickerEditor // 设计器里的属性面板
})
v5 解决了什么?
- ✅ 不需要改 vform3 源码就能加新组件
- ✅ schema / renderer / editor 三者独立,可以各自替换
- ✅ 组件开发者只需关心自己的组件,不需要了解整个引擎
v5 的问题?
这已经是一个相当完善的系统了。剩下的问题是:
- 运行时渲染和代码生成如何共用同一份组件定义?
- 深层嵌套的数据模型构建如何避免为每种容器写分支?
这些是 v6(当前版本)要解决的。
v6. 最终架构------vform3 的全貌
v5 不是全新的设计,而是 v1~v4 的整合与收敛。
6.1 双轨制------运行时 + 代码生成
这是 vform3 最核心的架构决策:
scss
一份 JSON (formConfig + widgetList)
│
┌────────────┴────────────┐
│ │
运行时渲染轨 代码生成轨
(form-render/) (sfc-generator.js)
│ │
动态组件 + provide/inject 策略对象 + 字符串拼接
│ │
▼ ▼
设计器内实时预览 可导出的独立 .vue 文件
两套方案共用:
- 同一份 widgetConfigs(组件配置列表)
- 同一套命名约定(
type + '-widget'/type + '-item') - 同一份 formConfig 全局配置
6.2 通用遍历器------不再为每种容器写 if/else
v3 里 buildFormModel 的 if/else 地狱在此收敛:
javascript
// v6: 两个通用遍历器,所有需要遍历 widgetList 的地方都用它们
// 遍历所有叶子字段
export function traverseFieldWidgets(widgetList, handlerFn) {
widgetList.forEach(w => {
if (w.category === 'container') {
traverseContainerWidgets([w], container => {
if (container.type === 'grid') {
container.cols.forEach(col => traverseFieldWidgets(col.widgetList, handlerFn))
} else if (container.type === 'table') {
container.rows.forEach(row => row.cols.forEach(col =>
traverseFieldWidgets(col.widgetList, handlerFn)))
} else if (container.type === 'tab') {
container.tabs.forEach(tab => traverseFieldWidgets(tab.widgetList, handlerFn))
} else {
if (container.widgetList) traverseFieldWidgets(container.widgetList, handlerFn)
}
})
} else if (w.formItemFlag) {
handlerFn(w)
}
})
}
// 遍历所有容器
export function traverseContainerWidgets(widgetList, handlerFn) { /* 类似 */ }
有了这两个遍历器,所有后续功能都是 "handlerFn" 的变体:
javascript
// 构建数据模型 = 遍历字段 + 初始化数据槽位
traverseFieldWidgets(widgetList, w => {
formModel[w.options.name] = w.options.defaultValue
})
// 构建校验规则 = 遍历字段 + 提取 required/validation
traverseFieldWidgets(widgetList, w => {
if (w.options.required) rules[w.options.name] = [...]
})
// 构建选项数据 = 遍历字段 + 提取 optionItems
traverseFieldWidgets(widgetList, w => {
if (['radio','checkbox','select','cascader'].includes(w.type))
fieldOptions[w.options.name] = w.options.optionItems
})
不再有分散的遍历逻辑。所有需要"遍历整棵组件树"的地方,都通过这两个遍历器完成。
6.3 全局依赖注入------深层嵌套组件的通信
v6 用 Vue 的 provide/inject 解决了"深层嵌套的叶子组件如何访问表单上下文"的问题:
javascript
// 顶层 form-render 组件 provide 全局上下文
provide() {
return {
getFormConfig: () => this.formJsonObj.formConfig, // 全局配置
globalModel: { formModel: this.formDataModel }, // 数据模型
refList: this.widgetRefList, // 组件引用注册表
globalOptionData: this.optionData, // 外部选项数据
}
}
// 任意深层子组件 inject 即可
// grid-col-item → tab-pane-item → input-widget
// 不需要逐层传递 props
inject: ['getFormConfig', 'globalModel']
6.4 全架构图
ini
用户传入 JSON: { formConfig, widgetList }
│
├── 数据层
│ └── buildFormModel(widgetList)
│ → 用 traverseFieldWidgets 遍历所有叶子
│ → 建立 formDataModel = { name1: val1, name2: val2 }
│ → 注入为 provide 的 globalModel.formModel
│
├── 运行时渲染轨(设计器预览用)
│ └── form-render/index.vue
│ ├── <el-form :model="formDataModel">
│ ├── v-for widget in widgetList
│ │ ├── category='container' → <component :is="type+'-item'">
│ │ │ → 容器内部递归渲染自己的 widgetList
│ │ └── category='field' → <component :is="type+'-widget'">
│ │ → v-model 绑定到 formDataModel[name]
│ └── 组件通过 import.meta.globEager 自动注册
│
├── 代码生成轨(导出独立项目用)
│ └── sfc-generator.js
│ ├── genTemplate(widgetList) → <template> 部分
│ │ ├── containerTemplates[type] → grid/tab/table HTML
│ │ └── elTemplates[type] → el-input/el-select/... HTML
│ ├── genVue2JS(widgetList) → <script> 部分
│ │ └── traverseFieldWidgets → 提取 defaultValues/rules/options
│ └── genCSS() → <style> 部分
│
└── 扩展层
└── extension-loader
├── 注册 schema → widgetConfigs
├── 注册 renderer → app.component()
└── 注册 editor → 属性面板
横向对比:vform3 的路径选择 vs AMIS vs Formily
明白了 vform3 是怎么演化到当前版本的之后,我们来看另外两个知名方案在同样的问题上做了什么选择。
对比表:同一个问题,三种方案
| 演化阶段 | vform3 的选择 | AMIS 的选择 | Formily 的选择 |
|---|---|---|---|
| JSON 结构 | {type, category, options:{name,label,...}, widgetList} |
扁平化:{type, name, label, body} --- 属性直接在顶层 |
JSON Schema 标准:{type:"string", title, x-component, x-reactions} |
| 容器嵌套 | 多种容器各有路径:cols/tabs/rows[].cols |
统一路径:所有容器都用 body 数组 |
统一路径:properties 对象(key=字段名) |
| 数据绑定 | options.name + v-model="formModel[name]" |
自动:name 自动关联数据域 |
路径系统:"object.aaa.bbb" 点号访问 |
| 组件分发 | v-if category === 'container' → 容器 → 字段 |
type 对应注册的 React 组件 |
x-component 解耦------Schema 不关心谁渲染 |
| 代码生成 | ✅ SFC 生成器(核心卖点) | ❌ 不生成代码------始终运行时渲染 | ❌ 不生成代码------始终运行时渲染 |
| 表达式 | new Function() 运行 JS 字符串------最灵活也最危险 |
${xxx} 模板表达式------较安全 |
x-reactions 声明式联动------最安全 |
| 扩展机制 | schema + renderer + editor 三要素注册 | 注册 React 组件到工厂 | 注册 Component + 通过 x-component 引用 |
| 运行时依赖 | 有(双轨制:可带可不带) | 必须带 | 必须带 |
vform3 的差异化优势
vform3 在整个生态中唯一做代码生成的方案。这个选择的价值:
- AMIS/Formily:用户设计的页面永远是 JSON,描述和运行不分离
- vform3:用户设计完可以导出为独立的 Vue 文件,放进任何项目,零框架依赖
代价是:vform3 必须为代码生成单独维护一套 elTemplates 策略对象,且这套对象和运行时渲染必须保持一致。这是一份维护成本。
三种方案的适用场景
javascript
vform3 → 表单设计器 + 导出独立 Vue 项目(设计一次,到处用)
AMIS → 后台管理系统快速搭建(JSON 即页面,永远在线渲染)
Formily → 复杂表单场景(联动、校验、跨字段依赖),追求最大灵活性和规范性
总结:JSON 页面引擎的演化规律
回顾整个旅程,每一步都是被前一步的问题逼出来的:
vbnet
v0: "UI 是一棵组件树,用 type + props + children 就能完整描述它"
v1: "把这棵树变成 HTML"
问题:没有数据绑定
v2: "要能收集用户输入"
问题:不能嵌套布局
v3: "布局也是组件,要能递归嵌套"
问题:太重,不能脱离设计器
v4: "编译成独立 Vue 文件,零运行时依赖"
问题:不能在设计器里预览;加新组件要改源码
v5: "让用户不碰源码也能加自定义组件"
问题:运行和代码两套逻辑分裂
v6: "运行和代码生成共享定义,遍历逻辑统一"
状态:当前版本
6 个通用设计原则
从这段演化中可以提炼出 JSON 页面引擎的不变规律:
1. 组件节点必须是递归的
ComponentNode = { type, options, children } ------ 容器套容器是刚需。但嵌套路径可以"统一"(AMIS 的 body)也可以"多样"(vform3 的 cols/tabs),取决于你对布局表达力的要求。
2. 数据模型构建和 UI 渲染是两件事
先遍历 JSON 建立响应式数据槽位(formModel),再渲染 UI 把槽位和各表单控件绑定。这两步解耦后,修改数据槽位 = 自动更新 UI(响应式框架的价值)。
3. 策略对象 > if/else 链
elTemplates = { input: fn, select: fn, ... } 是组件类型和渲染逻辑之间的解耦层。加新类型 = 加一个策略函数,不改现有代码。
4. 通用遍历器是引擎的脊柱
traverseFieldWidgets + traverseContainerWidgets ------ 所有后续功能(数据模型、校验规则、选项数据、代码生成)都是这两个遍历器的 handler 变体。没有它们,每种功能都要自己写遍历逻辑。
5. 代码生成 vs 运行时渲染不是二选一,可以双轨
vform3 最大的架构洞察:两套方案可以共存,且共享组件定义层。设计器用运行时轨,导出用代码生成轨,两边看到的效果完全一致。
6. 扩展点 = Schema + Renderer + Editor 三项分离
组件开发者和引擎开发者解耦。引擎只需提供注册接口,组件开发者只需提供三样东西。
附录:各版本代码量统计
| 版本 | 核心代码量 | 新增的关键概念 |
|---|---|---|
| v0 | --- | JSON 协议定义:type + props + children |
| v1 | ~30 行 | 递归渲染:JSON 树 → HTML 字符串 |
| v2 | +20 行 | 数据模型(formModel)、响应式绑定 |
| v3 | +30 行 | category(container/field)、递归渲染 |
| v4 | +50 行 | 策略对象(elTemplates)、SFC 代码生成 |
| v5 | +40 行 | schema/renderer/editor 三项分离、注册机制 |
| v6 | +30 行 | 通用遍历器、双轨整合、provide/inject |
| 合计 | ~200 行核心 | → 演化出 vform3 的完整架构 |
这 200 行是"核心逻辑"------vform3 源码总共有数千行,但大部分是具体组件的实现(每个 input-widget.vue、select-widget.vue 各自有几百行),和 30 种组件类型的属性定义。核心引擎的骨架始终是这 200 行。