Element plus 跟随页面的横向滚动条及固定表头

1,741 阅读8分钟

Element plus 跟随页面的横向滚动条及固定表头

更新

  • 25.3.29 更新:

    对于寻找比滚动条外部容器的方法进行修改,现在会寻找距离整个组件最近的拥有滚动条的元素。这样可以避免出现如果界面不止表格组件,还有其他组件并且长度超出屏幕后,需要两个滚动条才能完整滑动的问题

问题

  • 原生的el-table组件的固定表头必须要设置高度,当表格高度需要动态变化的时候该选项无效。
  • 固定列之后出现横向滚动条,但该滚动条也无法跟随页面移动只能固定在表格底部,当数据过多时,无法直接操作。

分析

​ 目前滚动条的解决方案是两种:

  1. 手搓滚动条,过于繁琐了
  2. 第三方组件,目前没找到可以用的

​ 固定表头的方案是动态的计算max-height,理论是可以的,但在我的项目无效

解决方案

滚动条

​ 我的整体思想是在不破坏原有滚动条的情况下,更改它的定位来实现随页面滚动

​ 它的滚动条的定位是当通过position:absolute定位到表格底部,以及display来控制显示与否。

image-20241102162944682.png

​ 属性scrollbar-always-on可以来解决不显示的问题。

​ 定位的解决方案是:切换absolute和fixed定位来控制滚动条的位置。当超过表格的高度或者表格不可见时,使其保持他原生的定位,反之则固定在页面底部。

由于滚动条并不是出现在表格内部,所以是给它的外部容器添加滚动事件

其中elScrollBarH是滚动条的外部容器,并不是滚动条本身,tableContent则是表格的内容区域,用于给滚动条定位

新增寻找最近的拥有滚动条的元素

// 使用 WeakMap 自动管理清理函数
const scrollHandlers = new WeakMap<HTMLElement, ScrollHandler>()

// 自定义指令
const vScrollParent = {
  mounted(
    el: HTMLElement,
    binding: DirectiveBinding<(container: HTMLElement) => () => void>,
  ) {
    // 获取最近的滚动容器
    const scrollContainer = findScrollParent(el)
    if (scrollContainer === null) {
      return
    }

    // 执行回调获取清理函数
    const cleanup = binding.value(scrollContainer)

    // 关联清理函数到元素
    scrollHandlers.set(el, { cleanup })
  },

  beforeUnmount(el: HTMLElement) {
    // 执行清理操作
    const handler = scrollHandlers.get(el)
    handler?.cleanup()
    scrollHandlers.delete(el)
  },
}

// 滚动处理函数
const handleScrollParent = (scrollContainer: HTMLElement) => {
  // 清理旧的监听(当容器变化时)
  const prevContainer = tableContainer.value
  if (prevContainer && prevContainer !== scrollContainer) {
    const prevHandler = scrollHandlers.get(prevContainer)
    prevHandler?.cleanup()
    scrollHandlers.delete(prevContainer)
  }

  // 如果已经是当前容器,直接返回
  if (tableContainer.value === scrollContainer) {
    return () => {}
  }

  // 节流控制
  let ticking = false
  let rafId: number

  // 事件处理器
  const handler = () => {
    if (!ticking) {
      ticking = true
      rafId = window.requestAnimationFrame(() => {
        setScrollAndHeader()
        ticking = false
      })
    }
  }

  // 清理函数
  const cleanup = () => {
    scrollContainer.removeEventListener('scroll', handler)
    if (rafId) window.cancelAnimationFrame(rafId)
    if (tableContainer.value === scrollContainer) {
      tableContainer.value = null
    }
  }

  // 绑定事件
  scrollContainer.addEventListener('scroll', handler, { passive: true })
  tableContainer.value = scrollContainer

  // 保存引用
  scrollHandlers.set(scrollContainer, { cleanup })

  return cleanup
}


// 清理逻辑,在
const cleanup = () => {
  // 1. 移除 IntersectionObserver
  if (tableContent.value) {
    tableVisOb.unobserve(tableContent.value)
  }
  if (tableContainer.value) {
    tableSizeOb.unobserve(tableContainer.value)
  }

  // 2. 完全断开观察器(可选)
  tableVisOb.disconnect()
  tableSizeOb.disconnect()

  // 3. 清理其他资源
  // window.removeEventListener('scroll', handleScroll)
}


