平滑滚动的实现(上)

8,635 阅读7分钟

前言

研究了2天的平滑滚动,后面又结合锚点的实现,感觉收获很多,因此写下记录来整理一下。

最常见的需求是一个较长的页面的右下角可能有一个按钮,点击它就能回到顶部。这一般都是用锚点实现的,但是原生锚点的缺点是直接跳转,过于生硬。

因此我们需要一种平滑滚动的实现。

注:使用codepen时,codepen的浏览器环境就是你浏览器的环境,因此以下示例如果不能跑通,请检查你的浏览器环境。

使用css实现

对于较新的浏览器,终于等到了css的原生实现,不再需要js来实现平滑滚动。

css属性scroll-behavior对由navigationCSSOM scrolling APIs触发的滚动生效。当其值为smooth时,点击对应锚点的a标签,就会出现平滑的匀速滚动到相应的锚点位置。因为锚点行为是CSSOM scrolling APIs触发

MDN上详细的了解scroll-behavior

想让其在整个html中生效,我们只需要css这么写就ok了:

html {
  scroll-behavior: smooth; 
}

注:在body上使用这个属性是无效的,详见mdn

codePen上示例

chrome从chrome 61(2017.9)开始支持这个属性,因此可见这个属性是比较新的,只有较新版本的chrome,Firefox,Opera支持。而Safari,Edge,IE的任何版本都不支持。

使用window.scrollTo()

window.scrollTo有两种用法

window.scrollTo(x-coord, y-coord)
window.scrollTo(options)

第一种用法就和普通锚点一样,立刻滚动到相应的位置。只有第二种用法能实现平滑滚动,其中传入的options里的behavior:smooth,可以实现匀速的平滑滚动

window.scrollTo({
  top: 100, // 滚动终点y的位置
  left: 100, // 滚动终点x的位置
  behavior: 'smooth' //平滑滚动
});

codePen上示例

遗憾的是第二种用法兼容性也比较差。

只有较新版本的chrome,Firefox,Opera支持。而Safari,Edge,IE的任何版本都不支持。

使用requestAnimationFrame实现

当以上2种原生方法都不能使用时,我们就需要自己手动利用js实现。

requestAnimationFrame是浏览器提供用来绘制动画一个api。它会在浏览器的每一帧Layout,Paint之前执行,就是常说的回流和重绘之前执行,这样可以很方便的执行一段更改样式的代码,然后浏览器就能马上回流,重绘,生成一帧。

使用requestAnimationFrame的优点

目前来说,使用requestAnimationFrame实现是比较好的一种做法

使用requestAnimationFrame实现有哪些好处?

  1. 相对于过去使用setInterval实现,requestAnimationFrame性能要好。requestAnimationFrame保证了每次改动样式后再进行回流重绘,setInterval可能使浏览器作出无效的回流和重绘。requestAnimationFrame在每一帧的生命周期都会触发,会使动画更加流畅,而setInterval不能保证每一帧都能触发。

  2. requestAnimationFrame的兼容性是非常好的,可以一直向下兼容到IE10。对于IE9及其以下,可以降级使用setTimeout或者setInterval实现requestAnimationFrame的polyfill。

  3. 自己写就可以实现各种不同的滚动速度了,可以实现线性速度,也可以实现先加速后减速的效果。

raf实现一个匀速滚动

下面是一个尝试使用requestAnimationFrame实现的示例。

结合这个示例,顺便介绍一下requestAnimationFrame的用法

首先可以通过document.documentElement.scrollTop获得当前页面滚动条的位置。

document.documentElement.scrollTop代表滚动条到页面顶端的距离,当document.documentElement.scrollTop === 0时,说明滚动条在页面的最顶端,

document.documentElement.scrollTop //
document.documentElement.scrollTop = 0 //对其赋值,会使滚动条立刻滚动到页面最顶端

接下来我们来使用requestAnimationFrame来实现匀速滚动最简单的示例:

