函數式組件劫持替代json封裝element表格

背景

系統有個整改需求,要求系統內的所有表格支持本地動態列顯隱,拖拽排序列位置,固定列功能,涉及的頁面很多

上效果圖:

思路

其實最開始想的肯定是json配置表單的形式,再由循環出來的列去控制對應的位置和屬性 但是!很多頁面啊!每個頁面都要去轉json配置意味著大量的工作量和極高的風險

能不能我就寫個自己的組件來包一層,這樣我就能實現最小改動的情況下隻需要替換組件標簽來實現這個功能

與實際的不同隻是我將原來的el-table換成瞭hf-table,同時支持原本el-table的所有功能

想法與實踐

el-table-column獲取

我們不可能去自己實現一個el-table的組件,所以無非我們的組件就是在el-table的基礎上套一層殼,給他加上一個設置按鈕,同時設置的內容能夠去影響整個表格的渲染。

那既然我們不自己實現el-table則意味著原先代碼中的el-table-column我們要拿到,並且要傳給el-table,這樣我們才能去渲染出來原先的那個表格

在一個組件的實例中,我們能夠通過vnode去獲取到當前的一個虛擬dom,vnode去獲取到當前的一個虛擬dom,vnode去獲取到當前的一個虛擬dom,vnode有一個componentOptions組件配置項的屬性,通過他的children就能獲取到所有的el-table-column 虛擬dom數組

如何渲染表格

上一步我們已經拿到瞭所有的el-table-column虛擬dom,那怎麼將虛擬dom去渲染成對應的表格組件呢?

這不render就該登場瞭嗎!!

這個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,再利用父級組件的children中‘children中`children中‘options._componentTag === 'el-table'`的vnode,來拿到對應的實例

有點奇怪的是我在創建的時候給生成的組件配置attrs的ref,在父組件中$refs無法拿到

還有一點要註意!我在控制組件重新渲染的時候,使用瞭$nexttick,所以不要在鉤子函數中使用getInstance()方法獲取表格組件實例,如果一定要,那就用鏈式判一下空再用this.$refs.hftable.getInstance()?.xxx()

後話

其實這是不是的合適方案也未定,但是最主要是過程中的一個探索吧。包括評論說的$slots去繼承所有的vnode節點。其實在拿不到表格實例的時候我想換方案的時候嘗試過這個辦法,我直接去掉瞭函數式組件。直接寫瞭一個el-table,然後具名插槽去接收。但是因為要操作vnode,也就是我要隱藏某列,這意味著我需要去修改hf-table中$slots中的default數組,就總覺得不太合適,共同進步吧~更多關於表格函數式組件封裝element的資料請關註WalkonNet其它相關文章!

推薦閱讀: