不要再用json去封装element的表格了,用函数式组件去劫持它吧

13,257 阅读4分钟

背景

系统有个整改需求,要求系统内的所有表格支持本地动态列显隐,拖拽排序列位置,固定列功能,涉及的页面很多

上效果图:

image.png

思路

其实最开始想的肯定是json配置表单的形式,再由循环出来的列去控制对应的位置和属性 但是!很多页面啊!每个页面都要去转json配置意味着大量的工作量和极高的风险

能不能我就写个自己的组件来包一层,这样我就能实现最小改动的情况下只需要替换组件标签来实现这个功能

image.png 与实际的不同只是我将原来的el-table换成了hf-table,同时支持原本el-table的所有功能

想法与实践

el-table-column获取

我们不可能去自己实现一个el-table的组件,所以无非我们的组件就是在el-table的基础上套一层壳,给他加上一个设置按钮,同时设置的内容能够去影响整个表格的渲染。

那既然我们不自己实现el-table则意味着原先代码中的el-table-column我们要拿到,并且要传给el-table,这样我们才能去渲染出来原先的那个表格

在一个组件的实例中,我们能够通过vnode去获取到当前的一个虚拟domvnode去获取到当前的一个虚拟dom,vnode有一个componentOptions组件配置项的属性,通过他的children就能获取到所有的el-table-column 虚拟dom数组

如何渲染表格

上一步我们已经拿到了所有的el-table-column虚拟dom,那怎么将虚拟dom去渲染成对应的表格组件呢?

这不render就该登场了吗!!

image.png

这个children就是我们拿到的el-table-column的数组,我们只需要将该虚拟dom的数组以组件属性的形式传传进来了,再创建一个el-table,将对应的children传给他!卧槽,这不就又和原本<el-table>xxx</el-table>的效果一毛一样吗,是的 ,我做的就是挂羊头卖狗肉。

也就是说,实际上我的hf-table只是劫持了el-table,他的作用只是拿到原本写的el-table-colunm的虚拟dom,去渲染成一个表格

操作表格

此时我们的任务已经完成大半了,就是我原本el-table的标签已经可以被替换了,那我们要做的就只剩下操作表格了。 实际我做的很简单,既然我已经拿到了所有的子节点,那我就在hf-table组件中去操作成我想要的数组,再丢给render函数去渲染就好了

组件代码

整个组件的代码,代码量除掉样式也就不到100行

<template>
  <div class="hf-table">

    <el-popover
      placement="bottom-end"
      width="400"
      popper-class="table-cloumn-setting-popper"
      trigger="click"
    >
      <div class="setting-row-content">
        <draggable v-model="storageList" handle=".el-icon-s-operation" @end="updateTable">
          <div v-for="clo in storageList" :key="clo.label" class="setting-row">
            <i class="el-icon-s-operation" />
            <el-checkbox v-model="clo.show" class="label" @change="showOrHidden($event,clo)">{{ clo.label }}</el-checkbox>
            <el-button
              class="btn"
              size="mini"
              :type="clo.fixed === 'left' ? 'primary' : 'default'"
              @click="setFixed('left',clo)"
            >固定在左侧</el-button>
            <el-button
              class="btn"
              size="mini"
              :type="clo.fixed === 'right' ? 'primary' : 'default'"
              @click="setFixed('right',clo)"
            >固定在右侧</el-button>
          </div>
        </draggable>
      </div>
      <i slot="reference" class="el-icon-setting" />
    </el-popover>
    <new-table v-if="showTable" :config="config" />
  </div>
