深入理解requestAnimationFrame并实现相册组件中的切换动画

全手打原创,转载请标明出处:https://www.cnblogs.com/dreamsqin/p/12529885.html,多谢,=。=~
(如果对你有帮助的话请帮我点个赞啦)

通常情况下,我们利用HTML5的canvas,CSS3的transform、transition、animation实现动画效果,但是今天为了实现相册组件中scrollLeft改变的动效,怎么用js实现动画还不影响效果和性能~=。=,居然让我发现了一个神奇的存在:requestAnimationFrame,下面来深入学习一下。

效果展示

动画的本质就是要让人眼看到图像被刷新而引起变化的视觉效果,而这个变化要以连贯的、平滑的方式进行过渡。 那如何从原理上实现这种效果呢?或者说怎么让改变显得不会那么突兀?在说明前需要先科普一下相关的小知识。
首先看一下最终实现的效果 ↓

使用动画效果前:闪现式移动

使用动画效果后:平滑式过渡

科普小知识

1、屏幕刷新(绘制)频率

指图像在屏幕上更新的速度,也就是屏幕上的图像每秒钟出现的次数,单位为赫兹(Hz)。

对于一般笔记本电脑,这个频率大概是60Hz, 可以在桌面上右键 > 屏幕分辨率 > 高级设置 > 监视器 > 屏幕刷新频率中查看和设置。这个值的设定受屏幕分辨率、屏幕尺寸和显卡的影响,原则上设置成让眼睛看着舒适的值就可以了。

常见的两种显示器

CRT: 一种使用阴极射线管(Cathode Ray Tube)的显示器。屏幕上的图形图像是由一个个荧光点(因电子束击打而发光)组成,由于显像管内荧光粉受到电子束击打后发光的时间很短,所以电子束必须不断击打荧光粉使其持续发光。电子束每秒击打荧光粉的次数就是屏幕刷新频率
LCD: 我们常说的液晶显示器( Liquid Crystal Display)。因为 LCD中每个像素在背光板的作用下都在持续不断地发光,直到不发光的电压改变并被送到控制器中,所以 LCD 不会有电子束击打荧光粉而引起的闪烁现象。

因此,当你对着电脑屏幕什么也不做的情况下,显示器也会以每秒60次的频率不断更新屏幕上的图像。

为什么你感觉不到这个变化?
那是因为人的眼睛有视觉暂留,即前一副画面留在大脑的印象还没消失,紧接着后一副画面就跟上来了,这中间只间隔了16.7ms(1000/60≈16.7), 所以会让你误以为屏幕上的图像是静止不动的。

而屏幕给你的这种感觉是对的,试想一下,如果刷新频率变成1Hz(1次/秒),屏幕上的图像就会出现严重的闪烁,这样很容易引起眼睛疲劳、酸痛和头晕目眩等症状。

2、动画原理

根据屏幕刷新频率我们知道,你眼前所看到图像正在以每秒 60 次的频率绘制,由于频率很高,所以你感觉不到它的变化。

60Hz 的屏幕每 16.7ms 绘制一次,如果在屏幕每次绘制前,将元素的位置向右移动一个像素,即Px += 1,这样一来,屏幕每次绘制出来的图像位置都比前一个差1px,你就会看到图像在移动。

由于人眼的视觉暂留当前位置的图像停留在大脑的印象还没消失,紧接着图像又被移到了下一个位置,所以你所看到的效果就是图像在流畅的移动。这就是视觉效果上形成的动画。

感受一下↓(因为视觉暂留,让你感觉这个人在走动)

3、Element.scrollLeft

要实现相册组件中照片的移动需要使用dom元素的一个很重要的属性scrollLeft可以读取或设置元素滚动条到元素左边的距离。听上去好像有点儿绕,画个图看看:

蓝色的是元素的滚动条,scrollLeft则是红段标注的滚动条到元素左边的距离,后续相册中图片的切换需要通过修改scrollLeft值实现。

setTimeout实现动画

了解了动画原理后,假定在requestAnimationFrame出现以前,在JavaScript 中想要实现上述动画效果,怎么办呢?无外乎就是用setTimeoutsetInterval通过设置一个间隔时间来不断改变图像的位置,从而达到动画效果,本文以setTimeout为例。

