彻底解决PC滚动穿透问题

2,363 阅读3分钟

背景:
之前在做需求的时候,产品有提到一个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

    }

}