overscroll-behavior

在移动端 H5 页面或部分 Web 组件的开发中,开发者们大概率遭遇过这样一个让人抓狂的场景: 页面中心弹出了一个小小的滚动弹窗(Modal),用户在弹窗里专心往下划。当弹窗内容到底了,如果手指还没离开屏幕继续往下扒拉——结果整个背后的页面内容开始跟着疯狂滚动!

这种因为局部滚动到头,导致把滚动事件“击穿”传递给下层父容器的现象,被称作滚动穿透(Scroll Chaining/Scroll Bleeding)

在过去,为了制止这种“手贱”引起的联动反应,前端先驱们写出了无数篇名为《移动端彻底阻止弹窗滚动穿透》的文章。方案可谓惨烈:从通过 JS 监听 touchmove 并暴力 e.preventDefault(),到动态给 body 加 fixed 然后依靠 JS 算高度把页面死死钉住……不仅代码恶心,往往还会导致部分浏览器的原生弹性效果彻底报废。

直到 overscroll-behavior 带着圣光降临,几行 CSS 就优雅地平息了这一切。

浏览器的边界执念

我们要先理解,为什么浏览器非要触发这种“穿透”?

当我们用手指或者触控板滚动一个区域到达它的“边界(极值)”时,浏览器默认有两个连击动作:

  1. Scroll Chaining(滚动链传递):当前的地方滚不动了?没关系,我把你的滚动意图冒泡给你身后的父级有滚动条的祖宗容器,让它帮你滚。
  2. 触发原生手势反馈:如果连最外层也滚不动了,浏览器就会搬出系统级的花活——比如 iOS 那非常著名的果冻橡皮筋拉拽效果,或者 Android 浏览器的边缘发光效果,甚至是触发“下拉刷新”。

overscroll-behavior 属性诞生的使命,就是赐予我们没收浏览器这两个默认行为的权力。

三大核心形态解析

它主要有三个核心属性值:

1.auto(顺其自然,默认值)

一切照旧。内容滚到底部继续拉拉扯扯,就会理所当然地触发父容器的滚动链传递,并且该有橡皮筋特效的时候绝不含糊。

2.contain(锁住传导,保留特效)

这是解决滚动穿透的最佳利器! 只要在这个局部滚动的容器上加上 overscroll-behavior: contain;,它就像是被套上了一层隔绝结界: 当用户在这个容器里滚到底时,浏览器会阻断滚动链的向外传递(底下的页面绝对不会被连累),但也给用户留足了面子,依然保留诸如橡皮筋回弹的原生视觉反馈

3.none(物理阉割,特效全无)

这招更绝。它不仅像 contain 一样阻断了向外传播的滚动链,还顺带着把这个容器边界上所有的默认原生动作(如回弹反馈、Android 边缘发光、浏览器的下拉刷新)拔了个精光。 当你要做一个完全依靠自己内部逻辑计算滑动的重交互网页应用(比如网页版地图规划工具,或者极其拟态原生的自定义组件)时,使用 none 可以彻底封杀掉系统级的干扰。

实战演练:告别坑爹体验

让我们通过两个可以直观交互的 Demo,来感受这股神秘的东方力量。

场景一:弹窗中的滚动穿透 (使用 contain 拦截)

这是极其多见的业务 Bug:当用户专注在弹窗内的协议条款狂拉到底时,身后的长列表竟然背着用户也拉到底了。加上 overscroll-behavior: contain 就能锁住这个不安分的小盒子。

Bad Case vs Good Case 对比
import React from "react";

export default () => (
  // 外层包裹器模拟超长的 body 背景页面
  <div style={{ height: "300px", overflowY: "auto", background: "#ccc", padding: "20px", border: "2px solid black" }}>
    <h3 style={{ margin: "0 0 20px 0" }}>假装这里是无止尽的背景长页面</h3>
    <div style={{ height: "600px", background: "linear-gradient(#e66465, #9198e5)", padding: "10px" }}>
      <div style={{ display: "flex", gap: "20px" }}>
        {/* ❌ 错误案例:发生穿透 */}
        <div style={{ flex: 1, height: "150px", overflowY: "auto", background: "white", padding: "10px", borderRadius: "8px" }}>
          <h4>❌ 默认(auto)</h4>
          <p>尝试在这里面往下猛滑</p>
          <div style={{ height: "200px", background: "#f5f5f5" }}>
            <p style={{ paddingTop: "150px" }}>滑到底了!继续用力滑!</p>
            <p>糟糕,外面的渐变背景被连累拖下去了!</p>
          </div>
        </div>

        {/* ✅ 正确案例:使用 contain 拦截 */}
        <div
          style={{
            flex: 1,
            height: "150px",
            overflowY: "auto",
            background: "white",
            padding: "10px",
            borderRadius: "8px",
            overscrollBehavior: "contain" /* 魔法在这里 */,
          }}
        >
          <h4>✅ 加了 contain</h4>
          <p>尝试在这里面往下猛滑</p>
          <div style={{ height: "200px", background: "#e0ffe0" }}>
            <p style={{ paddingTop: "150px" }}>滑到底了!随便你再怎么划断手!</p>
            <p>外表页面稳如老狗,绝不穿透。</p>
          </div>
        </div>
      </div>
    </div>
  </div>
);

场景二:禁用 PWA 网页/全屏 H5 的下拉刷新 (使用 none 阉割)

如果你正在写一个像模像样的 Web App 工具(比如移动版石墨文档),上面有一个精致的原生贴靠式的 Header 工具栏。 非常尴尬的是,只要用户随便在页面顶端手一划,整个屏幕就被类似微信浏览器的“网页由xxx提供”的深色底纹或者是浏览器的漏斗刷新圈给拽了下来,极其出戏。

此时你不需要找各种偏门的 JS 手势拦截库了,直接在最外围(如 body)挂上 none 即可。

/* 彻底封闭浏览器的边缘回弹手势动作,适合极高拟真度的全屏 WebApp */
body {
  overscroll-behavior-y: none;
}

高频面试题剖析

Q:在移动端或浮层组件上,如何处理滚动穿透的问题?

回答思路:

  1. 定性问题:明确滚动穿透是因为浏览器在局部滚动到达临界值后,默认启用了事件的冒泡连结机制拖动了下方的父容器。
  2. 吐槽旧时代:以前需要通过监听 .addEventListener('touchmove', preventDefault, {passive: false}) 或通过动态给 body 打上 fixedtop 偏移量进行暴力控制,维护成本极高,还容易丧失丝滑性。
  3. 主推当代王牌:甩出 overscroll-behavior。指出在滚动的弹层容器上直接设置 overscroll-behavior: contain; 即可完美截断传递,且不破坏自身的阻尼回弹 UI 体验。
  4. 展露场景厚度:进一步补充该属性还可以利用 none 参数彻底阉割某些场景下烦人的浏览器原生下拉菜单或回弹刷新(比如打造沉浸式 Web Application 工具时非常有效)。明确表示这是纯 CSS 层面成本极低的防具。