深入理解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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM