跪拜 Guibai
← Back to the summary

Schema-Driven Virtual Tables: Bringing Element Plus TableV2 Under a Single Config


theme: channing-cyan

Schema-Driven Programming: Easily Handle Complex Element Virtual Table Scenarios

Hello everyone, I'm Full-Stack Xiao Juan. Earlier I wrote JuanForm vs FormCreate! Vue3 Form Engine Linkage Configuration PK!. FormCreate targets standalone small forms, covering multiple UIs on PC and mobile; JuanForm targets page-level configuration for PC admin panels, initially integrating Element Plus.

Admin pages are configured with schema; developers and AI only need to provide configuration specs, no longer dealing with Vue and UI component details. With a rendering engine, complex pages can be written according to schema.

JuanForm aligns with Vue's reactivity and watchers, balancing performance for large forms and complex pages, while retaining extension points. Third-party UI features can be plugged into the engine and exposed via schema.

This article uses Element Plus's virtualized table TableV2 as an illustration. Virtual scrolling, variable row heights, tree expansion, and custom cells must be configured separately on TableV2, and business logic also layers on dialogs and add/delete operations. Below we take an insurance beneficiary list and write these capabilities as schema. Dictionaries with even larger data volumes apply as well.

Basic Table

Page includes:

<script setup lang="ts">
import JuanForm from '@/form/JuanForm.vue'
import { vTableSchema } from '@/views/vTableSchema.ts'
</script>

<template>
  <JuanForm :schema="vTableSchema" />
</template>

vTableSchema.ts first writes so the table displays. The virtual table itself is also a field, type is VTable, columns are written in columns, each column still has prop, label, type. Use a button to simulate a data request, writing 5000 records into contacts.

import '@/form/builtin.ts'
import type { FormSchema, ScopeState } from '@/form/types.ts'

const genderOptions = [
  { label: '男', value: '0' },
  { label: '女', value: '1' },
]

export const vTableSchema: FormSchema = {
  children: [
    {
      items: [
        {
          label: '加载数据',
          type: 'Button',
          col: 3,
          props: {
            nativeType: 'button',
            onClick: {
              handle: (scope: ScopeState) => {
                const names = ['张三', '李四', '王五']
                scope.contacts = Array.from({ length: 5000 }, (_, i) => ({
                  id: `g${i + 1}`,
                  name: names[i % 3],
                  phone: String(13800000000 + i + 1),
                  gender: i % 2 === 0 ? '0' : '1',
                  age: 20 + (i % 40),
                }))
              },
            },
          },
        },
      ],
    },
    {
      items: [
        {
          prop: 'contacts',
          label: '受益人',
          type: 'VTable',
          key: 'id',
          columns: [
            {
              prop: 'id',
              label: 'id',
              type: 'Text',
              width: 120,
            },
            {
              prop: 'name',
              label: '姓名',
              type: 'Input',
              width: 160,
            },
            {
              prop: 'gender',
              label: '性别',
              type: 'Radio',
              width: 120,
              props: { options: genderOptions },
            },
            {
              prop: 'age',
              label: '年龄',
              type: 'Input',
              width: 80,
              props: { type: 'number' },
            },
            {
              prop: 'phone',
              label: '电话',
              type: 'Input',
              width: 180,
            },
          ],
        },
      ],
    },
  ],
}

Click load data, scroll the list, the viewport always shows a fixed number of rows:

test10.gif

The virtual list only renders the DOM in the viewport. When scrolling, the number of nodes stays constant, content swaps to new rows. Editing content in a cell updates the data model below:

test11.gif

Grouped Headers

Data doesn't change, only columns. Group columns only have label and children, they don't enter the data model; only the innermost column with prop does. children can nest further for arbitrary levels, below we demo one, two, and three levels. Other column configs are the same as the previous section, we only show the structure.

One-level Nesting

Put gender, age, phone under Personal Info, the header gets an extra row:

columns: [
  { prop: 'id' },
  { prop: 'name' },
  {
    label: '个人信息',
    children: [
      { prop: 'gender' },
      { prop: 'age' },
      { prop: 'phone' },
    ],
  },
]

The header becomes: gender, age, phone under Personal Info.

20261106170008.png

Two-level Nesting

Next, add a layer called Basic under Personal Info, put gender and age under Basic, phone remains under Personal Info:

columns: [
  { prop: 'id' },
  { prop: 'name' },
  {
    label: '个人信息',
    children: [
      {
        label: '基本',
        children: [
          { prop: 'gender' },
          { prop: 'age' },
        ],
      },
      { prop: 'phone' },
    ],
  },
]

