B 端数据表格封装 Vue 3 实践之:筛选区- el-input 功能封装

532 阅读9分钟

在上一篇文章 《B 端数据表格封装 Vue 3 实践之:筛选区-简单布局实现(闲聊文加点干货)》中我们介绍了如何封装网格布局、Element Plus 中表单的标签的几种排版方式实现以及如何使用渲染函数来实现功能。这一篇文章中我们将对 <el-input> 的功能进行封装,尽量覆盖其全部功能并提供简洁、灵活地配置。

功能点思考

参考 Element Plus 的 Form 表单Input 输入框文档,我们需要实现以下功能:

  • 属性的配置(包含原生和组件属性)
  • 事件的配置(包含事件修饰符)
  • v-model 的配置(包含修饰符)
  • 插槽的配置
  • 组件的引用

由于模板语法功能地限制,一些功能我们需要使用渲染函数来实现,作者会在文章中注明为什么,以及自己的解决方式——不一定是最优的。

类型声明调整

基于上一篇文章代码并参考 《10 Practical Tips for Better Vue Apps》文章第 10 条将类型单独放置在一个 <script> 中。

类型 RendererContent 变更如下:

export interface RendererContent {
  /** 标签名 */
  label: string
  /** 绑定的字段 */
  field: string
  /** 唯一标识符,用于 `v-for` 作为 `key` 值  */
  id?: string
  /** 默认值 */
  default?: any
+ /** v-model 的修饰符 */
+ modifiers?: 'trim' | 'number' | 'lazy' | 'capitalize' | 'uppercase' | 'lowercase'
+ /** HTML 属性 */
+ attrs?: Record<string, any>
+ /** 组件的属性 */
+ props?: Record<string, any>
+ /** 插槽 */
+ slots?: Record<string, any>
+ /** 表单验证规则 */
+ rules?: FormRules
  /** 组件类型 */
  type?: FormControlType
+ /** 控件尺寸 */
+ size?: '' | 'large' | 'default' | 'small'
+ /** 是否禁用 */
+ disabled?: boolean
  /** 标签使用的渲染函数 */
  renderLabel?: (label: string, item: RendererContent) => any
+ /** 监听表单发出的事件 */
+ events?: {
+ 	[eventName: string]: (event: Event, ...args: unknown[]) => any
+ }
}

注意:在配置事件时我们并没有使用 on 来描述,使用 events 更适合一点。v-model 的修饰符增加了 3 个额外的修饰符:capitalize / uppercaselowercase

类型 LabelOption 增加 size 属性:

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

类型 TableFormRendererProps 增加 sizedisabled 属性:

/**
 * 表单渲染器属性
 */
export interface TableFormRendererProps {
  /** 标签属性配置 */
  labelConfig?: LabelOption
  /** 表单配置 */
  content: Reactive<RendererContent[]>
  /** 布局配置 */
  gridConfig?: RendererGrid
  /** 强制插槽 `action` 内容换行显示(默认情况下会放置在最后一行末尾,除非满行) */
  forceWrap?: boolean
+ /** 控制组件尺寸 */
+ size?: '' | 'large' | 'default' | 'small'
+ /** 是否禁用所有表单 */
+ disabled?: boolean
}

render-form-item.vue 中的类型 RenderFormItem 调整如下:

+import type { RendererContent } from '../index.vue'

export interface RenderFormItemProps {
  /** 标签 */
  label: string
  /** 键名 */
  prop: string
+ /** v-model 的修饰符 */
+ modifiers?: 'trim' | 'number' | 'lazy' | 'capitalize' | 'uppercase' | 'lowercase'
+ /** 是否禁用 */
+ disabled?: boolean
  /** label 宽度 */
  labelWidth?: number | string
+ /** 控件尺寸 */
+ size?: '' | 'large' | 'default' |'small'
+ /** 表单配置项 */
+ data: RendererContent
}

render-form-item.vue 文件功能调整

这里需要根据 RendererContent.type 类型来判断出 input 输入框,并且在缺省情况下默认使用输入框。因为 type 属性是在 RenderFormItemProps 类型中的 data: RendererContent 中定义的,所以不能按常规方式使用 withDefaults() 宏命令来设置 type 的默认值,但我们可以在模板中判断:

