为什么 scroll 事件无法被 e.preventDefault 阻止?

1,161 阅读8分钟

一. 背景介绍

今天在开发的时候遇到一个问题,大致场景是:在 PC 端我需要写一个全局的遮罩组件,这个遮罩会弹出一个对话框,让用户选择确定和取消,也是平常十分常见的了,但是此时用户滚动界面的时候,就出现了遮罩层和内容层出现了断格。在解决这个问题的过程中,顺带把 addEventListener 的选项参数 passive 给彻底搞明白了,也是书写本文的契机。

🚀 TL;DR:直接跳转至标题五查看结论。

二. 场景再现

  1. 首先我们写一个普通背景页面,代码也十分简单,这里样式我使用了 Unocss ,但是今天样式不是我们的重点,不影响你对本文的整体理解。下面的代码对应起来就是一个简单的蓝色背景,这里就不过多介绍了。 image.png

  2. 接下来就该写遮罩层了,这里由于界面十分简单,只需要弹出一个对话框让用户进行选择即可,所以我采用了写宽高都继承父元素的一个绝对定位的元素,来覆盖整个区域。 image.png 这样我们就可以通过 v-show 来控制遮罩层的显示和关闭。目前的效果: image.png

  3. ps: 我知道这种方法很简陋,但是本文的目的并不是编写组件,而是想通过这样的方式让读者更简单的代入我当时遇到场景时的解题思路🎁。

  4. 目前看起来没什么问题,但是我们忽略了当主题内容溢出时,我们的对话框是存在 bug 的。什么意思?让我们创造一个内容溢出的场景。这里我们创建了一个高度为 3000pxdiv,并且父元素设置了overflow-scroll
    image.png
    我们暂时把 isShow设置为 false,目前对应的界面是这样的:
    1.gif

  5. 一切都很正常对吧?但是此时如果用户点击了某个按钮,将 isShow 设为 true,对话框此时弹起,就会发生预期之外的事情。
    2.gif

三. 我脑海的第一想法

  1. 关于如何实现背景不跟随移动,我相信大家已经看到过很多文章,比如动态设置我们父元素的样式,使其在这两个类名之间动态切换。比如下面这样:

    <---使用 js 动态切换 no-scroll 和 can-scroll-->
    <div class="no-scroll w-full h-full bg-blue relative border flex centered">
    

    image.png

  2. 但最开始碰到这个场景的时候,我其实下意识的想到的是 scroll 事件,这不就是滚动嘛,滚动不就是 scroll 负责的吗?

  3. 于是我就按照往常我们禁用 click 事件的那样,把事件的默认行为用 e.preventDefault() 给取消掉,这样 scroll 应该就不会发生了吧?于是我便在打开开关的时候,给外层的 wrapper 添加了下面的代码。
    image.png

  4. 于是我自信满满的打开控制台测试效果。
    4.gif

  5. 控制台输出的日志告诉我并不是代码没有执行。那是为什么呢?🤔然后我突然想到之前好像在某篇文章中看到过一个 addEventListener 参数,好像会影响页面滚动的逻辑,好像是叫什么 passive 来着,于是我翻开 MDN 的相关链接。
    image.png

  6. 这个属性我之前一直没搞懂啥意思,但是隐约记得在某个文章里说过这个配置和浏览器滚动有点暧昧关系。反正就是试一试呗,又不要什么成本,于是我添加了这个配置。MDN 介绍 passive:true 的时候,即使调用了 preventDefault 也不会生效,ok,那我设置为 passive:false
    image.png
    接着我信心满满的去打开控制台:
    5.gif

四. passive 值的含义

  1. 看来靠猜确实不靠谱,还真得搞明白这个 passive 到底指的什么意思才可以。

  2. 首先这个单词本身的意思是 被动的;消极的,但问题是在这个场景下我也没看出来到底谁被动谁消极啊!

  3. 于是我便进一步查询相关资料,终于发现了一篇 chrome 开发人员日志的文章,其中有一段这样描述。
    image.png

    大致意思就是:在早期没有这个属性值的时候,当页面发生滚动(原文指的是移动端),浏览器不知道 touch 事件是否要取消滚动的发生,于是就需要每次 touch 事件发生以后,都要去判断一次用户本次操作是否有可能取消 scroll 事件。

  4. 这种做法对于特殊场景是有必要的,这个特殊场景就是我真的是有意需要禁止页面滚动,那么你这次询问对我来说就是有用的。但是我们都知道,在移动端这种每时每刻都需要大量进行触摸交互的场合来讲,每发生一次 touch 事件就判断一次是否可能要停止滚动,势必会影响浏览器性能且可能会引起界面卡顿。于是就有了下面的解释。
    image.png

    大致意思:后来 chrome 团队就做了一组数据调查。他们发现80%的情况下,开发者是不会阻止默认滚动行为发生的,于是他们就加了 passive 这个属性,并且默认值为 true。代表的意思就是我们在为某个元素添加 event 的时候,保证不会主动调 preventDefault 来阻止默认行为。

  5. 这其实也就是你在有些情况下,可能会在浏览器看到下面错误的原因。
    image.png 因为你显式地设置了 passive:true 的情况下,依旧固执的调用 e.preventDefault,此时浏览器也不知道你到底要怎样,于是只能它为了性能考虑,觉得你代码写错了,猜测你应该是错误的调用了 e.preventDefualt ,所以它会无视你的这行代码,并且在控制台警告你:“bro,太迟了,你已经无法阻止我了!!
    image.png

  6. 现在我们算是把 passive 彻底搞明白了,但诡异的事情发生了,我在标题三第四节做的操作明明不就是把 passive 设置为了 false,然后才去调用的 e.preventDefault 的吗?为什么还是没生效呢?🤔

  7. 如果你看的仔细,在这篇文章中间部分,这位开发者已经解释了这个现象。
    image.png

    scroll 事件是无法被取消的,所以无论你怎样设置 passive 的值都是无效的。

  8. 看来我们选择以 scroll 事件来完成这个停止背景滚动的需求,一开始就是错误的,我们不得不转变思路。

  9. 依旧是这篇 chrome 开发者日志,无意间我看到了底部的相关链接,其中有一篇更加详细介绍 passive 设计目的文章中有这样一段描述。
    image.png

  10. 这引起了我极大兴趣,难道是问题的关键是 wheel 事件?