The header becomes: Basic under Personal Info contains gender and age, phone remains under Personal Info.

20261106170305.png

Three-level Nesting

Third level uses pairwise combination: put name and gender into a group named 姓名, don't put only one field in a group. The syntax is still label plus children, no new options. Adding more levels continues nesting children, with no upper limit:

columns: [
  { prop: 'id' },
  {
    label: '个人信息',
    children: [
      {
        label: '基本',
        children: [
          {
            label: '姓名',
            children: [
              { prop: 'name' },
              { prop: 'gender' },
            ],
          },
          { prop: 'age' },
        ],
      },
      { prop: 'phone' },
    ],
  },
]

The header becomes: name and gender in one group, age as a single column.

20261106171019.png

The width in columns is the proportion allocated to each column in the layout; the table stretches columns to fill the current window. When the window narrows, cells can wrap, and row height follows the content. Virtual scrolling calculates each row height based on variable content. Resize the window to see wrapping and row height:

test12.gif

Hidden Columns

Write hidden on a column, same syntax as form fields. When the function returns true, the column no longer displays, and the field is also removed from the data model.

The age column reads the current row's data, the switch hideAge is on the form, so write scope.$parent?.hideAge:

{
  prop: 'hideAge',
  label: '隐藏年龄',
  type: 'Switch',
}

{
  prop: 'age',
  hidden: (scope) => scope.$parent?.hideAge === true,
}

When the switch is turned on, the age column disappears from header through data, and the age field also no longer appears in the data model. Age was originally grouped under Basic with name; when a column is removed, Basic only has one group left, which can't form a group, so the Basic group header is dismantled, and the Personal Info directly contains name and phone. Whether or not there is data, the header changes accordingly. Turn on hide age, watch the header and data model:

test13.gif

Custom Columns

The operation column is for add/delete/edit. JuanForm also supports custom HTML and components inside cells, we use name splitting as an example.

Name Split Edit Column

Earlier the name column was the default editable column, config only had prop, label, type:

{ prop: 'name', label: '姓名', type: 'Input' }

The cell contains an input box, directly mapping to the name in the data model.

For a custom edit column, still use the same name column, keep prop, label, type, and add a custom property. With custom, the cell no longer renders the outer Input, instead renders the content in custom.items. The data model still only recognizes name; the first and last name inside items stay within the cell, they don't become separate fields on the row data.

The cell needs two inputs: left first name, right last name, with an Html separator in between. Data is loaded as a whole name string, so the two inputs won't auto-fill. Add a dependsOn block: read this row's name, split by space, write to firstName and lastName. After load, the two inputs get content.

{
  prop: 'name',
  label: '姓名',
  type: 'Input',
  custom: {
    items: [
      {
        type: 'Html',
        el: 'div',
        children: [
          {
            items: [
              { prop: 'firstName', label: '名', type: 'Input' },
              { type: 'Html', el: 'span', text: '-' },
              { prop: 'lastName', label: '姓', type: 'Input' },
            ],
          },
        ],
      },
    ],
    dependsOn: {
      handle: (scope) => {
        const name = `${scope.$parent?.name ?? ''}`
        const i = name.indexOf(' ')
        scope.firstName = i < 0 ? name : name.slice(0, i)
        scope.lastName = i < 0 ? '' : name.slice(i + 1)
      },
    },
  },
}

Earlier the loaded data used 张三, 李四, 王五, which have no spaces, so splitting by space yields nothing. Change names to include spaces; after splitting, the left gets 张 and the right gets 三:

const names = ['张 三', '李 四', '王 五']

Click load data, the name column shows two inputs with 张 and 三 respectively:

test14.gif

At this point, editing the two inputs doesn't update the name in the data model. Editing needs another block: inject schema, concatenate first and last name back into a string, and setValue on this column. If both are empty, write an empty string; otherwise insert a space in the middle.

dependsOn: [
  ..., // first dependsOn: split name into firstName and lastName by space, same as above
  {
    inject: ['schema'],
    handle: (scope, schema) => {
      const left = `${scope.firstName ?? ''}`
      const right = `${scope.lastName ?? ''}`
      schema.setValue(left === '' && right === '' ? '' : `${left} ${right}`)
    },
  },
]

Editing first or last name updates the name in the data model accordingly:

test15.gif

Operation Column

The name-split column has prop: 'name', so the data model expects that column's value. The operation column has no prop and no type, only label and custom. The cell contains only buttons, and this column doesn't add a field to the data model.

First add delete. The button injects list, uses current row's $index to delete that row from the array. After deletion, both the table and the data model lose that entry.

