【基于vue3table组件,理解hooks】

155 阅读3分钟

前言:vue3新增了Composition API,其中最大的亮点非Hooks莫属,本文将以封装table组件来对比vue2选项式api和vue3中hooks使用方法,帮你更好的理解hooks有哪些优点。

vue2选项式

使用vue2选项式封装原生表格组件。 代码块包含了mthods、computed、created、mounted,computed和methods之间互相调用,耦合性很强,虽然使用方便,但是后期维护比较麻烦。

export default {
  components: {
    list,
    tableTitle,
    empty
  },
  props: {
    // 展示的列
    columns: {
      type: Array,
      default: () => []
    },
    // 源数据
    data: {
      type: Array,
      default: () => []
    },
    // 是否显示标题头
    showTitle: {
      type: Boolean,
      default: true
    },
    // 是否显示列表数据
    showList: {
      type: Boolean,
      default: true
    },
    // 是否显示空状态
    showEmpty: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      rowTitleId: 'rowTitle' + Math.ceil(Math.random() * 10 ** 10),
      rowListId: 'rowList' + Math.ceil(Math.random() * 10 ** 10),
      fixedWidthDefault: 0.7, // 单元格默认宽度
      viewWidth: 360, // 屏幕默认宽度
      scopedSlotsKeys: [], // 插槽name数组
      htmlFontSize: 0, // 1rem对应px值
      // columns // 测试多级表头
    }
  },
  created() {
    this.getScopedSlotsKeys()
  },
  mounted() {
    window.addEventListener('resize', this.init)
    this.init()
  },
  beforeUpdate() {
    this.getScopedSlotsKeys()
    // this.scrollToTarget()
  },
  methods: {
    init() {
      this.getViewWidth()
      this.getHtmlFontSize()
    },
    // 屏幕宽度
    getViewWidth() {
      this.$nextTick(() => {
        const tableDom = this.$refs.tableRef
        if (!tableDom) {
          return
        }
        const tableBlockStyle = window.getComputedStyle(tableDom)
        const width = +tableBlockStyle.width.replace('px', '')
        const paddingLeft = +tableBlockStyle.paddingLeft.replace('px', '')
        const paddingRight = +tableBlockStyle.paddingRight.replace('px', '')
        const widthSum = width - paddingLeft - paddingRight
        this.viewWidth = !isNaN(widthSum) ? widthSum : this.viewWidth
      })
    },
    // 获取1rem对应的px值
    getHtmlFontSize() {
      this.$nextTick(() => {
        const htmldom = document.querySelector('html')
        this.htmlFontSize = +window.getComputedStyle(htmldom).fontSize.slice(0, -2)
      })
    },
    // 表头数据
    getColumnArr(list) {
      return list.reduce(
        (pre, cur) => {
          pre[cur.deep - 1].push(cur)
          return pre
        },
        Array.from({ length: this.maxDeep }).map(() => [])
      )
    },
    // 获取插槽name数组
    getScopedSlotsKeys() {
      const slotKeys = Object.keys(this.$scopedSlots || {})
      // console.log('slotKeys===>', slotKeys, this.$scopedSlots)
      this.scopedSlotsKeys = slotKeys.reduce((pre, key) => {
        if (typeof this.$scopedSlots[key] === 'function') {
          pre.push(key)
        }
        return pre
      }, [])
    },
    // 获取列宽和
    getColWidth(arr) {
      return +arr
        .reduce((pre, cur) => {
          if (!cur.hasChildren) {
            pre += cur.width ? +cur.width : this.fixedWidthDefault
          }
          return pre
        }, 0)
        .toFixed(2)
    },
    // 获取title 插槽
    getHeaderSlots(arr) {
      return arr.filter(item => item.slotHeader && this.scopedSlotsKeys.includes(item.slotHeader))
    },
    // 获取列表插槽
    getListSlots(arr) {
      return arr.filter(item => this.scopedSlotsKeys.includes(item.key))
    },
    // title和list同步滚动
    onscrollHandler(e, id) {
      const dom = document.querySelector(`#${id}`)
      if (dom) {
        dom.scrollLeft = e?.target?.scrollLeft
      }
    },
    // 滚动重置到左侧
    resetScrollLeft() {
      this.$nextTick(() => {
        const domTitle = document.querySelector(`#${this.rowTitleId}`)
        const domList = document.querySelector(`#${this.rowListId}`)
        if (domTitle) {
          scrollLeftTo(domTitle, 0, 0.3);
        }
        if (domList) {
          scrollLeftTo(domList, 0, 0.3);
        }
      })
    },
    // 表头和内容列对齐
    scrollToTarget() {
      const domTitle = document.querySelector(`#${this.rowTitleId}`)
      const domList = document.querySelector(`#${this.rowListId}`)
      if (!domTitle) {
        return
      }
      const domTitleScrollLeft = domTitle.scrollLeft
      if (domList) {
        scrollLeftTo(domList, domTitleScrollLeft, 0);
      }
    },
    changeSort(obj) {
      let target = null
      this.columns.forEach(element => {
        if (element.key === obj.key) {
          if (element.sort === 0) {
            element.sort = 2
          } else {
            element.sort = element.sort === 1 ? 2 : 1
          }
          target = {
            ...element
          }
        } else if (hasOwn(element, 'sort')) {
          element.sort = 0
        }
      })
      // console.log('columns', this.columns)
      this.$emit('changeSort', target)
    },
    /**
     * 数据处理
     * @params columns: 遍历的数组
     * @params obj: 处理后的结果
     * @params deep: 当前深度
     * @params isFirst: 是否是第一次调用函数
     * @params currentKey: 从父级到当前子级的key
     * @params fixed: 固定列子集添加fixed值
     */
    getColumnList(columns, obj, deep, isFirst, currentKey, fixed) {
      columns.forEach(item => {
        // 重置深度
        if (isFirst) {
          deep = 1
        }
        const ob = {
          ...item
        }
        ob.keysStr = isFirst ? ob.key : currentKey + '-' + ob.key
        ob.deep = deep
        if (fixed) {
          ob.fixed = fixed
        }
        if (!ob.children) {
          ob.hasChildren = false
          obj.list.push(ob)
        } else {
          ob.hasChildren = true
          deep++
          if (deep > obj.maxDeep) {
            obj.maxDeep = deep
          }
          obj.list.push({
            ...ob,
            children: []
          })
          this.getColumnList(item.children, obj, deep, false, ob.keysStr, ob.fixed || undefined)
        }
      })
    }
  },
  computed: {
    columnObj() {
      const obj = {
        list: [], // 扁平化数据列表
        maxDeep: 1 // 最大深度
      }
      this.getColumnList(this.columns, obj, 1, true, '', undefined)
      obj.list.forEach((item, index) => {
        item.colSpan = obj.list.filter(ite => ite.keysStr.includes(item.key) && !ite.hasChildren).length
        if (!item.hasChildren) {
          item.rowSpan = obj.maxDeep - item.deep + 1 // 根据表格得出规律
        } else {
          item.rowSpan = 1
        }
      })
      // console.log('list===>', obj.list, obj.maxDeep)
      return obj
    },
    // 标题list
    titleList() {
      return this.columnObj.list
    },
    // 标题行数
    maxDeep() {
      return this.columnObj.maxDeep
    },
    // 要展示得列数
    columnsList() {
      return this.titleList.filter(item => !item.hasChildren)
    },
    // 所有列宽和
    rowWidth() {
      return this.columnsList.reduce((pre, cur) => {
        pre += cur.width ? +cur.width : this.fixedWidthDefault
        return pre
      }, 0)
    },
    // x轴宽度超出屏幕宽度
    isXScroll() {
      // console.log('isXScroll', this.rowWidth, this.viewWidth, this.htmlFontSize)
      return this.rowWidth * this.htmlFontSize - this.viewWidth > 0
    },
    // 左侧固定列
    fixedLeftLength() {
      const arr = this.columnsList.filter(item => item.fixed === 'left')
      return arr.length
    },
    centerColumnLength() {
      return this.columnsList.filter(item => !['left', 'right'].includes(item.fixed)).length
    },
    // 列list
    columnArr() {
      let obj = {
        left: [],
        center: [],
        right: []
      }
      this.titleList.forEach((item, index) => {
        // fixed 值为 String
        if (item.fixed === 'left' || item.fixed === 'right') {
          obj[item.fixed].push(item)
        } else {
          obj.center.push(item)
        }
      })
      const wt = {
        left: this.getColWidth(obj.left),
        center: this.getColWidth(obj.center),
        right: this.getColWidth(obj.right)
      }
      const arr = []
      Object.keys(obj).forEach(key => {
        if (obj[key].length > 0) {
          arr.push({
            titleList: this.getColumnArr(obj[key]),
            col: obj[key].filter(item => !item.hasChildren),
            fixed: key,
            colWidth: wt[key], // 列宽度
            // 此处宽度需动态计算,屏幕宽度不一样对应的1rem等于多少px不一样
            width: ['left', 'right'].includes(key) ? wt[key] + 'rem' : `calc(100% - ${wt.left + wt.right}rem)`,
            headerSlots: this.getHeaderSlots(obj[key]),
            colSlots: this.getListSlots(obj[key]),
            shadow: key !== 'center' ? this.isXScroll : false
          })
        }
      })
      // 最后一列添加类名
      const obje = arr[arr.length - 1]
      if (obje) {
        const objCol = obje.col
        const lastCol = objCol[objCol.length - 1]
        if (lastCol) {
          lastCol.isLastCol = true
        }
        obje.titleList.forEach(item => {
          const last = item[item.length - 1]
          if (last) {
            last.isLastCol = true
          }
        })
      }
      // console.log('arr===>', arr)
      return arr
    }
  }
}
</script>

