彻底解决PC滚动穿透问题
背景:
之前在做需求的时候,产品有提到一个bug,说是在某些情况下不应该触发外部滚动条滚动,例如鼠标在气泡框的内部就不能产生外部滚动条滚动,这样会影响用户的体验
原效果:

禁止滚动穿透之后效果:

可以看到浏览器的默认行为就是会滚动穿透的,因此我也查找了一些解决方案,但是似乎效果都不太理想,例如最简单有效的方式就是通过设置一个css属性来解决这个问题
overscroll-behavior: contain;
但是呢这个属性实现的效果并不完美,以上方示例的黄色滚动区域为例,如果黄色区域是可以滚动的,那么该属性有效,如果黄色区域不可以滚动,该属性就会失效了,所以没办法只能另寻他法,用JS去解决这个问题
原理:通过监听目标元素内部的滚轮事件,如果内部还有元素可滚动则不做处理,如果内部元素已无法滚动,则禁止滚轮事件冒泡至外部,从而导致无法触发外部滚动条滚动的行为
以下是整体代码,我封装了一个VUE版本的通用HOOKS函数,具体实现大家可参考代码,希望给大家带来帮助!
import { isRef, onMounted, onUnmounted, nextTick } from "vue"
import type { Ref } from "vue"
/**
* 可解析为 DOM 元素的数据源类型
* - 选择器字符串 | Ref | 返回dom函数
*/
type TElement<T> = string | Ref<T> | (() => T)
/**
* HOOKS: 使用滚动隔离
*
* @author dyb-dev
* @date 21/06/2025/ 14:20:34
* @param {(TElement<HTMLElement | HTMLElement[]>)} target 目标元素 `[选择器字符串 | ref对象 | 返回dom函数]`
* @param {TElement<HTMLElement>} [scope=() => document.documentElement] 作用域元素(注意:目标元素为 `选择器字符串` 才奏效) `[选择器字符串 | ref对象 | 返回dom函数]`
*/
export const useScrollIsolate = (
target: TElement<HTMLElement | HTMLElement[]>,
scope: TElement<HTMLElement> = () => document.documentElement
) => {
/** LET: 当前绑定监听器的目标元素列表 */
let _targetElementList: HTMLElement[] = []
/** HOOKS: 挂载钩子 */
onMounted(async() => {
await nextTick()
// 获取目标元素列表
_targetElementList = _getTargetElementList()
// 遍历绑定 `滚轮` 事件
_targetElementList.forEach(_element => {
_element.addEventListener("wheel", _onWheel, { passive: false })
})
})
/** HOOKS: 卸载钩子 */
onUnmounted(() => {
_targetElementList.forEach(_element => {
_element.removeEventListener("wheel", _onWheel)
})
})
/**
* FUN: 获取目标元素列表
* - 字符串时基于作用域选择器查找
*
* @returns {HTMLElement[]} 目标元素列表
*/
const _getTargetElementList = (): HTMLElement[] => {
let _getter: () => unknown
if (typeof target === "string") {
_getter = () => {
const _scopeElement = _getScopeElement()
return _scopeElement ? [..._scopeElement.querySelectorAll(target)] : []
}
}
else {
_getter = _createGetter(target)
}
const _result = _getter()
const _normalized = Array.isArray(_result) ? _result : [_result]
return _normalized.filter(_node => _node instanceof HTMLElement)
}
/**
* FUN: 获取作用域元素(scope)
* - 字符串时使用 querySelector
*
* @returns {HTMLElement | null} 作用域元素
*/
const _getScopeElement = (): HTMLElement | null => {
let _getter: () => unknown
if (typeof scope === "string") {
_getter = () => document.querySelector(scope)
}
else {
_getter = _createGetter(scope)
}
const _result = _getter()
return _result instanceof HTMLElement ? _result : null
}
/**
* FUN: 创建公共 getter 函数
* - 支持 Ref、函数、直接值
*
* @param {unknown} target 目标元素
* @returns {(() => unknown)} 公共 getter 函数
*/
const _createGetter = (target: unknown): (() => unknown) => {
if (isRef(target)) {
return () => (target as Ref<unknown>).value
}
if (typeof target === "function") {
return target as () => unknown
}
return () => target
}
/**
* FUN: 监听滚轮事件
*
* @param {WheelEvent} event 滚轮事件
*/
const _onWheel = (event: WheelEvent) => {
const { target, currentTarget, deltaY } = event
let _element = target as HTMLElement
while (_element) {
// 启用滚动时
if (_isScrollEnabled(_element)) {
// 无法在当前滚动方向上继续滚动时
if (!_isScrollFurther(_element, deltaY)) {
event.preventDefault()
}
return
}
// 向上查找不到滚动元素且到达当前目标元素边界时
if (_element === currentTarget) {
event.preventDefault()
return
}
_element = _element.parentElement as HTMLElement
}
}
/**
* FUN: 是否启用滚动
*
* @param {HTMLElement} element 目标元素
* @returns {boolean} 是否启用滚动
*/
const _isScrollEnabled = (element: HTMLElement): boolean =>
/(auto|scroll)/.test(getComputedStyle(element).overflowY) && element.scrollHeight > element.clientHeight
/**
* FUN: 是否能够在当前滚动方向上继续滚动
*
* @param {HTMLElement} element 目标元素
* @param {number} deltaY 滚动方向
* @returns {boolean} 是否能够在当前滚动方向上继续滚动
*/
const _isScrollFurther = (element: HTMLElement, deltaY: number): boolean => {
/** 是否向下滚动 */
const _isScrollingDown = deltaY > 0
/** 是否向上滚动 */
const _isScrollingUp = deltaY < 0
const { scrollTop, scrollHeight, clientHeight } = element
/** 是否已到顶部 */
const _isAtTop = scrollTop === 0
/** 是否已到底部 */
const _isAtBottom = scrollTop + clientHeight >= scrollHeight - 1
/** 是否还能向下滚动 */
const _willScrollDown = _isScrollingDown && !_isAtBottom
/** 是否还能向上滚动 */
const _willScrollUp = _isScrollingUp && !_isAtTop
return _willScrollDown || _willScrollUp
}
}
作者:dyb
来源:juejin.cn/post/7519695901289267254
来源:juejin.cn/post/7519695901289267254