原生JS控制多個滾動條同步跟隨滾動

清夜發表於2017-12-22

在一些支援用 markdown寫文章的網站,例如 掘金 或者 CSDN等,後臺寫作頁面,一般都是支援 markdown即時預覽的,也就是將整個頁面分成兩部分,左半部分是你輸入的 markdown文字,右半部分則即時輸出對應的預覽頁面,例如下面就是 CSDN後臺寫作頁面的 markdown即時預覽效果:

scrollBy

本文不是闡述如何從 0實現這種效果的(後續 很可能 會單出文章,),拋開其他,單看頁面主體中左右兩個容器元素,即 markdown輸入框元素和預覽顯示框元素

本文要探討的是,當這兩個容器元素的內容都超出了容器高度,即都出現了滾動框的時候,如何在其中一個容器元素滾動時,讓另外一個元素也隨之滾動。


DOM結構

既然是與滾動條有關,那麼首先想到 js中控制滾動條高度的一個屬性: scrollTop,只要能控制這個屬性的值,自然也就能控制滾動條的滾動了。

對於以下 DOM結構:

<div id="container">
  <div class="left"></div>
  <div class="right"></div>
</div>
複製程式碼

其中,.left元素是左半部分輸入框容器元素,.right元素是右半部分顯示框容器元素,.container是它們共同的父元素。

由於需要溢位滾動,所以還需要設定一下對應的樣式(只是關鍵樣式,非全部):

#container {
  display: flex;
  border: 1px solid #bbb;
}
.left, .right {
  flex: 1;
  height: 100%;
  word-wrap: break-word;
  overflow-y: scroll;
}
複製程式碼

再向 .left.right元素中塞入足夠的內容,讓二者出現滾動條,就是下面這種效果:

0.png

樣式是出來個大概了,下面就可以在這些 DOM上進行一系列的操作了。


初次嘗試

大致思路,監聽兩個容器元素的滾動事件,在其中一個元素滾動的時候,獲取這個元素的 scrollTop屬性的值,同時將此值設定為另外一個滾動元素的 scrollTop值即可。

例如:

var l=document.querySelector('.left')
var r=document.querySelector('.right')
l.addEventListener('scroll',function(){
  r.scrollTop = l.scrollTop
})
複製程式碼

效果如下:

scrollBy_1

似乎很不錯,但是現在是不僅想讓右邊跟隨左邊滾動,還想左邊跟隨右邊滾動,於是再加以下程式碼:

l.addEventListener('scroll',function(){
  r.scrollTop = l.scrollTop
})
複製程式碼

看上去很不錯,然而,哪有那麼簡單的事情。

這個時候你再用滑鼠滾輪進行滾動的時候,卻發現滾動得有點吃力,兩個容器元素的滾動似乎被什麼阻礙住了,很難滾動。

仔細分析,原因很簡單,當你在左邊滾動的時候,觸發了左邊的滾動事件,於是右邊跟隨滾動,但是與此同時右邊的跟隨滾動也是滾動,於是也觸發了右邊的滾動,於是左邊也要跟隨右邊滾動...然後就進入了一個類似於相互觸發的情況,所以就會發現滾動得很吃力。


解決scroll事件同時觸發的問題

想要解決上述問題,暫時有以下兩種方案。

scroll事件換成 mousewheel事件

由於 scroll事件不僅會被滑鼠主動滾動觸發,同時改變容器元素的 scrollTop也會觸發,元素的主動滾動其實就是滑鼠滾輪觸發的,所以可以將scroll事件換成一個對滑鼠滾動敏感而不是元素滾動敏感的事件:'mousewheel',於是上述監聽程式碼變成了:

l.addEventListener('mousewheel',function(){
    r.scrollTop = l.scrollTop
})
r.addEventListener('mousewheel',function(){
    l.scrollTop = r.scrollTop
})
複製程式碼

效果如下:

scrollBy_2

似乎是有點用,但是實際上還有兩個問題。

  • 當滾動其中一個容器元素的時候,另外一個容器元素雖然也跟著滾動,但滾動得並不流暢,高度有明顯的瞬間彈跳

在網上找了一圈,沒有找到關於 wheel事件滾動頻率相關內容,我推測這可能就是此事件的一個 feature

