1. 程式人生 > 實用技巧 >解析移動端滾動穿透

解析移動端滾動穿透

滾動穿透在移動端開發中是一個很常見的問題,產生詭異的互動行為,影響使用者體驗,同時也讓我們的產品看起來不那麼“專業”。雖然不少產品選擇容忍了這樣的行為,但是作為追求極致的工程師,應該去了解為什麼會產生以及如何去解決。

什麼是滾動穿透

移動端開發中避免不了會在頁面上進行彈窗、加浮層等這種操作。一個最常見的場景就是整個頁面上有一個遮罩層,上面畫著各種各樣的東西,具體是什麼就不討論。實現這樣一個遮罩層可難不住即使是一個剛開始寫前端的小白。但是這裡有一個問題就是如果不對遮罩層做任何處理,當用戶在上面滑動時會發現遮罩層下方的頁面居然也在滾動,這就很 interesting 了。就如下面的例子,一個名為mask

長寬都是螢幕大小的遮罩層,我們在上面滑動時,下面的內容也在跟隨滾動,即滾動“穿透”到了下方,這就是滾動穿透(scroll-chaining)。

上方 demo 的遮罩層底部是一個逐漸變藍的內容容器,但是滑動上面遮罩層時,底部也跟隨滾動了,這只是一個最簡單的場景,後面我們會討論更復雜的情況。

為什麼會出現

目前 Google 上搜滾動穿透會出現一大堆教你如何解決的文章,但是它們都是在告訴你怎麼解決怎麼 hack 掉這種互動異常。並沒有告訴讀者為什麼會產生這種行為,甚至認為這是瀏覽器的一個 bug。對於我來說這個是難以理解的,因為就算解決了問題,其實也並不知道問題的根本是怎樣的。

認知誤區

有一個誤區就是我們設定了一個和螢幕一樣大小的遮罩層,蓋住了下面的內容,按理說我們應該能遮蔽掉下方的所有事件也就是說不可能觸發下面內容的滾動。那麼我們就去看一下規範,什麼時候會觸發滾動。

//https://www.w3.org/TR/2016/WD-cssom-view-1-20160317/#scrolling-events
When asked to run the scroll steps for a Document doc, run these steps:

  1. For each item target in doc’s pending scroll event targets, in the order they were added to the list, run these substeps:
  • If target is a Document, fire an event named scroll that bubbles at target.
  • Otherwise, fire an event named scroll at target.
  1. Empty doc’s pending scroll event targets.

通過規範我們可以明白的 2 點是,首先滾動的 target 可以是 document 和裡面的 element。其次,在 element 上的 scroll 事件是不冒泡的,document 上的 scroll 事件冒泡。

所以如果我們想通過在 scroll 的節點上去阻止它的滾動事件冒泡來解決問題是不可行的!因為它根本就冒泡,無法觸及 dom tree 的父節點何談觸發它們的滾動。

那麼問題是怎麼產生的呢,其實規範只說明瞭瀏覽器應該在什麼時候滾動,而沒有說不應該在什麼時候滾動。瀏覽器正確實現了規範,滾動穿透也並不是瀏覽器的 bug。我們在頁面上加了一個遮罩層並不會影響 document 滾動事件的產生。根據規範,如果目標節點是不能滾動的那麼將會嘗試 document 上的滾動,也就是說遮罩層雖然不可滾動,但是這個時候瀏覽器會去觸發 document 的滾動從而導致了下方文件的滾動。也就是說如果 document 也不可滾動了,也就不會有這個問題了。這就引出瞭解決問題的第一種方案:把 document 設定為 overflow hidden。

怎麼解決

overflow hidden

既然滾動是由於文件超出了一屏產生的,那麼就讓它超出部分 hidden 掉就好了,所以在遮罩層被彈出的時候可以給html和 body 標籤設定一個 class:

.modal--open {
  height: 100%;
  overflow: hidden;
}