vue3 hooks

vue3 hooks 和 vue2 中 mixin 设计初衷是为了提供一种更简洁和直观的方式来共享逻辑和状态,使用过mixin的应该知道它有一些不足之处,hooks刚好解决了这些问题,下面通过代码来看一下hooks优点。

上面代码我们可以封装成3个不同功能的hook,看起来更简介,更容易理解。

// 处理列数据
const { list: colList, maxDeep} = useColumns(props.columns)
// 获取左侧列数、中间列数
const { fixedLeftLength, centerColumnLength, isXScroll } = useDesign(tableRef, colList, fixedWidthDefault)
// 获取最终要渲染的数据
const { columnArr } = useColumnList(colList, maxDeep, isXScroll, fixedWidthDefault)

useDesign hooks函数依赖于useColumns hooks函数的返回值 useColumnList hooks函数依赖于 useDesign 和 useColumns 返回值,最终返回我们要渲染的数据columnArr, 从以上代码可以看出

hooks优点

1、每个hook都是一个独立函数,不需要考虑属性命名冲突
2、 可以清晰的看到属性来自哪个自定义hook
3、hooks可以接受参数,使用更灵活。
4、Vue3自定义Hook可以在引入的时候对同名变量重命名。

入口文件代码

<script setup lang="ts">
import { ref, reactive, computed, unref, onMounted, nextTick, useSlots } from 'vue'
import tableTitle from './title.vue'
import list from './list.vue'
import { useColumns } from '@/hooks/useColumns.ts'
import { useDesign } from '@/hooks/useDesign.ts'
import { useColumnList } from './hooks/useColumnList.ts'
const props = defineProps({
  // 展示的列
  columns: {
    type: Array,
    default: () => []
  },
  // 源数据
  data: {
    type: Array,
    default: () => []
  },
  // 是否显示标题头
  showTitle: {
    type: Boolean,
    default: true
  },
  // 是否显示列表数据
  showList: {
    type: Boolean,
    default: true
  },
  // 是否显示边框
  showBorder: {
    type: Boolean,
    default: false
  }
})
const tableRef = ref(null)
const fixedWidthDefault = ref(70) // 单元格默认宽度
// 处理列数据
const { list: colList, maxDeep} = useColumns(props.columns)
// 获取左侧列数、中间列数
const { fixedLeftLength, centerColumnLength, isXScroll } = useDesign(tableRef, colList, fixedWidthDefault)
// 获取最终要渲染的数据
const { columnArr } = useColumnList(colList, maxDeep, isXScroll, fixedWidthDefault)