// demo1:
function moveTo(dom, to) {
    dom.scrollLeft += 1;
    if(dom.scrollLeft <= to) {
        setTimeout(() => {
            moveTo(dom, to)
            }, 16.7)
    }
}

但我们会发现,利用setTimeout实现的动画在某些低端机上会出现卡顿、抖动的现象。

这种现象的产生有两个原因:

setTimeout的执行时间并不是确定的。在Javascript中, setTimeout任务被放进了异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列里的任务是否需要开始执行,因此setTimeout的实际执行时间一般要比其设定的时间晚一些。
刷新频率受屏幕分辨率和屏幕尺寸的影响。因此不同设备的屏幕刷新频率可能会不同,而setTimeout只能设置一个固定的时间间隔,这个时间不一定和屏幕的刷新时间相同。

以上两种情况都会导致setTimeout的执行步调和屏幕的刷新步调不一致,从而引起丢帧现象。

那为什么步调不一致就会引起丢帧呢?
首先要明白,setTimeout的执行只是在内存中对图像属性进行改变,这个变化必须要等到屏幕下次刷新时才会被更新到屏幕上。如果两者的步调不一致,就可能会导致中间某一帧的操作被跳过去,直接更新下一帧的图像。

举个栗子~
假设屏幕每隔16.7ms刷新一次,而setTimeout每隔10ms设置图像向右移动1px, 就会出现如下绘制过程:

从上面的绘制过程中可以看出,屏幕没有更新left = 2px的那一帧画面,图像直接从1px的位置跳到了3px的的位置,这就是丢帧现象,会引起动画卡顿。而原因就是setTimeout的执行步调和屏幕的刷新步调不一致。

开发者可以用很多方式来减轻这些问题的症状,但彻底解决基本很难,问题的根源在于时机

对于前端开发者来说setTimeout提供的是一个等长的定时器循环(timer loop),我们对于浏览器内核对渲染函数的响应以及何时能够发起下一个动画帧的时机,是完全不了解的。
对于浏览器内核来说,它能够了解发起下一个渲染帧的合适时机,但是对于任何 setTimeout传入的回调函数执行,都是一视同仁的。它很难知道哪个回调函数是用于动画渲染的,因此,优化的时机非常难以掌握。

总的来说就是,写 JavaScript 的人了解一帧动画在哪行代码开始,哪行代码结束,却不了解应该何时开始,应该何时结束,而在内核引擎来说,却恰恰相反,所以二者很难完美配合,直到 requestAnimationFrame出现。

requestAnimationFrame实现动画

setTimeout相比,requestAnimationFrame最大的优势是由浏览器来决定回调函数的执行时机,即紧跟浏览器的刷新步调。

具体一点讲,如果屏幕刷新频率是60Hz,那么回调函数每16.7ms被执行一次,如果屏幕刷新频率是75Hz,那么这个时间间隔就变成了1000/75=13.3ms。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象,自然不会导致动画的卡顿。

// demo2:
function moveTo(dom, to) {
    dom.scrollLeft += 1;
    if(dom.scrollLeft <= to) {
        window.requestAnimationFrame(() => {
                moveTo(element, to)
            })
    }
}

除此之外,requestAnimationFrame还有以下两个优势

CPU节能:使用setTimeout实现的动画,当页面被隐藏(隐藏的<iframe>)或最小化(后台标签页)时,setTimeout仍然在后台执行动画任务,由于此时页面处于不可见或不可用状态,刷新动画是没有意义的,而且还浪费 CPU 资源和电池寿命。而requestAnimationFrame则完全不同,当页面处于未激活的状态下,该页面的屏幕绘制任务也会被浏览器暂停,因此跟着浏览器步伐走的requestAnimationFrame也会停止渲染,当页面被激活时,动画就从上次停留的地方继续执行,有效节省了 CPU 开销,提升性能和电池寿命。

感受一下↓(在console中实时打印修改后的scrollLeft):

setTimeout:页面最小化时scrollLeft仍在被修改

requestAnimationFrame:页面最小化时scrollLeft修改被暂停

函数节流:在高频率事件(resize,scroll 等)中,为了防止在一个刷新间隔内发生多次函数执行,使用requestAnimationFrame可保证每个绘制间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销。一个绘制间隔内函数执行多次时无意义,因为显示器(60Hz)每16.7ms 绘制一次,多次执行并不会在屏幕上体现出来。

换句话说,其实就是你使用setTimeout并且函数执行时间间隔小于16.7ms(60Hz情况下)时会存在的问题~

requestAnimationFrame的优雅降级

由于requestAnimationFrame目前还存在兼容性问题,不同浏览器还需要带不同的前缀。各浏览器兼容性如下:

所以需要通过优雅降级的方式对requestAnimationFrame进行封装,优先使用高级特性,然后再根据不同浏览器情况进行回退,直到只能使用setTimeout为止。以Darius Bacon的github代码为例:

// demo3:
if (!Date.now)
    Date.now = function() { return new Date().getTime(); };

(function() {
    'use strict';
    
    var vendors = ['webkit', 'moz'];
    for (var i = 0; i < vendors.length && !window.requestAnimationFrame; ++i) {
        var vp = vendors[i];
        window.requestAnimationFrame = window[vp+'RequestAnimationFrame'];
        window.cancelAnimationFrame = (window[vp+'CancelAnimationFrame']
                                   || window[vp+'CancelRequestAnimationFrame']);
    }
    if (/iP(ad|hone|od).*OS 6/.test(window.navigator.userAgent) // iOS6 is buggy
        || !window.requestAnimationFrame || !window.cancelAnimationFrame) {
        var lastTime = 0;
        window.requestAnimationFrame = function(callback) {
            var now = Date.now();
            var nextTime = Math.max(lastTime + 16, now);
            return setTimeout(function() { callback(lastTime = nextTime); },
                              nextTime - now);
        };
        window.cancelAnimationFrame = clearTimeout;
    }
}());

requestAnimationFrame的Chrome源码

见识过requestAnimationFrame的强大之后就想知道它到底是怎么实现的,所以从Chrome对requestAnimationFrame的实现入手,对Chrome+Blink源码进行分析。
由上述demo2的例子可以看到,requestAnimationFrame语法是window.requestAnimationFrame(callback);。所以我们重点需要关注的源码是回调函数callback的注册与调用过程,下面让我们至顶向下来瞅瞅。

1、生成ScriptedAnimationController实例并调用registerCallback注册函数

(/[blink]/trunk/Source/core/dom/Document.cpp)

int Document::requestAnimationFrame(FrameRequestCallback* callback) {
    return ensureScriptedAnimationController().registerCallback(callback);
}

suspend()即页面被隐藏或最小化时的性能优化,绘制任务会被浏览器暂停:
(/[blink]/trunk/Source/core/dom/Document.cpp)

ScriptedAnimationController& Document::ensureScriptedAnimationController(){
    if (!m_scriptedAnimationController) {
    m_scriptedAnimationController = ScriptedAnimationController::create(this);
    // We need to make sure that we don't start up the animation controller on a background tab, for example.
    if (!page())
        m_scriptedAnimationController->suspend();
    }
    return *m_scriptedAnimationController;
}

2、回调函数callback的注册过程

(/[blink]/trunk/Source/core/dom/ScriptedAnimationController.cpp)

ScriptedAnimationController::CallbackId ScriptedAnimationController::registerCallback(FrameRequestCallback* callback) {
      CallbackId id = m_callbackCollection.registerCallback(callback);
      scheduleAnimationIfNeeded();
      return id;
}

由源码可知注册函数registerCallback返回的是一个ID值,是回调列表中唯一的标识。是个非零值,没别的意义。后续你可以传这个值给 window.cancelAnimationFrame() 以取消回调函数。

注册之后还需要请求重绘,scheduleAnimationIfNeeded实现如下,用于判断是否需要重绘:
(/[blink]/trunk/Source/core/dom/ScriptedAnimationController.cpp)

void ScriptedAnimationController::scheduleAnimationIfNeeded(){
    if (!hasScheduledItems())
        return; 
    if (!m_document)
        return;
    if (FrameView* frameView = m_document->view())
        frameView->scheduleAnimation();
}

如果需要重绘则调用scheduleAnimation
(/[blink]/trunk/Source/web/WebViewImpl.cpp)

    void WebViewImpl::scheduleAnimation() {
    if (m_layerTreeView) {
        m_layerTreeView->setNeedsBeginFrame();
        return;
    }
    if (m_client)
        m_client->scheduleAnimation();
    }

