scroll事件会失效原因

Aditya2022-04-15前端JavaScript

scroll事件会失效原因

在Web开发过程中,或多或少都会使用到Scroll事件,用来做上拉加载、下拉刷新或者是延迟加载等等。
我在使用Scroll事件过程中经常遇到的问题必然是##Scroll为什么会失效##,解决方案无非是万能捕获阶段监听、overflow、高度问题,没有去深入了解产生原因是什么。
所以,趁着闲暇时间知其然知其所以然。

一点点从头捋一遍

JavaScript事件模型

JavaScript的事件模型有三种:

  • DOM0事件模型(原始)
  • DOM2事件模型
  • IE事件模型

目前我们所熟知的事件模型是DOM2事件模型,也是我们重点讲的地方,它新增了冒泡和捕获概念,事件流也就有了三个阶段:捕获阶段-目标阶段-冒泡阶段

捕获阶段是事件从document到传递到目标元素的过程,而冒泡阶段是事件从目标元素传递到document的过程。

image

在平时,我们一般是监听事件的冒泡阶段,即elem.addEventListener('scroll', handler)。Vue中也提供了v-on指令监听某个事件,默认用的也是事件的冒泡阶段。

但是根据MDN对scroll事件的描述,发现了以前忽略的点:

冒泡: elementscroll事件不冒泡, 但是document的defaultViewscroll事件冒泡

这句话的意思就是说,如果scroll的目标元素是一个元素的话,比如说是一个div元素。那么此时事件只有从documentdiv的捕获阶段以及div的冒泡阶段。如果尝试在父级监视scroll的冒泡阶段监视这一事件是无效的。
如果scroll是由document.defaultView(目前document关联的window对象)产生的有冒泡阶段。但是由于其本身就是DOM树里最顶级的对象,因此只能在window里监视scroll的捕获阶段以及冒泡阶段。

当scroll的目标元素为document.defaultView时

在最常见的页面结构中来测试:

<body>
  <div id="root">
    <p>Hello world</p>
    <p>Hello world</p>
    ....
  </div>
  <script>
      window.addEventListener('scroll', e => console.log('window, capture'), true)
    window.addEventListener('scroll', e => console.log('window,bubble'))
    
    const root = document.getElementById('root')
    root.addEventListener('scroll', e => console.log('root, capture'), true)
    root.addEventListener('scroll', e => console.log('root,bubble'))
  </script>
</body>

在控制台打印

"window, capture"
"window,bubble"
"window, capture"
"window,bubble"
......

可以看到在全程只有window上监听scroll的捕获阶段以及冒泡阶段的回调函数执行了。这验证之前的结论:

果scroll是由document.defaultView(目前document关联的window对象)产生的有冒泡阶段。但是由于其本身就是DOM树里最顶级的对象,因此只能在window里监视scroll的捕获阶段以及冒泡阶段。

当scroll的目标元素是元素时

一个双栏布局,左侧为导航栏,右侧为内容,父容器使用Flex布局,要求导航栏不随内容的滑动而滑动。HTML文档如下:

<!DOCTYPE html>
<html>

<head>
  <title>Scroll In Element</title>
  <style>
    body {
      margin: 0;
    }

    #wrapper {
      width: 100%;
      height: 100vh;
      display: flex;
      overflow: hidden;
    }

    #left-col {
      overflow: hidden;
      flex: 0 0 300px;
      background-color: aqua;
    }

    #right-col {
      flex: 1;
      overflow: auto;
    }
  </style>
</head>

<body>
  <div id="wrapper">
    <div id="left-col">
      <nav>
        barabara
      </nav>
    </div>
    <div id="right-col">
      <p>Hello world</p>
      <p>Hello world</p>
      ......
    </div>
  </div>
  <script>
    const log = (elem, phase) => {
      console.log(`${elem}, ${phase}`)
    }
    
    const bindEvent = (elem, elemName)  => {
      elem.addEventListener('scroll', log.bind(null, elemName, 'capture'), true)
      elem.addEventListener('scroll', log.bind(null, elemName, 'bubble'))
    }
    
    bindEvent(window, 'window')
    const wrapper = document.querySelector('#wrapper')
    bindEvent(wrapper, 'wrapper')
    const rightCol = document.querySelector('#right-col')
    bindEvent(rightCol, 'rightCol')
  </script>