const rowTitleId = ref('rowTitle' + Math.ceil(Math.random() * 10 ** 10))
const rowListId = ref('rowList' + Math.ceil(Math.random() * 10 ** 10))
// title和list同步滚动
const onscrollHandler = function(e, id) {
  const dom = document.querySelector(`#${id}`)
  // console.log('onscrollHandler===>', e, dom)
  if (dom) {
    dom.scrollLeft = e?.target?.scrollLeft
  }
}
</script>

useColumns hook 用来给初始数据添加一些属性

import { computed, unref} from 'vue'
import { Columms, ObjTarget } from '../types/column.ts'

/**
 * 数据处理
 * @params columns: 遍历的数组
 * @params obj: 处理后的结果
 * @params deep: 当前深度
 * @params isFirst: 是否是第一次调用函数
 * @params currentKey: 从父级到当前子级的key
 * @params fixed: 固定列子集添加fixed值
 */
function getColumnList(columns: Array<Columms>, obj: ObjTarget, deep: number, isFirst: boolean, currentKey: string, fixed: string | undefined) {
  columns.forEach(item => {
    // 重置深度
    if (isFirst) {
      deep = 1
    }
    const ob: Columms = {
      ...item
    }
    ob.keysStr = isFirst ? ob.key : currentKey + '-' + ob.key
    ob.deep = deep
    if (fixed) {
      ob.fixed = fixed
    }
    if (!ob.children) {
      ob.hasChildren = false
      obj.list.push(ob)
    } else {
      ob.hasChildren = true
      deep++
      if (deep > obj.maxDeep) {
        obj.maxDeep = deep
      }
      obj.list.push({
        ...ob,
        children: []
      })
      getColumnList(item.children, obj, deep, false, ob.keysStr, ob.fixed || undefined)
    }
  })
}