onUnmounted(() => {
  cleanup()
})

将其绑定到根元素上:

<div class="tableContainer" v-scroll-parent="handleScrollParent">
	...content
</div>

处理定位:

  /**
   * @description: 设置滚动条的位置
   * 这里调整的是滚动条的包裹容器,他相对于表格容器来定位,
   * 而内部的实际的滚动条相对于这个容器使用tranlate来调整位置
   * 所以在调回的时候,直接把left设置为0,仍然能保持滚动条的相对位置
   * @param {*} state true:固定在可视区域,false:固定在表格容器内
   * @return {*}
   */
  const setScrollPos = (state: boolean) => {
    if (state) {
      if (!elScrollBarH.value || !tableContent.value) return

      elScrollBarH.value.style.position = 'fixed'
      // 这里去找表格的content区域,把他相对于视口的left值设置给滚动条的容器
      elScrollBarH.value.style.left =
        tableContent.value?.getBoundingClientRect().left + 'px'
    } else {
      elScrollBarH.value!.style.left = 0 + 'px'
      elScrollBarH.value!.style.position = 'absolute'
    }
  }

重点在于state条件的判断,为true的情况有只有一种,表格出现在视口内且滚动高度没有超过表格所在高度。为此,需要两个部分的判定,首先是滚动的时候需要对高度进行判断,第二需要一个可见性判断

  /**
   * @description: 判定当前横向滑动条是否在表格内
   * 滑动距离加可见区域高度不大于表格总高度,则说明在表格内
   * @return {*}
   */
  const isIntable = () => {
    if (!tableContent.value || !tableContainer.value) return false
    const { scrollTop, offsetHeight, scrollHeight } =
      tableContainer.value as HTMLElement
    let result = scrollTop + offsetHeight < scrollHeight
    isFixed.value = result // 这个一会儿解释
    return result
  }
  /**
   * @description: 观察表格是否在可视区域,设置滚动条的对应位置
   * @param {*} entries 观察实例,包含当前观察的元素的状态
   * @return {*}
   */
  const obScroll = (entries: IntersectionObserverEntry[]) => {
    setScrollPos(entries[0].intersectionRatio > 0)	// 这里他是一个数组,但我只有一个滚动条所以就直接访问0了
  }

  // 表格可见性的观察者
  const tableVisOb = new IntersectionObserver(obScroll)

到这里已经可以正常使用滚动条了,但是当表格宽度发生变化的时候,滚动条并不会跟随表格调整位置,所以还需要对表格尺寸变化进行监视

// 当前滚动条是否固定状态,这个主要用于在给sizeOb使用
// 如果是固定状态,那么在表格大小改变的时候,left需要重置到表格的最左侧的left位置
// 如果不是,那么设为0即可
// 这也是上方出现这个变量的原因
const isFixed = ref<boolean>(true)

/**
 * @description: 生成一个表格大小的观察者,当大小变化去动态的调整滚动条的位置
 * @param {ResizeObserverEntry[]} entries 观察的实例
 * @return {*}
 */
const tableSizeOb = new ResizeObserver((entries: ResizeObserverEntry[]) => {
  if (!elScrollBarH.value || !tableContainer.value) return
  // 找到表格容器
  let container = entries.find(item => item.target === tableContainer.value)
  if (!container) return

  // 看当前的固定状态,分别去调整滚动条的位置
  if (isFixed.value) {
    let left = container.target.getBoundingClientRect().left
    elScrollBarH.value.style.left = left + 'px'
  } else {
    elScrollBarH.value.style.left = 0 + 'px'
  }
})

最后记得挂载

onMounted(() => {
  tableVisOb.observe(tableContent.value as HTMLElement)
  tableSizeOb.observe(tableContainer.value as HTMLElement)
})

表头

表头的解决方案就很简单粗暴了,直接计算滑动高度来固定表头及还原表头位置,这个方案会让表头样式有一点点问题,可以调整(但我没调)

