B 端数据表格封装 Vue 3 实践之:筛选区-简单布局实现(闲聊文加点干货)

570 阅读14分钟

本文主要介绍如何使用 Vue 3 结合 Element Plus 来实现 B 端常见的数据表格页面中筛选部分的实现,如何封装 ElForm 及其表单控件,基于什么原因、为什么这么做?希望能帮助新人打开思路,一起成长。表格部分并不打算去对 ElTable 做二次封装,而是选择使用最流行的 @tanstack/vue-table 这个 Headless 库去做封装以满足 UI 和性能方面的考量。

关于 B 端这种页面的产品界面设计模式我就不献丑了,请看我后面的参考链接部分。这里我们首先将场景限制在使用多列的方式来分布表单控件,如下图:

Pasted image 20240921230155.png

这种布局有以下特点:

  • 标签文本通常固定宽度(以最长标签文本长度为参考),表单控件宽度伸缩。
  • 列通常是设定好的,内容会水平伸缩占据可用空间。
  • 标签文本可位于表单控件的左侧或者上方。
  • 标签广西位于控件左侧时可以右对齐、分散对齐以及根据文本宽度伸缩调节控件两者之前的宽度。
  • 功能性按钮通常位于控件最后一行(不满行时)或者另起一行的右下角区域。
  • 内容较多时,有“展开”和“收起”功能。

常用布局方式实现分析

要实现上面的功能,我们有很多方式,但是每一种方式各有利弊,最终采用的是强大的 Grid 布局来实现。

后续 CSS 示例我们将使用以下的模板来演示,其它部分省略。

<template>
  <div class="wrapper">
    <el-form :model="forms" label-width="100px">
      <el-form-item>
        <template #label> 姓名 </template>
        <el-input v-model="forms.name"></el-input>
      </el-form-item>
      <el-form-item label="年龄">
        <el-input v-model="forms.age"></el-input>
      </el-form-item>
      <el-form-item label="邮箱">
        <el-input v-model="forms.email"></el-input>
      </el-form-item>
      <el-form-item label="密码">
        <el-input v-model="forms.password"></el-input>
      </el-form-item>
      <el-form-item label="确认密码">
        <el-input v-model="forms.confirmPassword"></el-input>
      </el-form-item>
      <el-form-item label="开始日期">
        <el-date-picker v-model="forms.startAt" type="date" />
      </el-form-item>
      <el-form-item label="结束日期">
        <el-date-picker v-model="forms.endAt" type="date" />
      </el-form-item>
      <el-form-item label="">
        <el-button type="primary">查询</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

1. 浮动布局

在 Flex 和 Grid 布局没有出现之前,使用浮动布局来实现这种多列布局是最佳选择。浮动布局通常需要应对父容器高度塌陷问题以及对后续节点的影响(需要清除浮动)。要实现按多少列的宽度来展示控件,我们需要去算出比例,例如以三列显示内容,每列间隔 16px (使用 margin-left: 8px; margin-right: 8px; 实现),则需要使用 width: calc(33.33% - 16px) 来得到每列的宽度。同时因为右则多了 16px,需要在父容器中使用 margin-left: -8px; margin-right: -8px; 的方式来收缩。对于功能按钮所在的 .el-form-item,我们只需要单独给它 float: right 即可。下面是完整实现:

.wrapper {
  width: 1280px;
  margin: 0 auto;
  border: solid 1px #ccc;
  overflow: hidden;
  margin-top: 20px;
  padding: 20px;
  padding-bottom: 0;
}

.el-form {
  margin-left: -8px;
  margin-right: -8px;
}

.el-form-item {
  float: left;
  margin-left: 8px;
  margin-right: 8px;
  width: calc(33.33% - 16px);
}

.el-form-item:nth-last-child(1) {
  float: right;
}

.el-date-editor.el-input {
  width: 100%;
}

效果如下:

Pasted image 20240922000221.png