export function useColumns(columns: any) {
  const columnObj = computed(() => {
    const obj: any = {
      list: [], // 扁平化数据列表
      maxDeep: 1 // 最大深度
    }
    getColumnList(columns, obj, 1, true, '', undefined)
    obj.list.forEach((item: any) => {
      item.colSpan = obj.list.filter((ite: any) => ite.keysStr.includes(item.key) && !ite.hasChildren).length
      if (!item.hasChildren) {
        item.rowSpan = obj.maxDeep - item.deep + 1 // 根据表格得出规律
      } else {
        item.rowSpan = 1
      }
    })
    return obj
  })
  return unref(columnObj)
}

useDesign hook

import { reactive, onMounted, nextTick, computed, unref} from 'vue'
import type { Ref } from 'vue'
import { Columms } from '../types/column.ts'

export function useDesign(tableRef: Ref, colList: Array<Columms>, fixedWidthDefault: Ref) {
  const defaultConst = reactive({
    viewWidth: 360
  })
  // 屏幕宽度
  function getViewWidth(dom: Ref): number {
    if (!dom.value) {
      return defaultConst.viewWidth
    }
    const tableBlockStyle = window.getComputedStyle(dom.value)
    const width = +tableBlockStyle.width.replace('px', '')
    const paddingLeft = +tableBlockStyle.paddingLeft.replace('px', '')
    const paddingRight = +tableBlockStyle.paddingRight.replace('px', '')
    const widthSum = width - paddingLeft - paddingRight
    return !isNaN(widthSum) ? widthSum : defaultConst.viewWidth
  }
  
  onMounted(() => {
    nextTick(() => {
      defaultConst.viewWidth = getViewWidth(tableRef)
    })
  })

  // 要展示得列数
  const columnsList = computed(() => {
    return colList.filter(item => !item.hasChildren)
  })

   // 计算行宽
   const rowWidth = computed(() => {
    return columnsList.value.reduce((pre, cur) => {
      pre += cur.width ? +cur.width : fixedWidthDefault.value
      return pre
    }, 0)
  })

  // 左侧固定列
  const fixedLeftLength = computed(() => {
    const arr = columnsList.value.filter(item => item.fixed === 'left')
    return arr.length
  })

  // 中间列长度
  const centerColumnLength = computed(() => {
    return columnsList.value.filter(item => !['left', 'right'].includes(item.fixed)).length
  })

  // x轴宽度超出屏幕宽度
  const isXScroll = computed(() => {
    return rowWidth.value - defaultConst.viewWidth > 0
  })

  // 数组结构
  function unRefArr (obj: any) {
    return Object.keys(obj).reduce((pre: any, key) => {
      pre[key] = unref(obj[key])
      return pre
    }, {})
  }
  return unRefArr({
    fixedLeftLength,
    centerColumnLength,
    isXScroll
  })
}