</template>
<script>
import draggable from 'vuedraggable'
import newTable from './table.js'
const components = { newTable, draggable }
export default {
  components,
  props: {
    storageName: {
      type: String,
      default: 'hfTable'
    }
  },
  data() {
    return {
      showTable: false,
      storageList: [],
      name: '',
      config: {
        children: [],
        attrs: {},
        listeners: {}
      }
    }
  },
  watch: {
    '$attrs': {
      handler(newV) {
        this.$set(this.config, 'attrs', newV)
      },
      deep: true,
      immediate: true
    }
  },
  mounted() {
    this.initStorage()
    this.updateTable()
  },
  methods: {
    showOrHidden(val, clo) {
      if (!val && this.storageList.filter(i => i.show).length === 0) {
        this.$message.warning('列表最少显示一列')
        this.$nextTick(() => {
          clo.show = true
        })
        return
      }
      this.updateTable()
    },
    setFixed(value, clo) {
      if (clo.fixed === value) {
        clo.fixed = false
      } else {
        clo.fixed = value
      }
      this.updateTable()
    },
    // 初始化缓存配置
    initStorage() {
      this.storageList = []
      const storage = window.localStorage.getItem(this.storageName)
      // 不管是否初次还是要做一下处理,万一页面有修改,做一下更新,以最新的node节点数组为准
      let list = storage ? JSON.parse(storage) : []
      this.$vnode.componentOptions.children.forEach(node => {
        // 以label为准,因为可能会改文本
        if (!node.componentOptions.propsData.type && list.findIndex(i => i.label === node.componentOptions.propsData.label) < 0) {
          // 不是特殊类型的 找不到就加上
          const propsData = JSON.parse(JSON.stringify(node.componentOptions.propsData))
          propsData.fixed = propsData.fixed !== undefined ? 'left' : false
          list.push({
            fixed: false, // 默认新增的都是不固定
            show: true, // 默认新增的都是显示的
            ...propsData
          })
        }
      })
      // 必须在节点数组存在的才有意义
      list = list.filter(item => this.$vnode.componentOptions.children.find(n => {
        return item.label === n.componentOptions.propsData.label
      }))
      this.storageList = list
    },
    // 根据缓存的数组进行渲染表格
    updateTable() {
      const childrenNodes = this.$vnode.componentOptions.children.filter(node => node.componentOptions.propsData.type)
      this.storageList.forEach(item => {
        if (item.show) {
          const node = this.$vnode.componentOptions.children.find(n => n.componentOptions.propsData.label === item.label)
          if (node) {
            node.componentOptions.propsData.fixed = item.fixed
            childrenNodes.push(node)
          }
        }
      })
      this.config.children = childrenNodes
      this.config.attrs = this.$attrs
      this.config.listeners = this.$listeners
      this.showTable = false
      this.$nextTick(() => {
        this.showTable = true
      })
      window.localStorage.setItem(this.storageName, JSON.stringify(this.storageList))
    }
  }
}
</script>
<style lang="scss" scoped>
  .table-cloumn-setting-popper{
    .setting-row-content{
      max-height: 600px;
      overflow-y: auto;
      .setting-row{
        height: 40px;
        line-height: 40px;
        .el-icon-s-operation{
          cursor: move;
          font-size: 16px;
          margin-right: 8px;
        }
        .label{
          margin-right: 8px;
        }
        .btn{
          padding: 4px!important;
        }
      }
    }
  }
  .hf-table{
    width:100%;
    height:100%;
    position: relative;
    .el-icon-setting{
      position: absolute;
      right: 20px;
      top:-20px;
      cursor: pointer;
    }
  }
</style>

表格函数式组件

import Vue from 'vue'

export default Vue.component('newtable', {
  functional: true,
  props: {},
  listeners: {},
  render: function(h, context) {
    return h(
      'el-table',
      {
        props: context.data.attrs.config.attrs,
        on: context.data.attrs.config.listeners
      },
      context.data.attrs.config.children
    )
  }
})

问题点与优化

当真的推行到项目中时,发现了以上代码存在了几个问题:

1.函数式组件没有生命周期和实例,也就是table.js帮我们渲染了el-table,我们却没办法拿到el-table 的实例,也就没办法去调用table原生的方法,例如clearSelection等

2.忘了做插槽传递。例如空数据自定义插槽等

hf-table.vue