columns: [
  ..., // previous id, Personal Info columns, same as above
  {
    label: '操作',
    custom: {
      items: [
        {
          label: '删除',
          type: 'Button',
          props: {
            nativeType: 'button',
            onClick: {
              inject: ['list'],
              handle: (scope, list) => {
                list.delete(scope.$index)
              },
            },
          },
        },
      ],
    },
  },
]

Click delete, the row disappears from both the table and the data model:

test16.gif

Next add edit. Editing doesn't happen in the cell, it opens a dialog. The button injects dialog, open writes the dialog's name. The dialog and the table are on the same level, not written on the column. The dialog's fields for name, phone, gender are the same as the earlier column fields, after confirming they write back to the row.

{
  items: [
    {
      prop: 'contacts',
      type: 'VTable',
      // rest of table config same as above, only columns and the dialog on the same level are shown
      columns: [
        ..., // previous id, Personal Info columns, same as above
        {
          label: '操作',
          custom: {
            items: [
              {
                label: '编辑',
                type: 'Button',
                props: {
                  nativeType: 'button',
                  onClick: {
                    inject: ['dialog'],
                    handle: (scope, dialog) => {
                      dialog.open('editContact')
                    },
                  },
                },
              },
              ..., // delete button: inject list, delete row by $index, same as above
            ],
          },
        },
      ],
    },
    {
      name: 'editContact',
      type: 'Dialog',
      label: '编辑受益人',
      children: [
        {
          items: [
            { prop: 'name', label: '姓名', type: 'Input' },
            { prop: 'phone', label: '电话', type: 'Input' },
            { prop: 'gender', label: '性别', type: 'Radio' },
          ],
        },
      ],
    },
  ],
}

Click edit opens the dialog, after confirming the row is written back:

test17.gif

When name is empty, don't allow edit; same hidden as the earlier hidden column. Write it on the edit button: if name has no value, return true, this column only has delete.

{
  label: '编辑',
  type: 'Button',
  hidden: (scope) => !scope.name,
  ..., // rest props, onClick opens editContact dialog, same as above
}

Clear the name, the edit button disappears:

test18.gif

mode Switch

Earlier the table defaulted to control mode: name, gender, phone were input boxes and editable. Add a switch, when turned on the data columns become plain text, when off they return to controls. The operation column is unaffected, edit and delete remain.

The switch and load data button are on the same level. Write mode on the table: if tableText is true, it's 'text', otherwise 'control'.

{
  items: [
    {
      label: '加载数据',
      type: 'Button',
      ..., // click writes contacts, same as above
    },
    {
      prop: 'tableText',
      label: '文本',
      type: 'Switch',
    },
  ],
}

{
  prop: 'contacts',
  type: 'VTable',
  mode: (scope) => (scope.tableText === true ? 'text' : 'control'),
  ..., // rest key, columns, dialog on same level, same as above
}

Turn on the text switch, data columns become text, operation column remains buttons; turn off, back to inputs:

test19.gif

Text Column Formatting

In control mode, the phone is still an input box, editing changes the number in the data model. When the text switch is on, this column goes through formatter, you can compose the display text, the number itself stays unchanged.

First add a country code selector on the same level as load data. The phone column gets a formatter: read phonePrefix from the row's $parent, prepend it to the number. If no prefix, show only the number.

{
  items: [
    ..., // load data, hide age, text switch, same as above
    {
      prop: 'phonePrefix',
      label: '区号',
      type: 'Select',
      defaultValue: '+86',
      props: {
        options: [
          { label: '中国 +86', value: '+86' },
          { label: '美国 +1', value: '+1' },
          { label: '英国 +44', value: '+44' },
        ],
      },
    },
  ],
}

{
  label: '个人信息',
  children: [
    ..., // basic, name groups, same as above
    {
      prop: 'phone',
      label: '电话',
      type: 'Input',
      formatter: (row, _col, cellValue) => {
        const prefix = row.$parent?.phonePrefix
        const phone = cellValue ?? ''
        return prefix ? `${prefix} ${phone}` : String(phone)
      },
    },
  ],
}

Turn on text switch, then switch country code, the prefix on the phone column changes, while the phone in the data model remains the original number:

test20.gif

Variable Row Height

Earlier wrapping happened when cells narrowed and content wrapped, changing row height. This column places an HTML block with very different lengths, row height follows the content. type is HtmlText, the cell renders as HTML, not affected by the text switch above.

When loading data, give each row a desc, rotating three rows: short one line, medium three lines, long six lines. Add the column after Personal Info and before the operation column.

