可能不会有人教你的 vue 奇技淫巧之表单转描述列表

493 阅读3分钟

可能不会有人教你的 vue 奇技淫巧之表单转描述列表

恰逢大概率接下来会失去工作的机会,把近些年做的一些东西整理下,给面试吹牛逼用

偶尔看到的几段源码可能会在之后帮到你大忙。

免责声明:原来没写注释,现在的注释 MarsCode 加的,有问题找它

简介

起源是我在工作中遇到的一个比较奇葩的需求,一般给表单做回显时,都是加个 disabled 状态做回显,省事并且数据展示都和提交时一致,但是需求方嫌丑,要求把表单做成描述列表(element-ui 的 Descriptions 组件)。

思路

然而当时整个系统开发了能有一年多了,大大小小累积的表单组件数不胜数,摆在我面前的路就是挨个写过去,吗?虽然有其他小伙伴协助,这也是个大工程。这需求除了费时间也没啥技术量,但我寻思着都是 key-value 形式,能否把表单里的 key 和 value 想个办法提取出来,直接用用 Descriptions 组件展示呢?key 无非就是 label 属性或者 lable slot 里的内容,而 value 就是每个 el-form-item 里的组件展示出来的值。 最开始的思路是通过原生的 DOM 方法取值,然后找出 key 和 value,可实际上哪有那么简单,这个匹配方法写出来我估计人都升天了,更何况还有小部分的表单是表格之类的,做不了一点。 最后我的决定是试试能不能通过 vue 实例来拿到表单里的组件,通过 vue 实例的$children 来递归拿到所有的 el-form-item 组件,然后尝试提取出 key 和 value。 首先写一下找对应组件实例的代码:

/**
 * 在给定的组件中查找指定名称的组件
 * @param {VueComponent} component - 要搜索的组件
 * @param {string} name - 要查找的组件的名称
 * @param {boolean} [deep=false] - 是否深度搜索子组件
 * @param {boolean} [greedy=false] - 是否在找到第一个匹配项后停止搜索
 * @return {VueComponent[]} - 找到的组件列表
 */
export function findComponentByName(
  component,
  name,
  deep = false,
  greedy = false
) {
  let componentList = []
  const children = component?.$children || []
  children.forEach(com => {
    if (com.$options.name === name) {
      componentList.push(com)
      if (greedy) return
    }
    if (deep) {
      componentList = componentList.concat(
        findComponentByName(com, name, deep, greedy)
      )
    }
  })

  return componentList
}

ok,现在找到了所有的 el-form-item 组件,接下来就是提取 key 和 value 了,但要注意的是,并不是所有组件都是需要提取的,比如我上面说的表格、或者是一些上传完成回显的组件等等,所以难点其实不是在提取 key 和 value 上,而是在不展示 value 的时候,如何将原来的组件原样展示。正好 vue 能做到这点,众所周知 vue 采用虚拟 dom patch 生成的真实 dom,只要拿到虚拟 dom 就等于拿到了这个组件渲染后的数据,如果看过点 vue 源码或者 console 过 vue 实例就知道每个实例对应的 vnode 其实就是 vm.$vnode。 提取 key(label)有两种方式,但我们一直没用过 slot 的方式,所以直接取 el-form-item 实例的 label 即可。 提取 value 则是拿到 el-form-item 下的所有子组件,针对不同组件做不同的处理即可,只需要看下对应组件的源码,例如 el-input 用的 value、el-select 用的 selected 或者 selectedLabel 等。

/**
 * 获取指定组件实例的值和虚拟节点
 * @param {string} componentName - 组件的名称
 * @param {VueComponent} elInstance - 组件实例
 * @return {Object} - 包含值、虚拟节点和是否有枚举的对象
 */