<template>
  <div class="hf-table">
    <el-popover
      placement="bottom-end"
      popper-class="table-cloumn-setting-popper"
      trigger="click"
    >
      <div class="setting-row-content">
        <div style="text-align:right">
          <el-button @click="delAllStorage">恢复系统表格设置</el-button>
          <el-button @click="delStorage">恢复当前表格设置</el-button>
        </div>
        <draggable v-model="storageList" handle=".el-icon-s-operation" @end="updateTable">
          <div v-for="clo in storageList" :key="clo.label" class="setting-row">
            <i class="el-icon-s-operation" />
            <el-checkbox v-model="clo.show" class="label" @change="showOrHidden($event,clo)">{{ clo.label }}</el-checkbox>
            <el-button
              class="btn"
              size="mini"
              :type="clo.fixed === 'left' ? 'primary' : 'default'"
              @click="setFixed('left',clo)"
            >固定在左侧</el-button>
            <el-button
              class="btn"
              size="mini"
              :type="clo.fixed === 'right' ? 'primary' : 'default'"
              @click="setFixed('right',clo)"
            >固定在右侧</el-button>
          </div>
        </draggable>
      </div>
      <i slot="reference" class="el-icon-setting" />
    </el-popover>
    <!-- 按钮容器 -->
    <div
      class="table-operate-btn-content"
    >
      <!-- 插槽自定义表格上方操作栏 -->
      <slot name="operateBtnContent">
        <!-- 默认左右都有操作按钮,如果单纯想左或者想右,请在插入具名插槽 -->
        <div class="operate-btn-content">
          <!-- 流式左右布局 -->
          <slot name="btnContentLeft">
            <div />
          </slot>
          <slot name="btnContentRight">
            <div />
          </slot>
        </div>
      </slot>
    </div>
    <div :style="{height:`${tableHeight}px`}">
      <new-table v-if="showTable" :config="config" />
    </div>
  </div>
</template>
<script>
import draggable from 'vuedraggable'
import newTable from './table.js'
import setHeight from '@/mixins/setHeight'
const components = { newTable, draggable }
export default {
  name: 'HfTable',
  components,
  mixins: [setHeight],
  props: {
    storageName: {
      type: String,
      required: true
    }
  },
  data() {
    return {
      showTable: false,
      storageList: [],
      name: '',
      config: {
        children: [],
        attrs: {},
        listeners: {}
      }
    }
  },
  watch: {
    '$attrs': {
      handler(newV) {
        this.$set(this.config, 'attrs', newV)
      },
      deep: true,
      immediate: true
    }
  },
  mounted() {
    this.initStorage()
    this.updateTable()
  },
  methods: {
    getInstance() {
      const ref = this.$children.find(i => i.$options._componentTag === 'el-table')
      return ref
    },
    delStorage() {
      this.$confirm('恢复当前表格设置将清除当前表格设置并刷新页面是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        const storage = window.localStorage.getItem('tableStorage') ? JSON.parse(window.localStorage.getItem('tableStorage')) : {}
        storage[this.storageName] = []
        window.localStorage.setItem('tableStorage', JSON.stringify(storage))
        location.reload()
      })
    },
    delAllStorage() {
      this.$confirm('恢复系统表格设置将清除当前表格设置并刷新页面是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        window.localStorage.removeItem('tableStorage')
        location.reload()
      })
    },
    showOrHidden(val, clo) {
      if (!val && this.storageList.filter(i => i.show).length === 0) {
        this.$message.warning('列表最少显示一列')
        this.$nextTick(() => {
          clo.show = true
        })
        return
      }
      this.updateTable()
    },
    setFixed(value, clo) {
      if (clo.fixed === value) {
        clo.fixed = false
      } else {
        clo.fixed = value
      }
      this.updateTable()
    },
    // 初始化缓存配置
    initStorage() {
      this.storageList = []
      const storage = window.localStorage.getItem('tableStorage') ? JSON.parse(window.localStorage.getItem('tableStorage')) : {}
      // 不管是否初次还是要做一下处理,万一页面有修改,做一下更新,以最新的node节点数组为准
      let list = storage[this.storageName] ? storage[this.storageName] : []
      this.$vnode.componentOptions.children.forEach(node => {
        // 以label为准,因为可能会改文本
        if (!(!node.componentOptions || node.componentOptions.propsData.type) && list.findIndex(i => i.label === node.componentOptions.propsData.label) < 0) {
          // 非插槽且 不是特殊类型的 找不到就加上
          const propsData = JSON.parse(JSON.stringify(node.componentOptions.propsData))
          if (propsData.fixed === undefined || propsData.fixed === false) {
            propsData.fixed = false
          } else {
            propsData.fixed = propsData.fixed ? propsData.fixed : 'left'
          }
          list.push({
            fixed: false, // 默认新增的都是不固定
            show: true, // 默认新增的都是显示的
            ...propsData
          })
        }
      })
      // 必须在节点数组存在的才有意义
      list = list.filter(item => this.$vnode.componentOptions.children.find(n => {
        return n.componentOptions && item.label === n.componentOptions.propsData.label
      }))
      this.storageList = list
    },
    // 根据缓存的数组进行渲染表格
    updateTable() {
      // 特殊类型
      const childrenNodes = this.$vnode.componentOptions.children.filter(node => node.componentOptions && node.componentOptions.propsData.type)
      this.storageList.forEach(item => {
        if (item.show) {
          const node = this.$vnode.componentOptions.children.find(n => n.componentOptions && n.componentOptions.propsData.label === item.label)
          if (node) {
            node.componentOptions.propsData.fixed = item.fixed
            childrenNodes.push(node)
          }
        }
      })
      this.config.children = childrenNodes
      this.config.attrs = this.$attrs
      this.config.listeners = this.$listeners
      this.showTable = false
      this.$nextTick(() => {
        this.showTable = true
      })
      const storage = window.localStorage.getItem('tableStorage') ? JSON.parse(window.localStorage.getItem('tableStorage')) : {}
      storage[this.storageName] = this.storageList
      window.localStorage.setItem('tableStorage', JSON.stringify(storage))
    }
  }
}
</script>
<style lang="scss" scoped>
  .table-cloumn-setting-popper{
    .setting-row-content{
      max-height: 600px;
      overflow-y: auto;
      .setting-row{
        height: 40px;
        line-height: 40px;
        .el-icon-s-operation{
          cursor: move;
          font-size: 16px;
          margin-right: 8px;
        }
        .label{
          margin-right: 8px;
        }
        .btn{
          padding: 4px!important;
        }
      }
    }
  }
  .hf-table{
    width:100%;
    height:100%;
    position: relative;
    .el-icon-setting{
      position: absolute;
      right: 10px;
      top:16px;
      cursor: pointer;
    }
    .table-operate-btn-content{
      width: calc(100% - 40px);
     .operate-btn-content {
        height: 40px;
        display: flex;
        justify-content: space-between;
        align-items: center;
      }
    }
  }