<template>
  <template v-if="(data.type === 'input' || !data.type) && !data.hidden">
    <el-input v-model="model" />
  </template>
</template>

处理 v-model 修饰符

根据 v-model官方文档,我们使用 defineModel() 宏来定义双向绑定。由于这里不使用 v-model 的参数功能,所以直接可以使用 const model = defineModel() 来定义一个默认的 v-model 双向绑定。由于在模板中我们无法使用 v-model.[变量名] 的方式来动态绑定修饰符,但可以通过 defineModel() 的返回值解构结果的第二个参数来获取在组件调用时传递的修饰符,因此我们有了以下的处理方式:

const [model, modelModifiers] = defineModel<string, 'trim' | 'number' | 'lazy' | 'capitalize' | 'uppercase' | 'lowercase'>({
  required: true,
  set(value: string = '') {
    if (props.modifiers === 'capitalize' || modelModifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    } else if (props.modifiers === 'uppercase' || modelModifiers.uppercase) {
      return value.toUpperCase()
    } else if (props.modifiers === 'lowercase' || modelModifiers.lowercase) {
      return value.toLowerCase()
    } else if (props.modifiers === 'trim' || modelModifiers.trim) {
      return value.trim()
    } else if (props.modifiers === 'number' || modelModifiers.number) {
      return parseFloat(value)
    } else if (props.modifiers === 'lazy' || modelModifiers.lazy) {
      return value
    } else {
      return value
    }
  }
})

前面类型声明时有提到我们增加了 3 个修饰符,再加上 Vue 默认支持的 3 个,一共处理了 6 种情况。number 修饰符的使用需要将 input 的属性 type 设置为 number 才生效;trim 修饰符用于去除文本首尾的空白符;lazy 修饰符用于更改模型值变化时事件的响应方式,由 input 事件处理方式变成 change 事件处理方式,但是需要注意的是在 Element Plus 中并不起作用,我们需要自己去处理 change 事件。