可以看到这种方式侵入性比较强,如果进一步封装的话需要设置的动态数据较多,此外,还需要考虑到对后续元素的影响,我们需要在 .el-form 加上一个伪元素来清除浮动。

.el-form::after {
  content: '';
  clear: both;
  display: table;
}

可能关于浮动实现方式到这里就结束了,但是我们忽略了负边距带来的副作用,如果我们分别给 .wrapper.el-form 加上背景,并为 <el-form> 添加一个兄弟元素 <p> 内容为 lorem...,会发现后面的元素和边界视觉上就会差 16px 的距离,这对于不添加背景色的布局没有什么影响,但是一但有这方面的需求,后续元素都要为前面的负边距买单。

Pasted image 20240922112400.png

2. 使用 Flex 布局

Flex 布局是在开发过程中使用频率最高的布局方式,实在太好用了无处不在。这里我们也可以使用它来实现多列布局,但是也会存在浮动布局负边距带来的副作用。

使用 Flex 布局我们在处理列之间的间距时有二种方式:一是使用 column-gap: 16px;二是使用和浮动布局一样的 margin-left: 8px; margin-right: 8px;。默认情况下容器中的项目是会自动分配可用空间,我们需要为 .el-form-item 同样指定宽度(根据所需要的列数来确定),同时设置 flex-wrap: wrap; 来强制换行。

一)使用外边距来设置列间隔

下面是实现代码:

.wrapper {
  width: 1280px;
  margin: 0 auto;
  border: solid 1px #ccc;
  overflow: hidden;
  margin-top: 20px;
  padding: 20px;
  padding-bottom: 0;
  background-color: #f5f5f5;
}

.el-form {
  display: flex;
  flex-wrap: wrap;
  margin-left: -8px;
  margin-right: -8px;
  background: #e4e4e4;
}

.el-form-item {
  width: calc(33.33% - 16px);
  margin-left: 8px;
  margin-right: 8px;
}

.el-form-item:last-child {
  align-self: flex-end;
}

.el-date-editor.el-input {
  width: 100%;
}

除了有负边距的副作用外,这里还有一点值得注意:「功能性按钮」所在的 <el-form-item> 节点内容始终是位于最后一行末尾或者空行第一列。如果你原本就是想把按钮放在左边倒是省事没有什么问题,如果是放在最右边就得考虑将「功能性按钮」脱离 <el-form-item> 或者我们直接动态在其前面补上空的 <el-form-item> 节点,但这属于缝缝补补又三年,一直打补丁的节奏。

二)使用 column-gap 来设置列间隔

实现方式如下:

.wrapper {
  width: 1280px;
  margin: 0 auto;
  border: solid 1px #ccc;
  overflow: hidden;
  margin-top: 20px;
  padding: 20px;
  padding-bottom: 0;
  background-color: #f5f5f5;
}

.el-form {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  column-gap: 16px;
  background: #e4e4e4;
}

.el-form-item {
  width: calc(33.33% - 16px);
}

.el-form-item:last-child {
  align-self: flex-end;
}

.el-date-editor.el-input {
  width: 100%;
}

这种方式有效地避开了负边距所带来的副作用,但是又产生了一个新的问题:同样是「功能性按钮」所在的 <le-form-item> 无法达到期望的位置,设置的列数量越多,越明显,这里就不解释了。

从上面的分析来看,Flex 并不适合这种特殊多列布局的场景。

3. 使用 Columns 布局

这种方式可能很多人没有接触过,它在这里确定也不合适,因为它处理数据列表的顺序是先列后行,结果根本就不是你想要的。虽我们可以调整顺序来适配,但是又存在很多问题,比如你想展示 5 列,但实际效果是第 5 列根本就没有数据,还有一个限制是 columns 的值只能是确切的单位,你不能使用 33.33%calc() 去动态算。

4. 使用 UI 框架提供的 <el-row>