useColumnList hook

import { computed, unref, useSlots } from 'vue'
import type { Ref } from 'vue'

export function useColumnList(colList: any, maxDeep: number, isXScroll: boolean, fixedWidthDefault: Ref ) {
  const slots = useSlots()
  // 获取title 插槽
  function getHeaderSlots(arr: any) {
    return arr.filter((item: any) => item.slotHeader && slots[item.slotHeader])
  }

  // 获取列宽和
  function getColWidth(arr: any, defaultWidth: number): number {
    return +arr
      .reduce((pre: any, cur: any) => {
        pre += cur.width ? +cur.width : defaultWidth
        return pre
      }, 0)
      .toFixed(2)
  }

  // 表头数据
  function getColumnArr(list: any, maxDeep: number) {
    return list.reduce(
      (pre: any, cur: any) => {
        pre[cur.deep - 1].push(cur)
        return pre
      },
      Array.from({ length: maxDeep }).map(() => [])
    )
  }
  // 列list
  const fixedArr = ['left', 'center', 'right']
  const columnArr = computed(() => {

    let obj = fixedArr.reduce((pre: any, key) => {
      pre[key] = []
      return pre
    }, {})

    colList.forEach((item: any) => {
      // fixed 值为 String
      if (item.fixed === 'left' || item.fixed === 'right') {
        obj[item.fixed].push(item)
      } else {
        obj.center.push(item)
      }
    })

    const wt = fixedArr.reduce((pre: any, key) => {
      pre[key] = getColWidth(obj[key], fixedWidthDefault.value)
      return pre
    }, {})

    const arr: any = []
    Object.keys(obj).forEach(key => {
      if (obj[key].length > 0) {
        arr.push({
          titleList: getColumnArr(obj[key], maxDeep),
          col: obj[key].filter((item: any) => !item.hasChildren),
          fixed: key,
          colWidth: wt[key], // 列宽度
          // 此处宽度需动态计算,屏幕宽度不一样对应的1rem等于多少px不一样
          width: ['left', 'right'].includes(key) ? wt[key] + 'px' : `calc(100% - ${wt.left + wt.right}px)`,
          headerSlots: getHeaderSlots(obj[key]),
          colSlots: obj[key].filter((item: any) => slots[item.key]),
          shadow: key !== 'center' ? isXScroll : false
        })
      }
    })
    // 最后一列添加类名
    const objeFirst = arr[0]
    objeFirst.isFirstCol = true
    const objeEnd = arr[arr.length - 1]
    objeEnd.isLastCol = true
    return arr
  })

  return { columnArr: unref(columnArr) }
}