平滑滚动的实现(下) - smooth-scroll源码分析

2,118 阅读6分钟

前言

上篇讲述了平滑滚动的几种实现方式,并且重点分析raf,使用raf实现了平滑滚动。接下来分析smooth-scroll这个库,看看别人家的实现。

核心实现

首先分析smooth-scroll的平滑滚动的实现方式

var loopAnimateScroll = function (timestamp) {
  if (!start) { start = timestamp; }
  //timeLapsed为累计运行的总时间
  timeLapsed += timestamp - start;
  //speed代表的这一次滚动动画的总时间
  percentage = speed === 0 ? 0 : (timeLapsed / speed);
  //percentage代表了当前的时间进度,区间是[0,1]
  //这里可以看到percentage大于1,说明滚动过头,让percentage强制为1
  percentage = (percentage > 1) ? 1 : percentage;

  position = startLocation + (distance * easingPattern(_settings, percentage));
  window.scrollTo(0, Math.floor(position));
  if (!stopAnimateScroll(position, endLocation)) {
    animationInterval = window.requestAnimationFrame(loopAnimateScroll);
    start = timestamp;
  }
};
//  ...
window.requestAnimationFrame(loopAnimateScroll);

其中,loopAnimateScroll利用raf递归调用自己。callback中有一个timestamp参数,这个参数是由requestAnimationFrame提供的。这个参数是一个时间点,单位是ms,但是是一个浮点数,根据不同的硬件设备,最多可以精确到5微秒。意义是callback调用时的时间。和performance.now()是一样的。

可以看到第一次执行时会初始化start参数,记录开始的时间戳。timeLapsed为累计运行的总时间。结合源码上下文,speed代表的这一次滚动动画的总时间,percentage代表了当前的时间进度,区间是[0,1]。

position = startLocation + (distance * easingPattern(_settings, percentage));

distance是总距离,position是当前走的路程。easingPattern()接受percentage返回一个系数。

最后通过scrollTo滚动到这一帧应到的位置。

window.scrollTo(0, Math.floor(position));

这种实现方式有两个比较精髓的地方:

  1. 能够很准确的控制滚动动画的总时间和速度。假设我们想控制滚动整个时间为2000ms,只要是设置speed=2000就ok了。并且简化一下,令easingPattern()返回的就是percentage
percentage = timeLapsed / speed;
// ...
position = startLocation + (distance * percentage);
// ...
window.scrollTo(0, Math.floor(position));

timeLapsed是随着调用raf递增的,percentage也是随着timeLapsed线性递增的,当percentage>=1时,说明时间到位了,已经过了2000ms,因此position也就到位了。

  1. easingPattern()函数就能非常方便的控制滚动的模式了,是线性的就直接返回percentage。easingPattern()只要满足当percentage在区间[0,1]中趋近1时,easingPattern()的返回值趋近为1这种函数关系就行。

看看源码中的一些方法,变量time就是传入的percentage,pattern就是easingPattern()的返回值

easingPattern(settings,time){
// ...
if (settings.easing === 'easeInQuad') 
  pattern = time * time; // accelerating from zero velocity
if (settings.easing === 'easeOutQuad') 
  pattern = time * (2 - time); // decelerating to zero velocity
if (settings.easing === 'easeInOutQuad') 
  pattern = time < 0.5 ? 2 * time * time : -1 + (4 - 2 * time) * time; // acceleration until halfway, then deceleration
// ...
  return pattern
}

这里就比较考察大家的物理,数学函数功底了。pattern本质上代表距离s,time代表时间t,s(t)求导可得v (t)关系,v(t)为常数就是匀速运动,再次求导可得a(t)关系,a(t)>0就是加速,a(t)=0就是匀速,a(t)<0就是减速。

所以如果有个模式是pattern = time就是匀速滚动了,上面三个分别是从0加速,减速到0,先加速后减速。

这里和raf的执行频率是无关的。好比raf就是一串点落在这个y= easingPattern(x)连续的函数上

我们可以自定义一些函数来拓展滚动的模式。

取消滚动

这个库实现了取消滚动的方法,并且暴露了出来,可以直接调用。requestAnimationFrame会返回一个id,调用cancelAnimationFrame传入id,如果这个requestAnimationFrame的callback还未执行,就会被取消。因此raf就变得可控了,这里有点类似于清除定时器,使程序变成可控。

animationInterval = window.requestAnimationFrame(loopAnimateScroll);
smoothScroll.cancelScroll = function (noEvent) {
  cancelAnimationFrame(animationInterval);//传入id,raf里的cb就被取消了
  animationInterval = null;
  if (noEvent) return;
  emitEvent('scrollCancel', settings); //分发scrollCancel事件
};

实现钩子