既然是自己封装,我们没有必要脱了裤子去放屁,还有一点就是这个是一套基于 24 设定的网格布局模式,所以对于基数列并不能很好地处理,达不到期望的效果。由于在 <el-form> 内加了一层 <el-row> ,虽然 <el-row> 是基于 Flex 布局实现的,在处理列间距时并没有出现负边距的副作用。

如果只考虑偶数列的情况下,使用 <el-row> 在处理「功能性按钮」区域时,可以使用 <el-col>span , pullpush 来调整占据的格数。

5. 使用 Grid 布局

Grid 布局功能非常强大,知识点较多,学习并掌握并非一件容易的事件,需要付出一定的精力和时间。我们这里只涉及到一些 Grid 布局的简单功能,就可以完美实现需求:

.wrapper {
  width: 1280px;
  margin: 0 auto;
  border: solid 1px #ccc;
  overflow: hidden;
  margin-top: 20px;
  padding: 20px;
  padding-bottom: 0;
  background-color: #f5f5f5;
}

.el-form {
  display: grid;
  column-gap: 16px;
  grid-template-columns: repeat(3, 1fr);
  background: #e4e4e4;
}

.el-form-item:last-child {
  grid-column: 2 / 4;
}

.el-form-item:last-child .el-form-item__content {
  display: flex;
  justify-content: flex-end;
}

.el-date-editor.el-input {
  width: 100%;
}

效果如下:

Pasted image 20240922142838.png

最终效果很不错,也不需要我们自己去算元素的宽度,我们还可以使用 repeat(3, minmax(200px, 1fr)) 来设置最小宽度。使用 grid-column 可以很好的处理「功能性按钮」位置问题。

功能点思考

既然目的是封装表单功能,我们期望能够直接使用一个配置对象数组就把所有功能生成出来。这里需要考虑包含所有的表单功能,当然由于其功能的特殊性,我们不用考虑复杂的关联关系验证逻辑,这里甚至都不需要验证逻辑,因为每一个控件都是一个过滤条件,都是原子条件。

由于功能特殊性,我们不用考虑表单的 disabledreadonly 属性。这里我们实现的是一个名为 table-form-renderer 的组件,至少需要一个配置参数 content。之所以选择取这个名字:一方面是参考别的库是这样子做的;另一方面它更加贴切。在变量命名时,通常习惯用 data 表示纯数据、config 表示配置对象、options 表示数据选择列表以及 list 通常用来表示列表数据,这里的 content 内容不仅包含了配置数据,也包含了渲染函数。

因为 <el-form> 提供了标签位置配置 label-position 属性,我们对标签做等宽处理时需要考虑到其值为 top 时是不需要做处理的。

标签的最大宽度是以配置项中标签 label 最长的文本为依据的,但这里我们会遇到用户自定义标签渲染函数的情况,所以需要提供一个 width 属性来调整最大宽度。标签名称默认值为字符串,我们需要为其提供一个全局的渲染函数 render(),同时提供单个标签的渲染函数 renderLabel(),三者的优先级依次变高。

布局方面我们可以指定列数,最大最小宽度,行与列间距。对于表单支持的控件,我们提供一个 type 属性,默认为值为 input,其它根据需要去定义。

「功能性按钮」区域我们为其提供一个插槽 action,并依据以下规则来设置:如果最后一行数据列未满,则放在最后一列(合并前面的空白列);如果刚好满一行,则换行(合并所有空白列)。如果用户在不满一行时想强制换行放置,我们为其提供一个配置 forceWrap

开始编码

关于如何创建新的 Vue 项目以及如何安装 Elemment Plus 这里就不说了,直接上当前的一个目录结构:

Pasted image 20240922164045.png

这篇文章中我们只实现标签的处理以及列布局的实现,其它的一律不考虑,因此在设计属性时也不会包含其它的内容。标签属性我们将其统一归到 labelConfig 属性下,同样 Grid 布局属性为 gridConfig,内容如下:

/**
 * 标签配置
 */
