前端页面引擎协议:由浅入深——从 30 行到 vform3 的演化之路

前端页面引擎协议:由浅入深------从 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)

这棵树的每个节点有三个特征:

  1. 它是什么 ------ 类型(Input? Button? Form?)
  2. 它有什么属性 ------ 配置(label 是什么?placeholder 是什么?宽高多少?)
  3. 它包含什么 ------ 子节点(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 使得结构可以任意嵌套

Formchildren[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[].colswidgetList 只是其中一种子节点路径。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)是硬编码在源码里的。如果某个用户想要一个"地图选点"组件,他必须:

  1. 改 vform3 源码,在 elTemplates 里加函数
  2. 改设计器源码,在组件面板里加入口
  3. 改属性编辑器源码,加配置面板

这不可能规模化。

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.vueselect-widget.vue 各自有几百行),和 30 种组件类型的属性定义。核心引擎的骨架始终是这 200 行。

相关推荐
学网安的肆伍1 小时前
【044-WEB攻防篇】PHP应用&SQL盲注&布尔回显&延时判断&报错处理&增删改查方式
前端·sql·php
八号当铺1 小时前
从 Prompt 到 AI 工程化:理解 Rules、Skills 与 Agent
前端·ai编程·cursor
didadida2621 小时前
子路径部署 Vue/React 应用偶发白屏
前端·后端
invicinble1 小时前
前端框架使用vue-cli (第五层:构建打包层--总体层介绍)
前端·vue.js·前端框架
前端那点事2 小时前
Vuex刷新数据丢失?4种持久化方案全覆盖,从零到项目落地(实战完整版)
前端·vue.js
Cerrda2 小时前
性能提升 satisfying!一个 Vue3 指令干掉页面上 200 个无用 Tooltip 实例
前端·设计
漫游的渔夫2 小时前
前端开发者做 AI Agent:别只渲染答案,用 7 个状态接住确认、错误和 trace
前端·人工智能·typescript
clove2 小时前
从 LLM 到 Agent:一篇文章课带你彻底搞懂 AI 智能体的核心逻辑
前端
前端那点事2 小时前
彻底吃透JS定时器!setTimeout/setInterval区别、坑点与最优优化方案(Vue实战)
前端·vue.js