滑鼠每次滾動基本上都並不是以 1px為單位的,其最小單元遠比 scroll事件小的多,我用我的滑鼠在 chrome瀏覽器上滾動,每次滾過的距離都恰好是 100px,不同的滑鼠或者瀏覽器這個數值應該都是不一樣的,如果你的滑鼠質量比較好,齒輪比較精細,那麼應該就會小於 100px, 跳動也就不會那麼大,我的滑鼠是公司給配的電腦自帶的,作用只限於能用,所以齒輪刻度比較大,而 wheel事件其實真正監聽的是滑鼠滾輪滾過一個齒輪卡點的事件,這也就能解釋為何會出現彈跳的現象了。

這裡寫圖片描述

一般來說,滑鼠滾輪每滾過一個齒輪卡點,就能監聽到一個wheel事件,從開始到結束,被滑鼠主動滾動的元素已經滾動了 100px,所以另外一個跟隨滾動的容器元素也就瞬間跳動了 100px

而之所以上述 scroll事件不會讓跟隨滾動元素出現瞬間彈跳,則是因為跟隨滾動元素每次 scrollTop發生變化時,其值不會有 100px那麼大的跨度,可能也沒有小到1px,但由於其觸發頻率高,滾動跨度小,最起碼在視覺上就是平滑滾動的了。

如果你想讓右側滾動框也平滑滾動,也是可以做到的,當每次監聽到 wheel事件的時候,也別管它相比於上次是差了100px還是 50px的,始終都讓右側的跟隨滾動框按照 10px(或者再稍大點或稍小點的跨度,只要給人視覺上的感受是平滑滾動並且延遲不是太大就行了)來滾動,連續滾動 10次,那就是100px了,同樣能到達準確的位置,例如如下程式碼:

function scrollToY(rightELe, toY, step=10) {
    let diff = rightELe.scrollTop - toY
    let realStep = diff > 0 ? -step : step
    if(Math.abs(diff) > step) {
        rightELe.scrollTop = rightELe.scrollTop + realStep
        requestAnimationFrame(()=>{
            scrollToY(rightELe, toY, step)
        })
    } else {
        rightELe.scrollTop = toY
    }
}
複製程式碼
  • wheel只是監聽滑鼠滾輪事件,但如果是用滑鼠拖動滾動條,就不會觸發此事件,另外的容器元素也就不會跟隨滾動了

這個其實很好解決,用滑鼠拖動滾動條肯定是能觸發 scroll事件的,而在這種情況下,你肯定能夠很輕易地判斷出這個被拖動的滾動條是屬於哪個容器元素的,只需要處理這個容器的滾動事件,另外一個跟隨滾動容器的滾動事件不做處理即可。

  • wheel事件的相容問題

wheel事件是 DOM Level3的標準事件,但是除了此事件之外,還有很多非標準事件,不同的瀏覽器核心使用不同的標準,所以可能還需要按情況來進行相容,具體可見 MDN MouseWheelEvent

w3c1


實時判斷

如果你難以忍受 wheel的彈跳,也不好確定右側跟隨滾動框每次滾動的跨度到底多大才能完美,更不想考慮各種相容,那麼其實還有另外的路可以走得通,依舊是 scroll事件,只不過需要做一些額外的工作。

scroll事件的問題在於,沒有判斷當前主動滾動的是哪一個容器元素,只要確定了主動滾動的容器元素,這事就好辦了,例如上述使用 wheel事件中,用滑鼠拖動滾動條之所以能夠使用 scroll事件,就是因為能夠很容易地確定當前主動滾動容器元素是哪一個。

所以,問題的關鍵在於,如何判斷出當前主動滾動的容器元素,只要解決了這個問題,剩下的就很好辦了。

不論是滑鼠滾輪滾動還是滑鼠按在滾動條上拖動滾動條滾動,都會觸發 scroll事件,並且這個時候,在座標系 Z軸上,滑鼠的座標肯定是位於滾動容器元素所佔的面積之內的,也就是說,在 Z軸上,滑鼠肯定是懸浮或者位於滾動容器元素之上。

滑鼠在螢幕上移動的時候,是可以獲取到滑鼠當前座標的。

3.png

其中,clientXclientY就是當前滑鼠相對於視口的座標,可以認為,只要這個座標在某個滾動容器的範圍內,則認為這個容器元素就是主動滾動容器元素,容器元素的座標範圍可以使用 getBoundingClientRect進行獲取。