由源码追溯发现最终实际调用的是SetNeedsAnimate函数:
(/[chrome]/trunk/src/cc/trees/thread_proxy.cc)

void ThreadProxy::SetNeedsAnimate() {
    DCHECK(IsMainThread());
    if (main().animate_requested)
        return;
    TRACE_EVENT0("cc", "ThreadProxy::SetNeedsAnimate");
    main().animate_requested = true;
    SendCommitRequestToImplThreadIfNeeded();
}

(/[chrome]/trunk/src/cc/trees/thread_proxy.cc)

void ThreadProxy::SendCommitRequestToImplThreadIfNeeded() {
   DCHECK(IsMainThread());
   if (main().commit_request_sent_to_impl_thread)
     return;
   main().commit_request_sent_to_impl_thread = true;
   Proxy::ImplThreadTaskRunner()->PostTask(
       FROM_HERE,
       base::Bind(&ThreadProxy::SetNeedsCommitOnImplThread,
                  impl_thread_weak_ptr_));
}

3、回调函数callback的执行过程

回调函数会被传入DOMHighResTimeStamp(是一个double类型,用于存储时间值。该值可以是离散的时间点或两个离散时间点之间的时间差。)参数,DOMHighResTimeStamp指示当前被 requestAnimationFrame()排序的回调函数被触发的时间。

在同一个帧中的多个回调函数,它们每一个都会接受到一个相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间。该时间戳是一个十进制数,单位毫秒,最小精度为1ms(1000μs)。

(/[blink]/trunk/Source/core/dom/ScriptedAnimationController.cpp)

void ScriptedAnimationController::executeCallbacks(double monotonicTimeNow) {
    // dispatchEvents() runs script which can cause the document to be destroyed.
    if (!m_document)
        return;
    double highResNowMs = 1000.0 * m_document->loader()->timing().monotonicTimeToZeroBasedDocumentTime(monotonicTimeNow);
    double legacyHighResNowMs = 1000.0 * m_document->loader()->timing().monotonicTimeToPseudoWallTime(monotonicTimeNow);
    // First, generate a list of callbacks to consider. 
    // Callbacks registered from this point on are considered only for the "next" frame, not this one.
    m_callbackCollection.executeCallbacks(highResNowMs, legacyHighResNowMs);
}

(/[blink]/trunk/Source/core/dom/ScriptedAnimationController.cpp)
executeCallbacks回调由serviceScriptedAnimations执行:

void ScriptedAnimationController::serviceScriptedAnimations(double monotonicTimeNow) {
    if (!hasScheduledItems())
        return;

    // First, generate a list of callbacks to consider.  Callbacks registered from this point on are considered only for the "next" frame, not this one.
    RefPtrWillBeRawPtr<ScriptedAnimationController> protect(this);

    callMediaQueryListListeners();
    dispatchEvents();
    executeCallbacks(monotonicTimeNow);

    scheduleAnimationIfNeeded();
}

那么动画是如何被触发的呢?
(/[blink]/trunk/Source/web/PageWidgetDelegate.cpp)

void PageWidgetDelegate::animate(Page& page, double monotonicFrameBeginTime, LocalFrame& root) {
    RefPtrWillBeRawPtr<FrameView> view = root.view();
    if (!view)
        return;
    page.autoscrollController().animate(monotonicFrameBeginTime);
    page.animator().serviceScriptedAnimations(monotonicFrameBeginTime);
}
void WebViewImpl::animate(double monotonicFrameBeginTime)
{
  TRACE_EVENT0("webkit", "WebViewImpl::animate");

  if (!monotonicFrameBeginTime)
      monotonicFrameBeginTime = monotonicallyIncreasingTime();

  // Create synthetic wheel events as necessary for fling.
  if (m_gestureAnimation) {
    if (m_gestureAnimation->animate(monotonicFrameBeginTime))
      scheduleAnimation();
    else {
      m_gestureAnimation.clear();
      if (m_layerTreeView)
        m_layerTreeView->didStopFlinging();

      PlatformGestureEvent endScrollEvent(PlatformEvent::GestureScrollEnd,
          m_positionOnFlingStart, m_globalPositionOnFlingStart, 0, 0, 0,
          false, false, false, false);

      mainFrameImpl()->frame()->eventHandler()->handleGestureScrollEnd(endScrollEvent);
    }
  }

  if (!m_page)
    return;

  PageWidgetDelegate::animate(m_page.get(), monotonicFrameBeginTime);

  if (m_continuousPaintingEnabled) {
    ContinuousPainter::setNeedsDisplayRecursive(m_rootGraphicsLayer, m_pageOverlays.get());
    m_client->scheduleAnimation();
  }
}