handle: (scope) => {
  const names = ['张 三', '李 四', '王 五']
  scope.contacts = Array.from({ length: 5000 }, (_, i) => ({
    id: `g${i + 1}`,
    name: names[i % 3],
    phone: String(13800000000 + i + 1),
    gender: i % 2 === 0 ? '0' : '1',
    age: 20 + (i % 40),
    desc:
      i % 3 === 0
        ? '<span>短描述</span>'
        : i % 3 === 1
          ? '<p>中等描述</p><p>第二行</p><p>第三行</p>'
          : '<p>较长描述,在这一列里会折行。</p><p>再来一段。</p><p>第三段。</p><p>第四段。</p><p>第五段。</p><p>第六段,比中等高一截。</p>',
  }))
}

columns: [
  ..., // previous id, Personal Info columns, same as above
  {
    prop: 'desc',
    label: '描述',
    type: 'HtmlText',
    width: 280,
  },
  ..., // operation column, same as above
]

While scrolling, it first reserves space with an estimated height, then adjusts after measuring the actual height. Default is 40. Since the description column is generally taller, raise estimatedRowHeight in this VTable's props, the header height is unaffected.

{
  prop: 'contacts',
  type: 'VTable',
  props: { estimatedRowHeight: 80 },
  ..., // rest mode, columns, dialog on same level, same as above
}

Click load data, look at the description column: short rows are short, long rows are tall, deletion doesn't affect:

test21.gif

In the dialog, also add a description field, edit HTML and write back to the row, row height changes accordingly.

{
  name: 'editContact',
  type: 'Dialog',
  label: '编辑受益人',
  children: [
    {
      items: [
        ..., // name, phone, gender, same as above
        {
          prop: 'desc',
          label: '描述',
          type: 'Textarea',
          props: { placeholder: '请输入 HTML' },
        },
      ],
    },
  ],
}

Click edit, change the description, confirm, the row height changes:

test22.gif

Row Nesting

Earlier all rows were flat. When a row carries a children array, they are child rows. The table writes expandColumnKey: 'id', the id column gets expansion arrows. Load becomes a tree: 20 groups, each with 9 subitems, each subitem with 10 entries.

{
  prop: 'contacts',
  type: 'VTable',
  expandColumnKey: 'id',
  ..., // rest mode, props, columns, dialog on same level, same as above
}

handle: (scope) => {
  const names = ['张 三', '李 四', '王 五']
  const row = (id, i) => ({
    id,
    name: names[i % 3],
    phone: String(13800000000 + i + 1),
    gender: i % 2 === 0 ? '0' : '1',
    age: 20 + (i % 40),
    ..., // desc long/short HTML, same as above
  })
  let n = 0
  scope.contacts = Array.from({ length: 20 }, (_, g) => ({
    ...row(`g${g + 1}`, n++),
    children: Array.from({ length: 9 }, (_, p) => ({
      ...row(`g${g + 1}-${p + 1}`, n++),
      children: Array.from({ length: 10 }, (_, c) =>
        row(`g${g + 1}-${p + 1}-${c + 1}`, n++),
      ),
    })),
  }))
}

The child row's $parent is the parent row, not the form. The country code is on the form, so the phone formatter must read from $root, the prefix still shows after expanding.

formatter: (row, _col, cellValue) => {
  const prefix = row.$root?.phonePrefix
  const phone = cellValue ?? ''
  return prefix ? `${prefix} ${phone}` : String(phone)
}

Click the id column's expansion arrow to see child rows. Turn on text switch, child row phones still show the country code:

test23.gif

Hierarchical Linkage Extension

After expanding, you want to see the current level. Add a level column, type is Text, value computed with expression: count up from this row through $parent until hitting $root. Top level is 1, child rows are 2, deeper rows are 3. This column isn't edited, the expression derives it.

Place it after the id column.

columns: [
  ..., // id column, same as above
  {
    prop: 'level',
    label: '层级',
    type: 'Text',
    width: 80,
    expression: (scope) => {
      let n = 1
      let p = scope.$parent
      while (p && p !== scope.$root) {
        n++
        p = p.$parent
      }
      return n
    },
  },
  ..., // other columns, same as above
]

test24.gif

Adding Nodes

To make node editing more convenient later, first use the form engine's component extension capability to connect a recognition dialog. Paste a piece of text, the current row's name, phone, gender, age, description are written back at once.