export interface LabelOption {
  /** 是否标准化标签名 */
  normalize?: boolean
  /** 是否固定标签宽度,优先级高于 `normalize` */
  fixed?: boolean
  /** 标签对齐方式 */
  position?: 'left' | 'right' | 'top'
  /** 标签宽度,使用 `renderLabel` 时如果计算 label 宽度不准确,可设置该属性(将覆盖自动计算的宽度)。 */
  width?: number | string
  /** 标签渲染函数 */
  render?: (label: string, data: RendererContent) => any
}

/**
 * `display: grid` 相关属性配置
 */
export interface RendererGrid {
  /** 列间距 */
  colGap?: number | string
  /** 行间距 */
  rowGap?: number | string
  /** 一行显示列数 */
  colCount?: number
  /** 列最小宽度 */
  colMinWidth?: number | string
  /** 列最大宽度 */
  colMaxWidth?: number | string
}

上述类型定义中的 normalize 字段主要用于格式化文本使其两端对齐,可以参考文章盘点CSS文本两端对齐的N种方式,我这里选用了第四种实现方式。

组件的配置项定义为:

export interface RendererContent {
  /** 标签名 */
  label: string
  /** 绑定的字段 */
  field: string
  /** 唯一标识符,用于 `v-for` 作为 `key` 值  */
  id?: string
  /** 默认值 */
  default?: any
  /** 组件类型 */
  type?: FormControlType
  /** 标签使用的渲染函数 */
  renderLabel?: (label: string, item: RendererContent) => any
}

这里的模型字段我并没有使用 model 或者 value 来定义,而是选择了 field 更具有识别性。同时默认值专门提供了一个 default 字段来设置。

然后是我们的组件属性声明:

/**
 * 表单渲染器属性
 */
export interface TableFormRendererProps {
  /** 标签属性配置 */
  labelConfig?: LabelOption
  /** 表单配置 */
  content: Reactive<RendererContent[]>
  /** 布局配置 */
  gridConfig?: RendererGrid
  /** 强制插槽 `action` 内容换行显示(默认情况下会放置在最后一行末尾,除非满行) */
  forceWrap?: boolean
}

准备测试页面

因为作者能力和精力有限就不去折腾单元测试,直接在项目的 views 目录下建一个测试页面,内容如下:

<script lang="tsx" setup>
import ElTableFormRenderer from '@/components/table-form-renderer'
import type { RendererContent } from '@/components/table-form-renderer/src/index.vue';
import { reactive } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';

const formFields = reactive({
  firstName: '',
  lastName: '',
  age: '',
  address: '',
  province: '',
  distinct: '',
  area: '',
  foo: ''
})

const formConent = reactive<RendererContent[]>([
  {
    type: 'input',
    label: '我的名字很长',
    field: 'firstName'
  },
  {
    type: 'input',
    label: '姓氏',
    renderLabel: (label: string, item: RendererContent) => (
      <span class="text-bold flex items-center">
        {label}
        <el-tooltip content="这是个提示" placement="top">
          <el-icon size={14} color="#ff8e00">
            <InfoFilled />
          </el-icon>
        </el-tooltip>
      </span>
    ),
    field: 'lastName'
  },
  {
    type: 'input',
    label: '年龄',
    field: 'age'
  },
  {
    type: 'input',
    label: '地址',
    field: 'address'
  },
  {
    type: 'input',
    label: '没有啥用',
    field: 'province'
  },
  {
    type: 'input',
    label: '身份证/签证',
    field: 'distinct'
  },
  {
    type: 'input',
    label: '中华人民共和国',
    field: 'area'
  },
  {
    type: 'input',
    label: '你谁呀?',
    field: 'foo'
  },
])

const renderLabel = (label: string, item: RendererContent) => {
  return (
    <span class="text-bold text-[#409eff]">{label}</span>
  )
}
</script>

<template>
  <el-table-form-renderer v-model="formFields" :content="formConent" :label-config="{ normalize: false, fixed: true }"
    :grid-config="{ colCount: 4 }" :force-wrap="true">
    <template #action>
      <el-button type="primary">提交</el-button>
    </template>
  </el-table-form-renderer>
</template>

上述代码中我们只准备了最简单的数据,控件类型也只有一种 el-input。参考开源项目 el-form-renderer 以及 el-form-renderer-vue3 都支持 render-form-grouprender-form-item 两种方式,我们这里暂时只考虑 render-form-item ,其内容如下:

<script lang="ts" setup>
import type { RendererContent } from '../index.vue'

export interface RenderFormItemProps {
  /** 标签 */
  label: string
  /** 键名 */
  prop: string
  /** label 宽度 */
  labelWidth?: number | string
}

defineOptions({
  name: 'RenderFormItem'
})

const props = defineProps<RenderFormItemProps>()
const model = defineModel()

</script>

<template>
  <el-form-item v-bind="$attrs" :label="props.label" :label-width="props.labelWidth">
    <template #label>
      <slot name="label" />
    </template>
    <el-input v-model="model" />
  </el-form-item>
</template>

<style lang="scss" scoped>
:deep(.el-form-item__label span) {
  flex: 1;
}
</style>

上术代码中我们向外提供了一个 label 插槽并将其内容传递给了 <el-form-item> 提供的同名插槽。表单控件的模型我们直接使用 defineModel() 来设置就可以了,如何后续有需要我们再考虑声明一个本地的 localModel 来处理。

核心内容编写

我们现来实现一个没有布局能够把标签和控件渲染出来的最基本功能。现阶段所有类型声明都是放置在 index.vue 文件中,后续再考虑拆分成单独的 types.ts 文件,这里类型声明想关的代码就省略掉。

<script lang="tsx" setup>
import type { FormInstance } from 'element-plus';
import { computed, ref, type Component, type Reactive } from 'vue';
import RenderFormItem from './components/render-form-item.vue';

// 默认列数
const DEFAULT_COLUMNS = 3
// 默认列间距
const DEFAULT_COLUMN_GAP = 16

defineOptions({
  name: 'ElTableFormRenderer'
})

const props = withDefaults(defineProps<TableFormRendererProps>(), {
  content: () => [],
})

/** 数据模型定义 */
const model = defineModel<Reactive<Record<string, any>>>({})

/** 表单实例 */
const formRef = ref<FormInstance>()
</script>

<template>
  <el-form ref="formRef" v-bind="$attrs" :model="model" :label-position="props?.labelConfig?.position || 'right'"
    :label-width="props?.labelConfig?.position || 'auto'">
    <template v-for="item in props.content" :key="item.value">
      <component :is="RenderFormItem" :label="item.label"
        :prop="item.field" v-model="(model || {})[item.field]" :data="item">
      </component>
    </template>
    <template v-if="$slots.action">
      <el-form-item class="action">
        <slot name="action" />
      </el-form-item>
    </template>
    <slot />
  </el-form>
</template>

注意:因为考虑到后续可能会添加 <render-form-group> 组件,所以这里使用了 <component is="xx"> 来动态渲染组件。

当前页面渲染为:

Pasted image 20240922221152.png

接下来我们来为 .el-form 加上 Grid 布局,结合前面介绍 Grid 实现列布局的相关知识,我们需要封装一个计算属性 style 用来根据传入的 props.gridConifg 来定义网格:

/**
 * <el-form> 样式
 */