我们总是希望我们的程序是一个可控的状态机。使用第三方库时,我们希望第三方库暴露出一些钩子,当第三方库完成某个操作之前,或者之后,我们能执行自己的脚本。比如vue,react提供了各种各样的生命周期钩子,比如git可以在commit之前执行任意的脚本。

smooth-scroll创建了3个自定义事件,并且在相应的操作之前或之后,分发这些事件。

  • scrollStart is emitted when the scrolling animation starts.
  • scrollStop is emitted when the scrolling animation stops.
  • scrollCancel is emitted if the scrolling animation is canceled.

创建事件,并分发的函数

var emitEvent = function (type, options, anchor, toggle) {
  if (!options.emitEvents || typeof window.CustomEvent !== 'function') return;
  var event = new CustomEvent(type, {
    bubbles: true,
    detail: {
      anchor: anchor,
      toggle: toggle
    }
  });
  document.dispatchEvent(event);
};

源码中执行完cancel操作后,创建,并分发scrollCancel事件

smoothScroll.cancelScroll = function (noEvent) {
  cancelAnimationFrame(animationInterval);//传入id,raf里的cb就被取消了
  animationInterval = null;
  if (noEvent) return;
  emitEvent('scrollCancel', settings); //分发scrollCancel事件
};

作为使用者我们只需要这样写就能在相应的钩子里调用我们自己的callback函数。

document.addEventListener('scrollCancel', callback, false);

点击的处理和实现

在new实例之后,这个库是默认绑定a标签的,并且读取a标签的hash,来定位要滚动到的元素的位置。

这种用法下是不支持非a标签的点击的。这个库也暴露出了animate的api,可以直接滚动到相应的位置。在button的click事件中使用这个api,就会触发滚动。

如果页面有很多锚点,并且对应了很多dom元素(web上阅读一个有目录的文章,或者阅读一个文档可能是非常符合这样的场景)使用这个库是非常好的。

首先监听整个document的click

document.addEventListener('click', clickHandler, false);

其次找到指向要滚动到的dom元素

function clickHandler(){
  // ...
  //selector一般为a[href*="#"]
  //.closest是寻找父元素中符合selector的元素,因为a标签有时会包裹img标签,span,
  //并不是event.target
  toggle = event.target.closest(selector);
  //toggle不是a标签就return
  if (!toggle || toggle.tagName.toLowerCase() !== 'a' || event.target.closest(settings.ignore)) return;
  // ...

  var hash = escapeCharacters(toggle.hash);

  var anchor;
  if (hash === '#') {
    if (!settings.topOnEmptyHash) return;
    anchor = document.documentElement;
  } else {
    anchor = document.querySelector(hash);
  }
  // anchor就是hash指向的dom元素了
}

这种监听方式实现起来,是符合低耦合的。想要实现滑动,直接引入这个库,1行代码就能实现。

var scroll = new SmoothScroll('a[href*="#"]');

想要删除滑动,直接删除这部分代码即可。

实现history动画

在点击事件中阻止了a标签的默认事件,否则会触发锚点的默认行为。同时使用history的api,并且更新url,在url中加入hash值。这样可以点击浏览器的回退前进,监听popstate事件,会回到上一个hash位置,并且实现了平滑滚动的默认动画。

var popstateHandler = function (event) {

  var anchor = history.state.anchor;
  if (typeof anchor === 'string' && anchor) {
    anchor = document.querySelector(escapeCharacters(history.state.anchor));
    if (!anchor) return;
  }

  smoothScroll.animateScroll(anchor, null, {updateURL: false});

回退的时候是不用updateURL的

updateURL中使用history.pushState,使得history保留了记录

var updateURL = function (anchor, isNum, options) {

  // Bail if the anchor is a number
  if (isNum) return;

  // Verify that pushState is supported and the updateURL option is enabled
  if (!history.pushState || !options.updateURL) return;

  // Update URL
  history.pushState(
    {
      smoothScroll: JSON.stringify(options),
      anchor: anchor.id
    },
    document.title,
    anchor === document.documentElement ? '#top' : '#' + anchor.id
  );
};

实现销毁,避免内存泄漏

smoothScroll.destroy = function () {

  // If plugin isn't already initialized, stop
  if (!settings) return;

  // Remove event listeners
  document.removeEventListener('click', clickHandler, false);
  window.removeEventListener('popstate', popstateHandler, false);

  // Cancel any scrolls-in-progress
  smoothScroll.cancelScroll();

  // Reset variables
  settings = null;
  anchor = null;
  toggle = null;
  fixedHeader = null;
  eventTimeout = null;
  animationInterval = null;

};

总结

阅读开源库的源码总能收获很多,不只是功能上的设计,也能体会到软件上设计原理的实践。