Element plus 跟随页面的横向滚动条及固定表头
更新
-
25.3.29 更新:
对于寻找比滚动条外部容器的方法进行修改,现在会寻找距离整个组件最近的拥有滚动条的元素。这样可以避免出现如果界面不止表格组件,还有其他组件并且长度超出屏幕后,需要两个滚动条才能完整滑动的问题
问题
- 原生的el-table组件的固定表头必须要设置高度,当表格高度需要动态变化的时候该选项无效。
- 固定列之后出现横向滚动条,但该滚动条也无法跟随页面移动只能固定在表格底部,当数据过多时,无法直接操作。
分析
目前滚动条的解决方案是两种:
- 手搓滚动条,过于繁琐了
- 第三方组件,目前没找到可以用的
固定表头的方案是动态的计算max-height,理论是可以的,但在我的项目无效
解决方案
滚动条
我的整体思想是在不破坏原有滚动条的情况下,更改它的定位来实现随页面滚动
它的滚动条的定位是当通过position:absolute定位到表格底部,以及display来控制显示与否。
属性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>