首頁 > 軟體

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

2022-07-19 14:00:39

背景

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

上效果圖:

思路

其實最開始想的肯定是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的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com