处理插槽(slot

<el-input> 提供了 4 个插槽:prefix / suffix / prpend / append,分别用于输入框头/尾部,前/后置内容插入。插槽通常用于自定义渲染内容,一般在 Vue 模板中以 <template v-slot:prefix> 或简写 <template #prefix> 的方式使用。在子组件中使用 <slot name="prefix"> 来定义一个具名插槽,或者在模板中使用 $slots.prefix 来设置条件插槽;在 <script setup> 中我们需要使用 const slots = useSlots() 的方式来获取同模板中一样的 $slots 对象,并以 slots.prefix() 方式使用。

因为我们的组件是以配置数据来渲染的,因此插槽配置对象 slots 有以下 3 种方式来配置:

  1. 字符串:slots: { prefix: '前置内容' }
  2. 渲染函数 h()slots: { prefix: h('span', null, '前置内容') }
  3. JSX 方式:slots: { prefix: <span>前置内容</span> }

其实上面 3 种方式 Vue 官方是不推荐使用的,因为你会在控制台看到以下警告信息:

[Vue warn]: Non-function value encountered for slot "prepend". Prefer function slots for better performance.

为什么呢?因为我们没有考虑作用域插槽的场景——如果是作用域插槽请告诉我参数应该怎么传递呢?

基于以上的分析,我们在模板中顺理成章便有了以下处理方式(这里不考虑作用域插槽):

<template>
  <template v-if="(data.type === 'input' || !data.type) && !data.hidden">
    <el-input v-model="model">
      <template v-for="key in Object.keys(data?.slots || {})" :key="key" v-slot:[key]>
        <slot :name="key">
          <template v-if="isVNode(getSlotContent(data.slots?.[key]))">
            <VNode :content="getSlotContent(data.slots?.[key])" />
          </template>
          <template v-else>
            {{ getSlotContent(data.slots?.[key]) }}
          </template>
        </slot>
      </template>
    </el-input>
  </template>
</template>

我们需要考虑使用字符串、函数及渲染函数几种情况,并正确解析其内容,所以封装了 2 个函数:

function VNode(props: { content: JSX.Element }): JSX.Element {
  return props.content
}

function getSlotContent(slot: string | Function) {
  return typeof slot === 'function' ? slot() : slot
}

这样有关插槽的内容就处理结束了,后续如果其它控件有作用插槽我们再作讨论。

处理事件绑定

这里的事件我们分为 2 类:原生事件和自定义事件。在处理原生事件如:input / change / submit / keydown / click 等事件以及事件的冒泡与捕获时 Vue 为我们提供了修饰符来简化代码的编写与判断。

这里的修饰符主要分为:事件修饰符、按键修饰符和鼠标按键修饰符,具体内容可参考官方文档·事件处理

自定义事件是使用 emit('xx', params) 抛出的事件,在 <script setup> 中使用 defineEmits() 宏来定义。

<el-input> 提供了 blur / focus / change / inputclear 5 种事件。通常我们是使用 <el-input v-on:input="doThis"> 或者简写 <el-input @input="doThis"> 的方式使用。在 v-on 的文档中有明确指出动态绑定的方式:v-on:[event]="doThis"@[event]="doThis",但是不适用于当前以对象方式配置的方式。退一步虽然可以使用绑定对象的方式 v-on="{ mousedown: doThis, mouseup: doThat }",但是官方有明确提示:“请注意,当使用对象语法时,不支持任何修饰符。”

在渲染函数中如果我们要使用事件和按键修饰符,需要用到 withModifiers 函数:

<div onClick={withModifiers(() => {}, ['self'])} />

这个函数的第 2 个参数接受以下修饰符(来源于 VSCode 插件提示):

  • stop - 调用 event.stopPropagation()
  • prevent - 调用 event.preventDefault()
  • self - 只有事件从元素本身发出才触发处理函数。
  • left - 只在鼠标左键事件触发处理函数。
  • right - 只在鼠标右键事件触发处理函数。
  • middle - 只在鼠标中键事件触发处理函数。
  • alt - Alt 键。
  • ctrl - Ctrl 键。
  • shift - Shift 键。
  • meta - Meta 键。
  • delete - Delete 键。
  • esc - Esc 键。
  • space - Space 键,即空格键。
  • exact - 允许精确控制触发事件所需的系统修饰符的组合。
  • up - Up 键,即向上箭头。
  • down - Down 键,即向下箭头。

注意:由于缺乏实践,这些修饰符通过 withModifiers 函数的具体使用作者暂无实际项目来举例。

上面我们明确了在模板中不能使用 v-on:[event]@[event] 来动态绑定不确定的事件,同时也不能使用对象的方式,所以我们只能采用渲染函数来实现了:

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

    <template v-if="(data.type === 'input' || !data.type) && !data.hidden">
      <RenderElInput />
    </template>
  </el-form-item>
</template>

其中 RenderElInput() 定义如下:

function RenderElInput() {
  return (
    <el-input
      v-model={model.value}
      {...data.value.events }
    >
    {
      {...data.value.slots }
    }
    </el-input>
  )
}

这里我们需要注意,原先的插槽也同时作了调整,相较于模板实现方式更加简单了,也不需要任何辅助判断函数了。

在配置中的使用举例:

events: {
  onClear: () => {
    console.log('clear')
  },
  onKeyup: withModifiers((event: Event) => {
    console.log('keyup', event)
  }, ['alt'])
}

注意:如果是使用模板方式,这里的配置方式就需要去掉 on 前缀。

暴露组件的实例方法和属性

<el-form> / <el-form-item><el-input> 都有实例方法可调用,因此我们需要将其提供给外部组件。由于我们这里使用的是 Vue 3.4.x 的版本,所以就不考虑 3.5 新出的 useTemplateRef 的使用。我们需要在组件中定义一个 inputRef 的变量,并在 RenderElInput 中使用 <el-input ref={inputRef} />,同时使用 defineExpose({ inputRef }) 将其暴露给父组件。

由于在 table-form-renderer/src/index.vue 中使用了 <el-form><el-form-item> (以 <component :is="RenderFormItem"> 的方式),所以需要在这里将其定义并暴露给外部。同样的实现方式:定义 formRef,使用 defineExpose({ formRef }) 暴露给父组件。

由于 <el-form-item> 是动态配置的,这里可以考虑以绑定的 field 属性作为引用名称,作者这里处理成:

<component
  :is="RenderFormItem"
  :ref="`formItem${capitalize(item.field)}`"
/>

这里我们不需要去定义每一个动态变量,因为我们在外部实际上是可以通过 xxRef.value.$refs.xx 获取到其引用的(在控制台单独输出 xxRef.value 是不会显示的)。

下面是一个简单的例子:点击按钮让“年龄”所在的输入框获得焦点。

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

const myRef = ref<any>(null)
const formFields = reactive({ age: '' })
const formConent = reactive<RendererContent[]>([
  {
    type: 'input',
    label: '年龄',
    field: 'age',
    slots: {
      prepend: () => "Http://"
    }
  },
])

function onKeyupEnter() {
  myRef.value?.$refs?.formItemAge[0].inputRef.focus()
}
</script>

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

其它属性处理

如果属性存在默认值我们就直接写在组件上,比如 placeholder 的默认值,然后再通过解构将传入的属性赋值给组件,覆盖默认值。

function RenderElInput() {
  return (
    <el-input
      ref={inputRef}
      v-model={model.value}
      placeholder="请输入"
      disabled={disabled.value}
      size={size.value}
      {...data.value.attrs }
      {...data.value.props }
      {...data.value.events }
    >
    {
      {...data.value.slots }
    }
    </el-input>
  )
}

table-form-renderer/src/index.vue 文件功能调整

因为我们只是实现与 <el-input> 相关的功能,所以这里的变动不大,只是增加了引用,修饰符等功能。由于前面介绍类型的变更,下面代码就省略掉这部分代码了:

<script lang="tsx" setup>
import type { FormInstance, FormRules } from 'element-plus'
import { computed, ref, useAttrs, type Component, type Reactive, toRefs } from 'vue'
import RenderFormItem from './components/render-form-item.vue'
import type { JSX } from 'vue/jsx-runtime'
import { capitalize } from 'lodash-es'

// 默认列数
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 attrs: Record<string, any> = useAttrs()

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

/**
 * <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
    }
  }
})

/**
 * 按钮区域样式
 */
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
})

