滾動穿透在移動端開發中是一個很常見的問題,產生詭異的交互行為,影響用戶體驗,同時也讓我們的產品看起來不那么“專業”。雖然不少產品選擇容忍了這樣的行為,但是作為追求極致的工程師,應該去了解為什么會產生以及如何去解決。
什么是滾動穿透
移動端開發中避免不了會在頁面上進行彈窗、加浮層等這種操作。一個最常見的場景就是整個頁面上有一個遮罩層,上面畫着各種各樣的東西,具體是什么就不討論。實現這樣一個遮罩層可難不住即使是一個剛開始寫前端的小白。但是這里有一個問題就是如果不對遮罩層做任何處理,當用戶在上面滑動時會發現遮罩層下方的頁面居然也在滾動,這就很 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:
- 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.
- 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 跟隨滾動。