下面是滑鼠移動到 .left元素中的示例程式碼:

if (e.clientX>l.left && e.clientX<l.right && e.clientY>l.top) {
    // 進入 .left元素中
}
複製程式碼

這樣確實是可以的,不過考慮到兩個滾動容器元素幾乎佔據了整個螢幕面積,所以 mousemove所要監聽的面積未免有點大,對於效能可能要求較高,所以其實可以換成 mouseover事件,只需要監聽滑鼠有沒有進入到某個滾動容器元素即可,也省去上述的座標判斷了。

l.addEventListener('mouseover',function(){
  // 進入 .left滾動容器元素內
})
複製程式碼

當確定了滑鼠主動滾動的容器元素是哪一個時,只需要處理這個容器的滾動事件,另外一個跟隨滾動容器的滾動事件不做處理即可。

scrollBy_3

嗯,效果很不錯,效能也很好,perfect,可以收工嘍~

那一屋!

事情沒有那麼簡單!

zj


按比例滾動

上述示例全部是在兩個滾動容器元素的內容高度完全一致的情況下的效果,如果這兩個滾動容器元素的內容高度不同呢?

那就是下面這種效果:

scrollBy_4

可見,由於兩個滾動容器元素的內容高度不同,所以最大的 scrollTop也就不同,就會出現當其中一個 scrollTop值較小的元素滾到底時,另外一個元素還停留在一半,或者當其中一個 scrollTop值較大的元素才滾到一半時,另外一個元素就已經滾到底了。

這種情況很常見,例如你用 markdown寫作時,一個一級標題標記 #在編輯模式下佔用的高度,一般都是小於預覽模式佔用的高度的,這樣就出現了左右兩側滾動高度不一致的情況。

所以,如果將這種情況也考慮進來的話,那麼就不能簡單地為兩個滾動容器元素相互設定 scrollTop值那麼簡單。

雖然無法固定住滾動容器內容的高度,但是有一點可以確定,滾動條最大滾動高度,或者說 scrollTop的值,肯定是與滾動容器內容的高度與滾動容器本身的高度呈一定的關係。

由於需要知道滾動容器內容的高度,還要存在滾動條,所以需要給此容器元素加個子元素,子元素高度不限,就是滾動容器內容的高度,容器高度固定,溢位滾動即可。

<div id="container">
  <div class="left">
	 <div class="child"></div>
  </div>
  <div class="right">
	  <div class="child"></div>
  </div>
</div>
複製程式碼

結構示例如下:

4

通過我的觀察推論與實踐驗證,已經確定下來了它們之間的關係,很簡單,就是最基本的加減法運算:

滾動條的最大滾動高度(scrollTopMax) = 滾動容器內容的高度(即子元素高度ch) - 滾動容器本身的高度(即容器元素高度ph)
複製程式碼

math1

也就是說,如果已經確定了滾動容器內容的高度(即子元素高度ch)與滾動容器本身的高度(即容器元素高度ph),那麼就一定能確定滾動條的最大滾動高度(scrollTop),而這兩個高度值基本上都是可以獲取到的,所以就能得到 scrollTop

因此,想要讓兩個滾動元素容器等比例上下滾動,即其中一個元素滾到頭或者滾到底,另外一個元素也能對應滾到頭和滾到底,那麼只要得到這兩個滾動容器元素之間的 scrollTop最大值的比例(scale)就行了。

math2

確定了 scale之後,實時滾動時,只需要獲取主動滾動容器元素的 scrollTop1,就能得到另外一個跟隨滾動的容器元素對應的 scrollTop2

math3

思路弄清晰了,寫程式碼就是很容易的事情了,效果如下:

scrollBy_5

很順滑~

這裡寫圖片描述


小結

上述基本上已經實現了需求,可能在實踐過程中還需要根據實際情況來進行一定的修改,例如如果你編寫一個 markdown的線上編輯和預覽頁面,就需要根據輸入內容的高度實時更新 scale值,不過主體已經搞定,小修小改就沒什麼難度了。

另外,本文所述不僅是針對兩個滾動容器元素的跟隨滾動,同時也可擴充套件開來,更多的元素間的跟隨滾動都是可以根據本文思路來實現的,本文只是為了方便講解而具體到了兩個元素上。

本文的可執行簡單示例程式碼已經放到 Github上了,有興趣可以看看,別忘了 star 啊~

相關文章