const style = computed(() => {
  const defaultStyle = {
    display: 'grid',
    gridTemplateColumns: `repeat(${DEFAULT_COLUMNS}, 1fr)`,
    columnGap: `${DEFAULT_COLUMN_GAP}px`
  }
  if (!props.gridConfig) {
    return defaultStyle
  } else {
    const { colGap = DEFAULT_COLUMN_GAP, rowGap, colCount = DEFAULT_COLUMNS, colMinWidth, colMaxWidth } = props.gridConfig

    const getRepeatLastValue = () => {
      const min = typeof colMinWidth === 'number' ? `${colMinWidth}px` : colMinWidth || 'auto'
      const max = typeof colMaxWidth === 'number' ? `${colMaxWidth}px` : colMaxWidth || 'auto'
      if (colMinWidth && colMaxWidth) {
        return `minmax(${min}, ${max})`
      } else if (colMinWidth) {
        return `minmax(${min}, 1fr)`
      } else if (colMaxWidth) { // 由于不能设置 `minmax(${max}, 1fr)`,所以直接设置成 `max`
        return max
      } else {
        return '1fr'
      }
    }

    return {
      display: 'grid',
      gridTemplateColumns: `repeat(${colCount}, ${getRepeatLastValue()})`,
      columnGap: typeof colGap === 'number' ? `${colGap}px` : colGap,
      rowGap: (typeof rowGap === 'number' ? `${rowGap}px` : rowGap) || null
    }
  }
})

在处理样式时,如果传入了最大值(colMaxWidth)或最小值(colMinWidth)我们将使用 minmax() 函数来处理,但是我们只能实现 minmax([最小值], 1fr) 不能处理 minmax(1fr, [最大值]) ,因为这个 1fr 可能大于最大值,对于这种情况我们直接不使用 minmax(),而是处理成 repeat([列数], [最大值])

现在我们在 <el-from> 上加上 :style="style" 得到页面如下:

Pasted image 20240922222530.png

当我们将 <table-form-renderer> 中的属性 gridConfig.colCount 调整为 4 时刚好完整两行,此时我们会发现【提交】按钮并没有在所在行的右则:

Pasted image 20240922223156.png

此外,我们将数据减少 3 个会得到下面的界面:

Pasted image 20240922224919.png

针对这二种情况我们需要使用到 Grid 布局中的网格线来实现网格的合并。对于第二种情况我们前面内容有提到了 forceWrap 属性来强制换行以满足一些特定的需求。下面是【提交】按钮所在 <el-form-item> 的样式设置:

/**
 * 按钮区域样式
 */
const actionStyle = computed(() => {
  const len = props.content.length
  const cols = props?.gridConfig?.colCount || DEFAULT_COLUMNS

  // 最后一行剩余的空白列数
  const blankCols = cols - len % cols
  const baseStyle = {
    gridColumn: blankCols > 0 && blankCols < cols ? `${cols - blankCols + 1} / ${cols + 1}` : `1 / span ${cols}`,
    alignSelf: 'flex-end'
  }

  return props.forceWrap && blankCols > 0 ? { ...baseStyle, gridColumn: `1 / span ${cols}` } : baseStyle
})

注意:样式 aliginSelf: 'flex-end' 的目的在于处理当 labelPosition 值为 top 时【操作】按钮没有放置在右下角的问题。

将其加在 action 插槽所在 <el-form-item> 后,我们还需要调整 .el-form-item__content 的主轴(因为它本身使用 Flex 布局)方向,使用其内容位于终止方向:

:deep(.el-form-item.action > .el-form-item__content) {
  justify-content: flex-end !important;
}

现在布局效果如下:

Pasted image 20240922230925.png

布局问题解决了,接下来我们来处理标签宽度和对齐问题。

默认情况下 .el-form-item__labelwidth 值为 auto,我们现在根据配置数据中最长标签名来为其指定一个 labelWidth 值。

在开始写之前,我们先来简化一下代码,将 props.labelConfig.xx 改成一个 labelConfig 的计算属性值,并给出缺省值:

/**
 * labelConfig 属性
 */
const labelConfig = computed(() => {
  if (props.labelConfig) {
    return {
      ...props.labelConfig,
      labelWidth: attrs.labelWidth || props.labelConfig.width || '',
      position: attrs.labelPosition || props.labelConfig.position || 'right'
    }
  } else {
    return {
      normalize: false,
      fixed: true,
      position: attrs.labelPosition || 'right',
      width: attrs.labelWidth || '',
      render: undefined
    }
  }
})