export function getElementComponentValue(componentName, elInstance) {
  function timePicker(elInstance) {
    return !elInstance.ranged
      ? elInstance.displayValue
      : (elInstance.displayValue && elInstance.displayValue[0]) +
          elInstance.rangeSeparator +
          (elInstance.displayValue && elInstance.displayValue[1])
  }
  function input(elInstance) {
    return elInstance.value ? elInstance.value : ''
  }
  function elInputNumber(e) {
    return e.value ? e.value : ''
  }
  // 将组件名称映射到对应的取值函数或属性名
  const valueEnum = {
    ElInput: input,
    ElInputNum: 'inputValue',
    ElCascader(elInstance) {
      return elInstance.multiple
        ? elInstance.presentTags.map(item => item.text)
        : elInstance.presentText
        ? elInstance.presentText
        : ''
    },
    ElSelect(elInstance) {
      return elInstance.multiple
        ? elInstance.selected.map(item => item.currentLabel)
        : elInstance.selectedLabel
    },
    ElInputNumber: elInputNumber,
    ElDatePicker: timePicker,
    ElTimePicker: timePicker,
  }
  let value = null
  let vnode = null
  let hasEnum = true
  // 检查是否有对应组件名称的取值函数或属性
  if (valueEnum[componentName]) {
    // 调用对应的取值函数或获取属性值
    value =
      typeof valueEnum[componentName] === 'function'
        ? valueEnum[componentName](elInstance)
        : elInstance[valueEnum[componentName]]
  } else {
    hasEnum = false
  }
  // 克隆组件的虚拟节点
  vnode = cloneVNode(elInstance.$vnode, elInstance.$createElement)
  return {
    value,
    vnode,
    hasEnum,
  }
}

该有的东西都有了,下面该思考的是如何以最小成本对相应的组件进行改动了。一种思路,直接重写 el-form,使其能直接渲染出描述列表,但这种太暴力了,会影响到部分不需要转为描述列表做展示的,并且写出 bug 的很大。另一种思路就是针对需要转换的页面进行处理,在 el-form 外层嵌套一层组件,该组件针对不同的属性做出不同的处理,最简单的就是传个值,需要展示表单就不做处理,不需要展示就把表单隐藏展示描述列表里。 以下是完整示例,可供参考:

<script>
// 导入自定义工具函数
import { findComponentByName, getElementComponentValue } from './util.js'
export default {
  name: 'FormToDescription', // 组件名称
  props: {
    descriptionVisiable: {
      // 用于控制描述区域的显示与隐藏
      type: Boolean,
      default: null,
    },
  },
  data() {
    return {
      isShowDescription: false, // 用于标识当前描述区域的显示状态
      descriptionData: [], // 用于存储描述数据
    }
  },
  provide() {
    return {}
  },
  computed: {},
  watch: {
    descriptionVisiable(val) {
      this.$nextTick(() => {
        this.isShowDescription = val
        this.getDescriptionData()
      })
    },
  },
  mounted() {
    this.$nextTick(() => {
      this.isShowDescription = this.descriptionVisiable
      this.getDescriptionData()
    })
  },
  methods: {
    getSlotInstance() {
      // 获取所有插槽实例
      return this.$scopedSlots.default().map(t => t.componentInstance)
    },
    getFormItemInstance() {
      // 获取所有 form-item 实例
      const formItemInstance = this.getSlotInstance().flatMap(t =>
        findComponentByName(t, 'ElFormItem', true)
      )
      return formItemInstance
    },
    getDescriptionData() {
      // 获取描述数据
      const formItemInstance = this.getFormItemInstance()
      const keyValue = []
      formItemInstance.forEach(vm => {
        vm.$children.forEach(cvm => {
          // 排除 label-wrap 组件的子组件
          if (cvm.$vnode?.componentOptions?.tag === 'label-wrap') return
          const { value = null, vnode = null } = getElementComponentValue(
            cvm.$options.name,
            cvm
          )
          keyValue.push({
            label: vm.label,
            value,
            vnode,
          })
        })
      })
      this.descriptionData = keyValue
    },
    description() {
      // 渲染描述区域
      const keyValue = this.descriptionData
      return (
        <el-descriptions border>
          {keyValue.map(item => (
            <el-descriptions-item label={item.label}>
              {item.value || <span />}
              {item.value !== null ? null : item.vnode}
            </el-descriptions-item>
          ))}
        </el-descriptions>
      )
    },
    setDescriptionVisiable(val) {
      this.isShowDescription = val
    },
    hadleClick() {
      // 处理点击事件,切换描述区域的显示状态
      this.setDescriptionVisiable(!this.isShowDescription)
    },
  },
  render() {
    return (
      <div>
        {this.descriptionVisiable === null && (
          <el-button disabled={false} onClick={this.hadleClick}>
            点击
          </el-button>
        )}
        <div style={{ display: this.descriptionVisiable ? 'none' : '' }}>
          {this.$scopedSlots.default()}
        </div>
        {this.isShowDescription ? this.description() : ''}
      </div>
    )
  },
}
</script>