(/[chrome]/trunk/src/content/renderer/render_widget.cc)

void RenderWidget::AnimateIfNeeded() {
  if (!animation_update_pending_)
    return;

  // Target 60FPS if vsync is on. Go as fast as we can if vsync is off.
  base::TimeDelta animationInterval = IsRenderingVSynced() ? base::TimeDelta::FromMilliseconds(16) : base::TimeDelta();

  base::Time now = base::Time::Now();

  // animation_floor_time_ is the earliest time that we should animate when
  // using the dead reckoning software scheduler. If we're using swapbuffers
  // complete callbacks to rate limit, we can ignore this floor.
  if (now >= animation_floor_time_ || num_swapbuffers_complete_pending_ > 0) {
    TRACE_EVENT0("renderer", "RenderWidget::AnimateIfNeeded")
    animation_floor_time_ = now + animationInterval;
    // Set a timer to call us back after animationInterval before
    // running animation callbacks so that if a callback requests another
    // we'll be sure to run it at the proper time.
    animation_timer_.Stop();
    animation_timer_.Start(FROM_HERE, animationInterval, this, &RenderWidget::AnimationCallback);
    animation_update_pending_ = false;
    if (is_accelerated_compositing_active_ && compositor_) {
      compositor_->Animate(base::TimeTicks::Now());
    } else {
      double frame_begin_time = (base::TimeTicks::Now() - base::TimeTicks()).InSecondsF();
      webwidget_->animate(frame_begin_time);
    }
    return;
  }
  TRACE_EVENT0("renderer", "EarlyOut_AnimatedTooRecently");
  if (!animation_timer_.IsRunning()) {
    // This code uses base::Time::Now() to calculate the floor and next fire
    // time because javascript's Date object uses base::Time::Now().  The
    // message loop uses base::TimeTicks, which on windows can have a
    // different granularity than base::Time.
    // The upshot of all this is that this function might be called before
    // base::Time::Now() has advanced past the animation_floor_time_.  To
    // avoid exposing this delay to javascript, we keep posting delayed
    // tasks until base::Time::Now() has advanced far enough.
    base::TimeDelta delay = animation_floor_time_ - now;
    animation_timer_.Start(FROM_HERE, delay, this, &RenderWidget::AnimationCallback);
  }
}

看到这里其实requestAnimationFrame的实现原理就很明显了:

  • 注册回调函数
  • 浏览器更新时触发 animate
  • animate 会触发所有注册过的 callback

工作机制可以理解为所有权的转移,把触发帧更新的时间所有权交给浏览器内核,与浏览器的更新保持同步。这样做既可以避免浏览器更新与动画帧更新的不同步,又可以给予浏览器足够大的优化空间。
在往上的调用入口就很多了,很多函数(RenderWidget::didInvalidateRectRenderWidget::CompleteInit等)会触发动画检查,从而要求一次动画帧的更新。

最后上一张requestAnimationFrame的官方时序图:

总结

本文以相册组件的切换动画引出requestAnimationFrameAPI。首先对屏幕刷新(绘制)频率、动画原理、Element.scrollLeft三个知识点做了总结性讲解;然后分别采用setTimeoutrequestAnimationFrame实现相同动画效果,对比后分析setTimeout存在的劣势和requestAnimationFrame的优化点并阐明理由;接着针对requestAnimationFrame的浏览器兼容性阐明其优雅降级的方案;最后对Chrome实现requestAnimationFrame的源码进行了分析。

参考资料

1、Polyfill for requestAnimationFrame:https://github.com/darius/requestAnimationFrame
2、window.requestAnimationFrame:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
3、requestAnimationFrame 知多少?https://www.cnblogs.com/onepixel/p/7078617.html
4、Chrome源码:https://src.chromium.org/viewvc

猜你喜欢

转载自www.cnblogs.com/dreamsqin/p/12529885.html