五. wheel event和 scroll event的关系

  1. wheel 不是就是滚轮事件嘛?和 scroll 有啥关系?等等,我好像忽略了我是如何将页面滚动的! 因为用手去滑动滚轮这件事太过于条件反射,以至于我压根没注意到一个事实:是先有了我操作滚轮的动作,才有了界面的滚动。那么是否有可能是关键点是在 wheel 上而不是 scroll 上呢?

  2. 于是我赶紧编写了下面的代码来验证我的猜想。
    image.png
    果然 wheel 事件是要比 scroll 发生的早的!
    6.gif

  3. 那么如果我在弹框出现的时候,将 wheel 调用 e.preventDefault 是否能让滚动停止呢?
    image.png
    果然滚动正确被禁止了,效果如下:
    7.gif

  4. 此处我们得出了结论:

    scroll 事件并非是滚动“起因”,它触发的时机是“滚动”这个行为已经发生之后,所以你无法再一个已经发生的事情上去阻止它的默认行为。如果你要阻止“滚动”,那么你应该阻止“滚动”发生之前的事件也就是 wheel

  5. 于是我们就可以组织以下的代码:

      1. 因为我们要动态的添加和移除相关的事件,所以不能使用匿名函数,所以我们首先要做的就是把之前的 e.preventDefault 封装到一个函数内叫做 banScroll
      1. dialog 开启的时候,我们给 wrapper 添加一个事件监听器 banScroll
      1. dialog 关闭的时候,移除 wrapper 的事件监听器。 image.png
        与之对应的效果如下:
        8.gif
  6. 注意:PC 端对应的是 wheel,移动端对应的是 touch 事件

六.源码


<script lang="ts" setup>
import { ref } from "vue";
const isShow = ref(false);

function banScroll(e: Event) {
  e.preventDefault();
  console.log("wheel 发生");
}

function openDialog() {
  isShow.value = true;
  const wrapper = document.getElementById("wrapper");
  wrapper.addEventListener("wheel", banScroll, {
    passive: false
  });

  wrapper.addEventListener("scroll", () => {
    console.log("滚动发生");
  });
}

function closeDialog() {
  isShow.value = false;
  const wrapper = document.getElementById("wrapper");
  wrapper.removeEventListener("wheel", banScroll);
}
</script>
<template>
  <div
    @click="closeDialog"
    id="wrapper"
    class="w-full h-full overflow-scroll bg-blue relative border flex centered">
    <!-- 用背景颜色 50% 的透明度来模拟遮罩层 -->

    <div
      @click.stop="openDialog"
      class="text-50px text-red w-50px h-3000px bg-yellow flex centered">
      <span class="text-40px text-black">
        这是一篇介绍 scroll 为什么无法被阻止的文章---韩振方
      </span>
    </div>

    <div
      v-show="isShow"
      class="absolute w-full h-full top-0 bg-black/50 flex justify-center">
      <div class="w-300px h-300px bg-white flex mt-100px">
        <span class="text-50px text-500 text-black">这是对话框</span>
      </div>
    </div>
  </div>
</template>

<style scoped>
no-scroll {
  overflow: hidden;
}

can-scroll {
  overflow: scroll;
}
</style>

七. 结语

本文的真实目的并非是去解决阻止背景滚动的这个需求,我相信站内已经有很多很多比我考虑更好的方案。 我更多想表达的其实是通过一个问题去 get 到自己从未设想方向的新知识后那种喜悦感,真的很让人上瘾。

本文引用的相关链接:
chrome 开发日志:为什么需要 passive属性
passive是如何影响 wheel 事件和 touch 事件的