原生JS控制多个滚动条同步跟随滚动

在一些支持用 markdown写文章的网站,例如 掘金 或者 CSDN等,后台写做页面,通常都是支持 markdown即时预览的,也就是将整个页面分红两部分,左半部分是你输入的 markdown文字,右半部分则即时输出对应的预览页面,例以下面就是 CSDN后台写做页面的 markdown即时预览效果:css

scrollBy

本文不是阐述如何从 0实现这种效果的(后续 极可能 会单出文章,),抛开其余,单看页面主体中左右两个容器元素,即 markdown输入框元素和预览显示框元素html

本文要探讨的是,当这两个容器元素的内容都超出了容器高度,即都出现了滚动框的时候,如何在其中一个容器元素滚动时,让另一个元素也随之滚动。git


DOM结构

既然是与滚动条有关,那么首先想到 js中控制滚动条高度的一个属性: scrollTop,只要能控制这个属性的值,天然也就能控制滚动条的滚动了。github

对于如下 DOM结构:chrome

<div id="container">
  <div class="left"></div>
  <div class="right"></div>
</div>
复制代码

其中,.left元素是左半部分输入框容器元素,.right元素是右半部分显示框容器元素,.container是它们共同的父元素。浏览器

因为须要溢出滚动,因此还须要设置一下对应的样式(只是关键样式,非所有):bash

#container {
  display: flex;
  border: 1px solid #bbb;
}
.left, .right {
  flex: 1;
  height: 100%;
  word-wrap: break-word;
  overflow-y: scroll;
}
复制代码

再向 .left.right元素中塞入足够的内容,让两者出现滚动条,就是下面这种效果:markdown

0.png

样式是出来个大概了,下面就能够在这些 DOM上进行一系列的操做了。性能


初次尝试

大体思路,监听两个容器元素的滚动事件,在其中一个元素滚动的时候,获取这个元素的 scrollTop属性的值,同时将此值设置为另一个滚动元素的 scrollTop值便可。flex

例如:

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 啊~

相关文章
相关标签/搜索