我们这样做的目的是考虑到在 <table-form-renderer> 内部我们透传了所有 <el-form> 的属性,这也包括涉及标签的 labelWidthlabelPosition 两个属性,我们需要将其考虑进去。此外其实在 <el-form-item> 中这两个属性其实是有缺省值的,我们这里还是给了一个默认值。

提示:不要忘记引入 useAttrs,并添加语句 const attrs = useAttrs()

关于 <el-form-item>label 属性有一个通识:一般情况下传字符串就可以了,如果有自定义需求只需要将内容放在 label 插槽中就可以了(插槽优先级高于属性传值)。

在前面定义的标签配置属性中 labelConfig.fixed 表示表单项的标签的宽度不会收缩,整体保持一致;labelConfig.normalize 用于格式化标签使其两端对齐。此外,我们还需要提供灵活地自定义渲染方式,包括全局的 labelConfig.render() 函数以及间个组件的 renderLabel() 函数,这几种场景组成了完整的功能点。

下面是两者组合情况的处理:

  • fixednormalize 同时为 true:组件 label 属性传空字符串 "",标签由插槽 label 来处理。
    • 如果 render() 函数有设定则忽略 fixednormalize 设置。
    • 如果 position 的值为 top 则忽略 normalize 设置
    • 如果单个表单欺罔 renderLabel() 函数有配置,则优先于全局的 render() 函数。
  • fixedtruenormalizefalse:不用特殊处理,两个渲染函数处理逻辑同上。
  • fixedfalsenormalizetrue:这种情况显然不合理,不固定宽度没法去做对齐。
  • fixednormalze 同时为 false:直接透传就好了,后续由渲染函数(如果有)处理。

对于 labelWidth 属性的处理则:

  • fixedtrue 时如果 normalize 也为 true,取 labelWidth 的值,否则为 auto
  • fixedfalse 时设置为 max-content

下面是一个参考逻辑实现方式:

<template>
  <el-form ref="formRef" v-bind="$attrs" :model="model" :style="style" :label-position="labelConfig.position"
    :label-width="labelConfig.fixed && labelConfig.normalize ? labelWidth : 'auto'">
    <template v-for="item in props.content" :key="item.value">
      <component :is="RenderFormItem" :label="labelConfig.fixed && labelConfig.normalize ? '' : item.label"
        :prop="item.field" v-model="(model || {})[item.field]" :data="item"
        :label-width="labelConfig.fixed ? labelConfig.width : 'max-content'">
        <template #label>
          <template v-if="labelConfig?.render">
            <VNode
              :content="item?.renderLabel ? item?.renderLabel(item.label, item) : labelConfig.render(item.label, item)" />
          </template>
          <template v-else>
            <template v-if="labelConfig.normalize && labelConfig.position !== 'top'">
              <RenderAliginedLabel :label="item.label" />
            </template>
            <template v-else>
              <template v-if="item?.renderLabel">
                <VNode :content="item?.renderLabel(item.label, item)" />
              </template>
              <template v-else>{{ item.label }}</template>
            </template>
          </template>
        </template>
      </component>
    </template>
    <template v-if="$slots.action">
      <el-form-item class="action" :style="actionStyle">
        <slot name="action" />
      </el-form-item>
    </template>
    <slot />
  </el-form>
</template>

说明:作者习惯将 v-forv-if 逻辑套用 <template> 来写,而不是直接写在目标组件或者 HTML 标签上。

上述代码中 RenderAliginedLabel 是一个函数式组件,用于将标签文本在指定的宽度下两端对齐。遍历采用了数组的 flatMap 方法,一种比较优雅的实现方式:

/**
 * 使用 `flex: 1` 作用于 `<span></span>` 上来对齐标签
 * 参考:https://juejin.cn/post/7399288740908417024
 */