/**
 * 获得标签最大长度,暂不考虑嵌套情况
 */
const labelWidth = computed(() => {
  let max = 0
  let labelWidth = props.labelConfig?.width
  props.content.forEach((item: RendererContent) => {
    max = Math.max(max, item.label.length)
  })
  // `12px` 为 `.el-form-item__label` 的右内边距
  return labelWidth || `${max * 16 + 12}px`
})

/**
 * 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
    }
  }
})

/**
 * 使用 `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]
    }
  })
}

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

+defineExpose({
+ formRef
+})
</script>

<template>
  <el-form
    ref="formRef"
    v-bind="$attrs"
    :model="model"
    :style="style"
+  :size="props.size || ''"
    :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"
+       :ref="`formItem${capitalize(item.field)}`"
+       v-model="(model || {})[item.field]"
        :label="labelConfig.fixed && labelConfig.normalize ? '' : item.label"
        :prop="item.field"
+       :modifiers="item.modifiers"
+       :size="item.size || props.size || ''"
+       :disabled="item.disabled || props.disabled"
+       :readonly="item.readonly || props.readonly"
        :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>

<style scoped>
+:deep(.el-form-item__content) {
+ align-items: flex-start;
+}

:deep(.el-form-item.action > .el-form-item__content) {
  margin-left: 0 !important;
  justify-content: flex-end !important;
}
</style>

结语

文章只是提供一种思路参考,实际场景可能需要应对各种苛刻的场景,如小屏日期区间选择显示不下完整内容需要提供一个提示完整内容的功能,这种情况需要怎么处理就需要后续不断地完善了。

由于文章是边 Coding 边写的,所以内容不一定严谨,而且未应用到实际项目中,所以你懂的,能从中获得一点点收获也不枉作者花时间精力来总结写作。