這樣文件高度和螢幕一樣,自然不會存在滾動了。但是這樣又會引來一個新的問題,如果文件之前存在一定的滾動高度那麼這樣設定後會導致之前的滾動距離失效,文件滾回了最頂部,這樣一來豈不是得不償失?但是我們可以在加 class 之前記錄好之前的滾動具體然後在關閉遮罩層的時候把滾動距離設定回來。這樣問題是可以得到解決的實現成本也很低,但是如果遮罩層是透明的,彈出後用戶仍然會看到丟失距離後的下方頁面,顯然這樣並不是完美的方案。

prevent touch event

還有一種辦法就是我們直接阻止掉遮罩層和彈窗的 touch event 這樣就不會在移動端觸發 scroll 事件了。但是在 PC 上沒有 touch 事件, scroll 事件仍然可以被觸發,原因上面我們也說過,scroll 事件是滾動它能滾動的元素。這裡我們解決的是移動端的問題,例子如下:

<div id="app">
  <div class="mask">mask</div>
  <div class="dialog">dialog</div>
</div>
const $mask = document.querySelector(".mask");
const $dialog = document.querySelector(".dialog");
const preventTouchMove = $el => {
  $el.addEventListener(
    "touchmove",
    e => {
      e.preventDefault();
    },
    { passive: false }
  );
};
preventTouchMove($mask);
preventTouchMove($dialog);

上面我們通過 prevent touchmove 來阻止頁面的觸控事件從而禁止進一步的頁面滾動,在 addEventListener 最後一個引數我們將 passive 顯示的設定為 false,這裡是有用意的。關於 passive event listener 這裡又是一個話題我們就不展開說了,就是瀏覽器為了優化滾動效能做的一些改進,具體可以看網站使用被動事件偵聽器以提升滾動效能,由於在 Chrome 56 開始將會預設開啟 passive event listener 所以不能直接在 touch 事件中使用 preventDefault,需要先將 passive 選項設定為 false 才行。

這裡我們解決了在頁面上普通彈窗的問題,但是如果 dialog 的內容是可以滾動的,這樣將其阻止了 touch 事件將會導致其內容也不能正常滾動,所以還有要進一步優化才行。

東莞vi設計https://www.houdianzi.com/dgvi/ 豌豆資源網站大全https://55wd.com

進一步優化

現在的場景是我們的彈窗是可以滾動的,所以不能再直接將其 touch 事件阻止,去掉後我們發現會產生新的問題。遮罩層被阻止了 touch 事件不能使下方滾動,但是彈出層 modal 這裡內容是可滾動的,在 touch modal 時能正常滾動裡面的內容。但是 modal 滾動到最上方或者最下方時仍然能觸發 document 的滾動,效果如下:

我們看到當 modal 滾動在頂部時仍然能拖動下方 document。這樣我們只能監聽使用者手勢,如果 modal 已經滑動到了底部或者頂部且還要往上或者下滑動則也要 prevent modal 的 touch 事件。簡單實現一個 fuckScrollChaining函式:

function fuckScrollChaining($mask, $modal) {
  const listenerOpts = { passive: false };
  $mask.addEventListener(
    "touchmove",
    e => {
      e.preventDefault();
    },
    listenerOpts
  );
  const modalHeight = $modal.clientHeight;
  const modalScrollHeight = $modal.scrollHeight;
  let startY = 0;

  $modal.addEventListener("touchstart", e => {
    startY = e.touches[0].pageY;
  });
  $modal.addEventListener(
    "touchmove",
    e => {
      let endY = e.touches[0].pageY;
      let delta = endY - startY;

      if (
        ($modal.scrollTop === 0 && delta > 0) ||
        ($modal.scrollTop + modalHeight === modalScrollHeight && delta < 0)
      ) {
        e.preventDefault();
      }
    },
    listenerOpts
  );
}

完整實現在這裡,至此無論彈出層內容是否可滾動都不會導致下方 document 跟隨滾動。