Earlier the edit used the built-in Dialog, where fields are edited one by one. Recognition is not a general capability, the format is defined by this page itself, so it's not written into the engine, but in user config using createInput to inherit Dialog, extending it to Fill. The main body is replaced with a large text area; on confirm, split by spaces, write into the current row in order, then call the parent's original write-back. At the bottom place a clear button, use $base: 'footer' to bring back the confirm button.

createInput('Fill', {
  extends: 'Dialog',
  slots: {
    default: {
      $el: 'div',
      attrs: { class: 'w-full' },
      children: [
        [
          {
            prop: 'ocrText',
            label: '粘贴文本',
            type: 'Textarea',
            col: 'auto',
            props: {
              placeholder: '张 三 13800138000 0 28 <p>描述</p>',
              class: 'min-lg',
            },
          },
        ],
      ],
    },
    footer: {
      $el: 'div',
      children: [
        [
          {
            label: '清空识别',
            type: 'Button',
            col: 6,
            props: {
              nativeType: 'button',
              onClick: {
                handle: (scope) => {
                  scope.ocrText = ''
                  scope.name = ''
                  scope.phone = ''
                  scope.gender = ''
                  scope.age = ''
                  scope.desc = ''
                },
              },
            },
          },
        ],
        { $base: 'footer' },
      ],
    },
  },
  methods: {
    flushData() {
      const parts = (this.state.ocrText as string).trim().split(/\s+/)
      this.state.name = `${parts[0]} ${parts[1]}`
      this.state.phone = parts[2]
      this.state.gender = parts[3]
      this.state.age = parts[4]
      this.state.desc = parts.slice(5).join(' ')
      this.parent.flushData!()
    },
  },
})

Add a recognition button to the operation column, open it the same way as edit: inject dialog, open writes the dialog's name. The dialog and the table are on the same level, type is Fill. The main body already comes from the extension, children is empty for now.

{
  label: '操作',
  custom: {
    items: [
      ..., // edit button: inject dialog, open editContact, same as above
      {
        label: '识别',
        type: 'Button',
        props: {
          nativeType: 'button',
          link: true,
          type: 'primary',
          onClick: {
            inject: ['dialog'],
            handle: (_scope, dialog) => {
              dialog.open('fill')
            },
          },
        },
      },
      ..., // delete button: inject list, delete row by $index, same as above
    ],
  },
}

{
  name: 'fill',
  type: 'Fill',
  label: '识别填写',
  children: [],
}

Click recognition, paste the following text, confirm, the current row's name, phone, gender, age, description are all written. Gender 0 is male, 1 is female.

张 三丰 13800138000 0 28 <p>描述</p>

test25.gif

Add Top-level Node

Place an add button above the table, inject list, call add, append an empty record to the end of the array.

{
  items: [
    {
      label: '新增',
      type: 'Button',
      col: 3,
      props: {
        nativeType: 'button',
        onClick: {
          inject: ['list'],
          handle: (_scope, list) => {
            list.add({})
          },
        },
      },
    },
  ],
}

Click add, an empty row appears at the bottom of the table and scrolls to it. The name is empty, use the earlier recognition to fill the fields.

test26.gif

Add Child Node

Add another add button to the operation column. Top-level adds call list.add, child nodes append an empty record to the current row's children. No existing node structure needs changing.

{
  label: '操作',
  custom: {
    items: [
      ..., // edit button: inject dialog, open editContact, same as above
      {
        label: '新增',
        type: 'Button',
        props: {
          nativeType: 'button',
          link: true,
          type: 'success',
          onClick: {
            handle: (scope) => {
              scope.children.push({})
            },
          },
        },
      },
      ..., // recognition button: inject dialog, open fill, same as above
      ..., // delete button: inject list, delete row by $index, same as above
    ],
  },
}

Click add on a row, an empty child row appears below that row and scrolls to it. If the parent isn't expanded, it expands first then scrolls. Fill the empty row with the earlier recognition.

Add a second level on a newly created top-level node:

test27.gif

Add a child row to an existing, unexpanded parent:

test28.gif

Final Words

TableV2's virtual scrolling, variable heights, tree expansion, and custom cells need separate configuration, and business logic layers on dialogs, recognition, and add/delete operations. This article wires these capabilities into JuanForm: the table acts as a field, columns still use prop, label, type. Grouped headers, hidden columns, custom editing, operation columns, text switch, variable heights, row nesting, depth, and adding all use the same schema. The page no longer needs to assemble TableV2 configuration separately. Page-specific formats, like recognition, can extend the dialog through createInput without modifying the engine.

Other virtualized scrolling scenarios can also be configured via schema. Developers and AI follow the configuration specs, without handling Vue and UI component details.