</body>

</html>

此时scroll事件的目标元素是div#right-col。接下来我们,分别在window,div#wrapper,div#right-col上监听scroll的捕获阶段以及冒泡阶段。

在控制台打印:

"window, capture"
"wrapper, capture"
"rightCol, capture"
"rightCol, bubble"
"window, capture"
"wrapper, capture"
"rightCol, capture"
"rightCol, bubble"

如果scroll的目标元素是一个元素的话,比如说是一个div元素。那么此时事件只有从document到div的捕获阶段以及div的冒泡阶段

总结

综上所述,在使用scroll的时候,要思考以下两个点:

  1. scroll事件的目标元素是什么?也可以说谁产生了scroll事件。
  2. 监听scroll事件的元素是否在scroll事件的传递路径上,是否监听了正确的阶段?

其他相关问题

在Vue中子组件scroll失效问题

直接在子组件mounted里addEventListener进行监听

 window.addEventListener('scroll', this.scrollEvent)

运用上述总结,先分析当前目标元素与目标元素是否在scroll事件传递路上。
我们很容易就分析出失效的原因,当前监听的是顶层window冒泡,目标元素是子组件,所以子组件的滚动根本不会被监听到。

解决方案
  • this.$refs.childPage.addEventListener('scroll', this.scrollEvent)
  • @scroll="scrollEvent"
  • window.addEventListener('scroll', this.scrollEvent, true),万金油写法,在捕获阶段执行

未设置overflow:scroll / auto

// emmmm

未设置高度

在不确定父级高度一定情况下,使用*height:100%*也会导致scroll失效,稳妥的方案是100vh赋值,或者挨个检查父元素

解除scroll绑定失效问题

之前我就遇到了这个问题,甚至百思不得其解。
至于原因也很简单,捕获阶段添加了监听,我在冒泡阶段移除绑定。

window.addEventListener('scroll', this.scrollEvent, true)

// 错误示范
// window.removeEventListener('scroll', this.scrollEvent)

window.removeEventListener('scroll', this.scrollEvent, true)

v-on:scroll.passive

<!-- 滚动事件的默认行为 (即滚动行为) 将会立即触发 -->
<!-- 而不会等待 `onScroll` 完成  -->
<!-- 这其中包含 `event.preventDefault()` 的情况 -->
<div v-on:scroll.passive="onScroll">...</div>

我的理解滚动事件默认触发一次,不用在手动调用一次scroll方法

getBoundingClientRect

MDN解释

Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。

我用该API来获取元素位置信息

    const isIn = (el) => {
        let bound = el.getBoundingClientRect()
        const clientHeight = window.innerHeight
        return bound.top <= clientHeight
      }

使用 throttle 控制触发频率

引入 lodash:

import _ from 'lodash'

然后限制触发间隔为 500 ms:

window.addEventListener('scroll', _.throttle(() => {
    let windowHeight = document.documentElement.clientHeight||window.innerHeight
    let scrollTop = document.documentElement.scrollTop || document.body.scrollTop
    let windowBottom = +scrollTop + +windowHeight
    let selfTop = _.get(this.$refs, 'selfEcharts.offsetTop', 0)
    if(windowBottom >= selfTop){
        this.checkAndSetOption()
    }
}, 500))
解绑事件

若想用 document.removeEventListener() 解绑事件,首先我们要抽离事件本身,将匿名函数转为实名函数。 为了保证 addEventListener 和 removeEventListener 时操作的是同一个函数,这里我们使用 data 添加实名函数:

data() {
    return {
        scrollEvent:  _.throttle(this.checkPosition, 500)
    }
},
mounted () {
    window.addEventListener('scroll', this.scrollEvent)
},
methods: {
    checkAndSetOption() {
        let option = this.option
        if (isValidOption(option)) {
            this.myEcharts.setOption(option)
            this.isOptionAbnormal = false
        } else {
            this.isOptionAbnormal = true
        }
    }
},
  beforeDestroy() {
    window.removeEventListener('scroll', this.scrollEvent, true)
  }

参考

Last Updated 2024/12/27 11:36:49