</style>


针对插槽的处理主要是根据插槽没有componentOption属性,然后把它和带有type的这类vnode直接丢给el-table,而其他的再去做显隐的处理。

table.js

import Vue from 'vue'

export default Vue.component('newtable', {
  functional: true,
  props: {},
  listeners: {},
  render: function(h, context) {
    const scopedSlots = {}
    Object.keys(context.parent.$scopedSlots).forEach(key => {
      if (key !== 'default') {
        scopedSlots[key] = context.parent.$scopedSlots[key]
      }
    })
    return context.parent.$createElement(
      'el-table',
      {
        props: { ...context.data.attrs.config.attrs, ref: 'newtable' },
        on: context.data.attrs.config.listeners,
        attrs: { ref: 'newtable' },
        scopedSlots
      },
      context.data.attrs.config.children
    )
  }
})

针对函数式组件没有实例的问题,这里我直接调用了父级组件的$createElement方法去创建el-table,再利用父级组件的childrenchildren中`options._componentTag === 'el-table'`的vnode,来拿到对应的实例

有点奇怪的是我在创建的时候给生成的组件配置attrs的ref,在父组件中$refs无法拿到

还有一点要注意!我在控制组件重新渲染的时候,使用了$nexttick,所以不要在钩子函数中使用getInstance()方法获取表格组件实例,如果一定要,那就用链式判一下空再用this.$refs.hftable.getInstance()?.xxx()

后话

其实这是不是的合适方案也未定,但是最主要是过程中的一个探索吧。包括评论说的$slots去继承所有的vnode节点。其实在拿不到表格实例的时候我想换方案的时候尝试过这个办法,我直接去掉了函数式组件。直接写了一个el-table,然后具名插槽去接收。但是因为要操作vnode,也就是我要隐藏某列,这意味着我需要去修改hf-table中$slots中的default数组,就总觉得不太合适

共同进步吧