function RenderAliginedLabel({ label }: { label: string }) {
  return label.split('').flatMap((ch: string, idx: number) => {
    if (idx === 0) {
      return [ch]
    } else {
      return [<span></span>, ch]
    }
  })
}

我们可以在当前组件内部定义一个 Foo() 的函数式组件,并以 <Foo /> 的方式使用,但是作为属性传递到其它组件中时就不能这样子写了——我们需要使用一个名为(可以随便取名字) VNode 的函数式组件作为容器来渲染传入的渲染函数。它的作用至关重要,不可或缺。

/**
 * 函数式组件,用于挂载传递给 `label` 的渲染函数
 */
const VNode = (props: { content: JSX.Element }): JSX.Element => {
  return props.content
}

fixednormalize 组合结果如下:

Pasted image 20240928084858.png

labelConfig.render 和单个表单项的 renderLabel 结果:

Pasted image 20240928085227.png

到此,文章涉及的标签和列布局的知识就结束了。

简单聊一聊渲染函数和 JSX

在前面介绍 render-form-item 组件中,我们为组件设定了一个 label 插槽并将其透传进了被包裹的 el-form-item 组件的插槽 label 中。如果我们将 <slot name="label" /> 换成渲染函数 h() 要怎么实现呢?

通过翻阅 Vue 官方文档可以很快写出来:

<script lang="ts" setup>
import { h, useSlots } from 'vue'

const slots = useSlots()
const RenderLabel = () => h('div', slots?.label?.())
</script>

<template>
  <el-form-item v-bind="$attrs">
    <template #label>
      <RenderLabel />
    </template>
  </el-form-item>
</template>

这里的实现有一个不可避免的问题:h() 函数第一个参数必须传递一个字符串 (用于原生元素) 或者是一个 Vue 组件定义,但是这里我们发现结果明显多了一层 <div>,这里对于 Flex 布局来说有很大的影响。如果想要在使用 h() 函数的情况下规避这个问题,我们需要以 <el-form-item> 为根节点来进行封装:

<script lang="ts" setup>
import { h, useSlots } from 'vue'
import { ElFormItem, ElInput } from 'element-plus'
import 'element-plus/theme-chalk/el-input.css'

const props = defineProps<{
  modelValue: unknown
}>()
const slots = useSlots()
const emit = defineEmits(['update:modelValue'])

const RenderELFormItem = () => h(ElFormItem, {
  ...props,
}, {
  default: () =>
    h(ElInput, {
      modelValue: props.modelValue as string,
      'onUpdate:modelValue': (value) => emit('update:modelValue', value)
    }),
  label: () => slots.label?.()
})
</script>

<template>
  <RenderELFormItem />
</template>

可以看到使用 h() 函数后,我们需要自行引入 UI 组件库的样式以及使用到的组件,当我们也可以使用 resolveComponent() 来引入组件。此外,在 v-model 的处理上我们没有了语法糖加持,就需要拆分出 modelValue 进行属性绑定和回调事件的处理。

使用 JSX 实现要比使用渲染函数简单很多,也不用担心额外标签的影响。

<script lang="tsx" setup>
import { useSlots } from 'vue'

const model = defineModel()
const slots = useSlots()

const RenderLabel = () => <>{slots.label?.()} </>
</script>

<template>
  <el-form-item v-bind="$attrs" :label-width="props.labelWidth">
    <template #label>
      <RenderLabel />
    </template>
    <el-input v-model="model" />
  </el-form-item>
</template>

结语

本文旨在分享一些个人近况以及在封装组件上的一点思路,后续会抽空慢慢完善未完成的部分。同时在最近新工作中作者在大量业务需求、个个都是紧急需求(唉!救火队员,天天十几个小时不停地干)的情况下,愈发发现组件封装的重要性,以及这方面需要更多的经验积累和反思,以及借鉴学习好的模式来提高效率。

参考