if (tableHeaderRef.value) {
  const { scrollTop } = tableContainer.value as HTMLElement
  let contentOffsetTop = tableContent.value!.offsetTop // 父容器距离顶部的高度
  let contentScollHeight = tableContent.value!.scrollHeight // 整个父容器的高度
  // 超出表头原本位置时且小于整个表的高度,则固定表头
  if (
    scrollTop >= contentOffsetTop &&
    scrollTop <= contentOffsetTop + contentScollHeight
  ) {
    tableHeaderRef.value.style.position = 'fixed'
    tableHeaderRef.value.style.top = '125px' // 这里位置是随便给的,可以通过计算顶部导航高度来确定准确的值
    tableHeaderRef.value.style.zIndex = '666'
  } else {
    // 还原
    // 不设置top是因为在static下,top无效
    tableHeaderRef.value.style.position = 'static'
    tableHeaderRef.value.style.zIndex = '1'
  }
}

完整代码

使用时记得给需要出现滚动条的元素加上`overflow:scroll*

// useTableScroll.ts
import type { Ref } from 'vue'

export function useTableScroll(
  elScrollBarH: Ref<HTMLElement | null>,
  tableContent: Ref<HTMLElement | null>,
  tableContainer: Ref<HTMLElement | null>,
  tableHeaderRef: Ref<HTMLElement | null>,
  isFixed: Ref<boolean>,
) {
  /**
   * @description: 设置滚动条的位置
   * 这里调整的是滚动条的包裹容器,他相对于表格容器来定位,
   * 而内部的实际的滚动条相对于这个容器使用tranlate来调整位置
   * 所以在调回的时候,直接把left设置为0,仍然能保持滚动条的相对位置
   * @param state true:固定在可视区域,false:固定在表格容器内
   */
  const setScrollPos = (state: boolean) => {
    if (state) {
      if (!elScrollBarH.value || !tableContent.value) return

      elScrollBarH.value.style.position = 'fixed'
      // 这里去找表格的content区域,把他相对于视口的left值设置给滚动条的容器
      elScrollBarH.value.style.left =
        tableContent.value?.getBoundingClientRect().left + 'px'
    } else {
      elScrollBarH.value!.style.left = 0 + 'px'
      elScrollBarH.value!.style.position = 'absolute'
    }
  }

  /**
   * @description: 观察表格是否在可视区域,设置滚动条的对应位置
   * @param  entries 观察实例,包含当前观察的元素的状态
   * @return
   */
  const obScroll = (entries: IntersectionObserverEntry[]) => {
    setScrollPos(entries[0].intersectionRatio > 0)
  }

  /**
   * @description: 判定当前横向滑动条是否在表格内
   * 滑动距离加可见区域高度不大于表格总高度,则说明在表格内
   * @return 是否在表格内
   */
  const isInTable = () => {
    if (!tableContent.value || !tableContainer.value) return false
    const { scrollTop, offsetHeight, scrollHeight } =
      tableContainer.value as HTMLElement
    let result = scrollTop + offsetHeight < scrollHeight
    isFixed.value = result
    return result
  }

  /**
   * @description: 初始化滑动条
   */
  const initScroll = () => {
    let sc = document.querySelector('.el-scrollbar__bar') as HTMLElement
    let header = document.querySelector('.el-table__header-wrapper')
    if (sc) {
      elScrollBarH.value = sc
    }
    if (header) {
      tableHeaderRef.value = header as HTMLElement
    }
  }

  /**
   * @description: 设置横向滑动条的位置和表头的位置
   * 只有滑动位置到达表格内部时,才需要固定横向滑动条,否则让滚动条回到原来的初始位置,即表格的底部
   * 只有滚动条超出表头的原本位置时,才固定表头
   */
  const setScrollAndHeader = () => {
    setScrollPos(isInTable())
    if (tableHeaderRef.value) {
      const { scrollTop } = tableContainer.value as HTMLElement
      let contentOffsetTop = tableContent.value!.offsetTop // 父容器距离顶部的高度
      let contentScrollHeight = tableContent.value!.scrollHeight // 整个父容器的高度
      // 超出表头原本位置时且小于整个表的高度,则固定表头
      if (
        scrollTop >= contentOffsetTop &&
        scrollTop <= contentOffsetTop + contentScrollHeight
      ) {
        tableHeaderRef.value.style.position = 'fixed'
        tableHeaderRef.value.style.top = `${tableContainer.value?.getBoundingClientRect().top}px`
        tableHeaderRef.value.style.zIndex = '666'
      } else {
        // 还原
        // 不设置top是因为在static下,top无效
        tableHeaderRef.value.style.position = 'static'
        tableHeaderRef.value.style.zIndex = '1'
      }
    }
  }

  /**
   * 寻找最近的拥有滚动条的父元素
   * @param el 当前元素
   */
  const findScrollParent = (el: HTMLElement): HTMLElement | null => {
    let parent = el.parentElement

    while (parent) {
      const style = window.getComputedStyle(parent)
      const overflowY = style.overflowY

      // 判断标准:
      // 1. 是可见容器(高度不为0)
      // 2. 包含滚动条(内容溢出或强制显示)
      if (
        parent.clientHeight > 0 &&
        (overflowY === 'scroll' ||
          overflowY === 'auto' ||
          (overflowY === 'visible' &&
            parent.scrollHeight > parent.clientHeight))
      ) {
        return parent
      }

      parent = parent.parentElement
    }
    return null

    // // 未找到则返回window,
    // return window
  }

  return {
    initScroll,
    setScrollAndHeader,
    obScroll,
    setScrollPos,
    findScrollParent,
  }
}

// table.vue
<script>

interface ScrollHandler {
  cleanup: () => void
  rafId?: number
}

// 使用 WeakMap 自动管理清理函数
const scrollHandlers = new WeakMap<HTMLElement, ScrollHandler>()

// 自定义指令
const vScrollParent = {
  mounted(
    el: HTMLElement,
    binding: DirectiveBinding<(container: HTMLElement) => () => void>,
  ) {
    // 获取最近的滚动容器
    const scrollContainer = findScrollParent(el)
    if (scrollContainer === null) {
      return
    }

    // 执行回调获取清理函数
    const cleanup = binding.value(scrollContainer)

    // 关联清理函数到元素
    scrollHandlers.set(el, { cleanup })
  },

  beforeUnmount(el: HTMLElement) {
    // 执行清理操作
    const handler = scrollHandlers.get(el)
    handler?.cleanup()
    scrollHandlers.delete(el)
  },
}

// 滚动处理函数
const handleScrollParent = (scrollContainer: HTMLElement) => {
  // 清理旧的监听(当容器变化时)
  const prevContainer = tableContainer.value
  if (prevContainer && prevContainer !== scrollContainer) {
    const prevHandler = scrollHandlers.get(prevContainer)
    prevHandler?.cleanup()
    scrollHandlers.delete(prevContainer)
  }

  // 如果已经是当前容器,直接返回
  if (tableContainer.value === scrollContainer) {
    return () => {}
  }

  // 节流控制
  let ticking = false
  let rafId: number

  // 事件处理器
  const handler = () => {
    if (!ticking) {
      ticking = true
      rafId = window.requestAnimationFrame(() => {
        setScrollAndHeader()
        ticking = false
      })
    }
  }

  // 清理函数
  const cleanup = () => {
    scrollContainer.removeEventListener('scroll', handler)
    if (rafId) window.cancelAnimationFrame(rafId)
    if (tableContainer.value === scrollContainer) {
      tableContainer.value = null
    }
  }

  // 绑定事件
  scrollContainer.addEventListener('scroll', handler, { passive: true })
  tableContainer.value = scrollContainer

  // 保存引用
  scrollHandlers.set(scrollContainer, { cleanup })

  return cleanup
}



// 清理逻辑
const cleanup = () => {
  // 1. 移除 IntersectionObserver
  if (tableContent.value) {
    tableVisOb.unobserve(tableContent.value)
  }
  if (tableContainer.value) {
    tableSizeOb.unobserve(tableContainer.value)
  }

  // 2. 完全断开观察器(可选)
  tableVisOb.disconnect()
  tableSizeOb.disconnect()

  // 3. 清理其他资源
  // window.removeEventListener('scroll', handleScroll)
}

onMounted(() => {
  initScroll()
  initPagination()
  tableVisOb.observe(tableContent.value as HTMLElement)
  tableSizeOb.observe(tableContainer.value as HTMLElement)
})

onUnmounted(() => {
  cleanup()
})
</script>

<template>

<div class="tableContainer" v-scroll-parent="handleScrollParent">
	...content
</div>    
</template>

最终效果

自定义固定表头列.gif