<div id="button">回到顶部</div>
let button = document.getElementById('button')
button.addEventListener('click',function(){
  let cb = ()=>{
    if(document.documentElement.scrollTop===0){
      //满足条件,不再递归调用
      return;
    } else {
      let speed = 40 //每个帧内滚动条移动的距离
      document.documentElement.scrollTop -= speed
      //不满足条件,再次调用cb
      requestAnimationFrame(cb)
    }
  }
  requestAnimationFrame(cb)
}

requestAnimationFrame接受一个callback函数,会在浏览器的下一帧中的某个时间执行这个callback函数。注意的是只有下一帧,因此需要在callback函数中进行条件判断,当不满足条件时再次将callback函数传入raf

你可以在codePen查看这个示例:codePen

实例中实现了匀速滚动,并且可以根据变量来控制滚动的速度。

接下来考虑一个功能,点击按钮,不论在滚动条哪个位置,都能在大概2秒的时间回到页面的顶部,这个应该怎么实现?而且匀速运动的动画显得有些生硬,能否实现先加速后减速的功能?

raf实现变速滚动到顶端,并控制滚动的时间为2秒

1.如何实现2秒内滚动到位?

一般而言,目前最广泛的硬件设备刷新率是60帧,浏览器在硬件2帧之间执行回流重绘是没有意义的。因此浏览器一帧维持的时间是16.7ms,可以大致认为浏览器一帧的时间是1/60s,连续调用120次raf(cb)大致就是2秒钟。

2.如何实现变速滚动?

滚动的距离是一定的,令其为distance,我们需要在120帧里让document.documentElement.scrollTop的值减为0。

const distance = document.documentElement.scrollTop

这个动画过程中,速度就是一帧里scrollTop减掉的数值。理解这个我们就能用一个等差数列来实现一个变速滚动。

//假设最小的步长为x1document.documentElement.scrollTop - x * 12document.documentElement.scrollTop - x * 23document.documentElement.scrollTop - x * 3
...
第60document.documentElement.scrollTop - x * 6061document.documentElement.scrollTop - x * 60 //速度达到峰值62document.documentElement.scrollTop - x * 5963document.documentElement.scrollTop - x * 58
...
第120document.documentElement.scrollTop - x * 1

所以

let x = distance / (1+2+3···+60+60+59+···+2+1)
相当于
let x = distance / 60 / 61

下面完整的js代码,同时统计了滚动的时间。

let button = document.getElementById('button')

let dateBegin; 
let dateEnd;
button.addEventListener('click',function(){
  dateBegin = Date.now()
  let current = document.documentElement.scrollTop
  // 使用promise用来统计时间
  new Promise((resolve,reject)=>{
    let unit = current/60/61 //最小步长
    let index = 0
    let cb = ()=>{
      if(document.documentElement.scrollTop===0){
        dateEnd = Date.now()
        resolve([dateBegin,dateEnd])
      } else{
        index++
        if(index<=60){
          current-=index*unit 
          //这个地方不要用document.documentElement.scrollTop代替current,
          //document.documentElement.scrollTop可以接受浮点数,之后会转化为整数
          //这样的话会使时间变的不准
        } else if(index>60){
          current-=(121-index)*unit
        }
        document.documentElement.scrollTop = current
        requestAnimationFrame(cb)
      }
    }
    requestAnimationFrame(cb)
  }).then((data)=>{
    console.log(data[1] - data[0],' ms')
  })
})

codePen查看codePen

控制台输出在1900ms左右,并且可以看出是滚动是先加速,后减速的。

但是这样实现时间控制,是依赖于设备是保证60帧刷新的,如果当前页面有大量动画渲染或者复杂的js计算,导致浏览器帧数不能保证60帧运行呢?

这里其实对raf的使用是比较粗略的,比如raf对于传入的cb会提供一个时间参数,可以精确到5μs。利用这个可以精确的控制滚动的时间。还可以增加拓展功能,可以调用cancelAnimationFrame来取消raf的执行,从而在滚动过程中终止滚动。

下篇会分析smooth-scroll这个库的源码,这个库的实现很精彩,而且支持的变速滚动方式很多,并且能够很好的自定义。在vue的API文档中,点击侧栏,文档会平滑滚到相应的api的位置,就是用这个库实现的。这里暂时放一下。

最终手段:使用setInterval或者setTimeout实现

这个用法可以说是兼容性最高的方法了。当浏览器不支持raf时,我们可以使用raf的polyfill,raf的polyfill就是用setTimeout实现的。这个和raf的写法是差不多的,就不赘述了。当需要兼容不支持raf的浏览器时,最好的办法是使用raf的polyfill,这样既保证了优雅降级,也保证了在现代浏览器上能更好的工作。