1. 程式人生 > >效能優化之關於畫素管道及優化(二)

效能優化之關於畫素管道及優化(二)

畫素管道,這個和我們寫程式碼息息相關的東西,我估計很多人都不太清楚它是個什麼,網上也有幾篇文章關於它的內容,但是不是那麼盡如人意,那麼我就詳細說說這個東西,以及如何優化它。

關於動畫載入與人們的反應

一個流暢的動畫關乎使用者體驗(留存)

延遲 使用者反應
0 - 16 毫秒 大部智慧裝置的重新整理率都是 60HZ,也就是每幀 16 毫秒
(包括瀏覽器將新幀繪製到螢幕上所需的時間),
留給應用大約 10 毫秒的時間來生成一幀。
0 - 100 毫秒 在此時間視窗內響應使用者操作,他們會覺得可以立即獲得結果。
時間再長,操作與反應之間的連線就會中斷。
100 - 300 毫秒 使用者會遇到輕微可覺察的延遲。
300 - 1000 毫秒 在此視窗內,延遲感覺像是任務自然和持續發展的一部分。
對於網路上的大多數使用者,載入頁面或更改檢視代表著一個任務。
1000+ 毫秒 超過 1 秒,使用者的注意力將離開他們正在執行的任務。
10,000+ 毫秒 拜拜
  • 對於一個動作的響應,我建議一般在 100 毫秒內解決,這適用於大多數輸入,不管他們是在點選按鈕、切換表單控制元件還是啟動動畫。
  • 對於需要超過 500 毫秒才能完成的操作,請始終提供反饋,例如 Loading

關於畫素管道

從純粹的數學角度而言,每幀的預算約為 16 毫秒(1000 毫秒 / 60 幀 = 16.66 毫秒/幀)。 但因為瀏覽器需要花費時間將新幀繪製到螢幕上,只有 10 毫秒來執行程式碼。

如果無法符合此預算,幀率將下降,並且內容會在螢幕上抖動。 此現象通常稱為卡頓,會對使用者體驗產生負面影響。

而瀏覽器花費時間進行繪製的過程就是執行畫素管道的過程。

什麼是畫素管道

一個經典的圖:

s上圖就是一個畫素管道,這就是畫素繪製到螢幕上的關鍵點。

  • JavaScript(程式碼變動)。一般來說,我們會使用 JavaScript 來實現一些視覺變化的效果。比如用 jQuery 的 animate 函式做一個動畫、對一個數據集進行排序或者往頁面裡新增一些 DOM 元素等。當然,除了 JavaScript,還有其他一些常用方法也可以實現視覺變化效果,比如:CSS Animations、Transitions 和 Web Animation API。
  • Style(樣式計算)。此過程就是利用 CSS 匹配器計算出元素的變化,再進行計算每個元素的最終樣式。
  • Layout(佈局計算)。當 Style 規則應用後,瀏覽器會開始計算其在螢幕上顯示的位置和佔據的空間大小,然而一個元素的變動可能會影響到另外一個元素,從而引起重排,所以佈局變動是很頻繁的,這一過程經常發生。
  • Paint(繪製)。繪製就是簡單的畫素填充,會將排列好的樣式進行填充。其包括文字、顏色、圖片、邊框、陰影等任何可視部分。因為網頁樣式是個層級結構,所以繪製操作會在每一層進行。
  • Composite(合成)。因為層級原因,當層級繪製完成,為了確保層級結構的正確,合成操作會按照正確的層級順序繪製到螢幕上,以便保證渲染的正確性,因為一個小小的層級順序錯誤,就有可能造成樣式紊亂。

管道的每個部分都有可能會產生卡頓,所以我們務必要知道哪一部分出現了問題,對症下藥。

由於現在瀏覽器的更新,許多瀏覽器已經能夠將繪製樣式變動和頁面繪製分開執行緒進行渲染,這已經不是我們能夠控制的了,但是無論怎麼變動,管道始終要進行,不一定每幀都總是會經過管道每個部分的處理。實際上,不管是使用 JavaScriptCSS 還是網路動畫,在實現視覺變化時,管道針對指定幀的執行通常有三種方式。

管道執行方式

JS/CSS —> Style —> Layout —> Paint —> Composite

此過程就是我們常說的瀏覽器重排,也就是改變了元素的幾何屬性(例如寬度、高度、左側或頂部位置等),那麼瀏覽器將必須檢查所有其他元素,然後“自動重排”頁面。任何受影響的部分都需要重新繪製,而且最終繪製的元素需進行合成,重排進行了管道的每一步,效能受到較大影響。

JS/CSS —> Style —> Paint —> Composite

這既是我們常說的重繪,也就是修改“paint only”屬性(例如背景圖片、文字顏色或陰影等),即不會影響頁面佈局的屬性,則瀏覽器會跳過佈局,但仍將執行繪製。

JS/CSS —> Style —> Composite

此過程不會重排重繪,僅僅是進行合成,也就是修改 transformopacity 屬性更改來實現動畫,效能得到較大提升,最適合於應用生命週期中的高壓力點,例如動畫或滾動。

如果你想知道更改任何指定 CSS 屬性將觸發上述三個版本中的哪一個,請點選這裡

上面列出的各項管道工作在計算開銷上有所不同,一些任務比其他任務的開銷要大,所以接下來,讓我們深入瞭解此管道的各個不同部分。

我會以一些常見問題為例,闡述如何診斷和修正它們。

如何優化

JS/CSS(程式碼變動)

我們使用 JS 來改變樣式是最為常見的,對於通過 JS 來改變動畫,有以下幾點需要注意:

  • 動畫效果儘量使用 requestAnimationFrame 而不是使用 setTimeout 或者 setInterval
  • 由於 JS 是單執行緒執行,請將需要耗費大量時間執行的任務放到 Web Worker 進行執行。
  • 請使用微任務來進行 DOM 更改,如果你不瞭解什麼是微任務,請點選這裡
  • 使用 Chrome DevToolsTimelineJavaScript 分析器來評估 JavaScript 的影響

使用requestAnimationFrame

大多時候,我想大部分人執行一個動畫效果都會用到 setTimeout 或者 setInterval 這兩個函式,但是這兩個函式和requestAnimationFrame 有什麼區別呢,似乎用下來感覺差不多?

這要從螢幕重新整理率說起。

螢幕重新整理頻率

螢幕重新整理頻率,即影象在螢幕上更新的速度,也即螢幕上的影象每秒鐘出現的次數,它的單位是赫茲(Hz)。 對於一般膝上型電腦,這個頻率大概是60Hz。

因此,當你對著電腦螢幕什麼也不做的情況下,顯示器也會以每秒60次的頻率正在不斷的更新螢幕上的影象。為什麼你感覺不到這個變化? 那是因為人的眼睛有視覺停留效應,即前一副畫面留在大腦的印象還沒消失,緊接著後一副畫面就跟上來了,這中間只間隔了16.7ms(1000/60≈16.7), 所以會讓你誤以為螢幕上的影象是靜止不動的。而螢幕給你的這種感覺是對的,試想一下,如果重新整理頻率變成1次/秒,螢幕上的影象就會出現嚴重的閃爍,這樣就很容易引起眼睛疲勞、痠痛和頭暈目眩等症狀。

動畫原理

根據上面的原理我們知道,你眼前所看到影象正在以每秒60次的頻率重新整理,由於重新整理頻率很高,因此你感覺不到它在重新整理。而動畫本質就是要讓人眼看到影象被重新整理而引起變化的視覺效果,這個變化要以連貫的、平滑的方式進行過渡。 那怎麼樣才能做到這種效果呢?

重新整理頻率為60Hz的螢幕每16.7ms重新整理一次,我們在螢幕每次重新整理前,將影象的位置向左移動一個畫素,即1px。這樣一來,螢幕每次刷出來的影象位置都比前一個要差1px,因此你會看到影象在移動;由於我們人眼的視覺停留效應,當前位置的影象停留在大腦的印象還沒消失,緊接著影象又被移到了下一個位置,因此你才會看到影象在流暢的移動,這就是視覺效果上形成的動畫。

setTimeoutsetInterval

理解了上面的概念以後,我們不難發現,setTimeout 其實就是通過設定一個間隔時間來不斷的改變影象的位置,從而達到動畫效果的。但我們會發現,利用seTimeout實現的動畫在某些配置較低的機器上會出現卡頓、抖動的現象。 這種現象的產生有兩個原因:

  • setTimeout的執行時間並不是確定的。在Javascript中, setTimeout 任務被放進了非同步佇列中,只有當主執行緒上的任務執行完以後,才會去檢查該佇列裡的任務是否需要開始執行,因此 setTimeout 的實際執行時間一般要比其設定的時間晚一些。
  • 重新整理頻率受螢幕解析度和螢幕尺寸的影響,因此不同裝置的螢幕重新整理頻率可能會不同,而 setTimeout 只能設定一個固定的時間間隔,這個時間不一定和螢幕的重新整理時間相同。

以上兩種情況都會導致setTimeout的執行步調和螢幕的重新整理步調不一致,從而引起丟幀現象。 那為什麼步調不一致就會引起丟幀呢?

  • 第0ms: 螢幕未重新整理,等待中,setTimeout也未執行,等待中;

  • 第10ms: 螢幕未重新整理,等待中,setTimeout開始執行並設定影象屬性left=1px;

  • 第16.7ms: 螢幕開始重新整理,螢幕上的影象向左移動了1px, setTimeout 未執行,繼續等待中;

  • 第20ms: 螢幕未重新整理,等待中,setTimeout開始執行並設定left=2px;

  • 第30ms: 螢幕未重新整理,等待中,setTimeout開始執行並設定left=3px;

  • 第33.4ms:螢幕開始重新整理,螢幕上的影象向左移動了3px, setTimeout未執行,繼續等待中;

從上面的繪製過程中可以看出,螢幕沒有更新left=2px的那一幀畫面,影象直接從1px的位置跳到了3px的的位置,這就是丟幀現象,這種現象就會引起動畫卡頓。

requestAnimationFrame

setTimeout相比,requestAnimationFrame最大的優勢是由系統來決定回撥函式的執行時機。具體一點講,如果螢幕重新整理率是60Hz,那麼回撥函式就每16.7ms被執行一次,如果重新整理率是75Hz,那麼這個時間間隔就變成了1000/75=13.3ms,換句話說就是,requestAnimationFrame的步伐跟著系統的重新整理步伐走。它能保證回撥函式在螢幕每一次的重新整理間隔中只被執行一次,這樣就不會引起丟幀現象,也不會導致動畫出現卡頓的問題。

除此之外,requestAnimationFrame還有以下兩個優勢:

  • CPU節能:使用setTimeout實現的動畫,當頁面被隱藏或最小化時,setTimeout 仍然在後臺執行動畫任務,由於此時頁面處於不可見或不可用狀態,重新整理動畫是沒有意義的,完全是浪費CPU資源。而requestAnimationFrame則完全不同,當頁面處理未啟用的狀態下,該頁面的螢幕重新整理任務也會被系統暫停,因此跟著系統步伐走的requestAnimationFrame也會停止渲染,當頁面被啟用時,動畫就從上次停留的地方繼續執行,有效節省了CPU開銷。

  • 函式節流:在高頻率事件(resize,scroll等)中,為了防止在一個重新整理間隔內發生多次函式執行,使用requestAnimationFrame可保證每個重新整理間隔內,函式只被執行一次,這樣既能保證流暢性,也能更好的節省函式執行的開銷。一個重新整理間隔內函式執行多次時沒有意義的,因為顯示器每16.7ms重新整理一次,多次繪製並不會在螢幕上體現出來。

Web Worker

由於 JavaScript 是單執行緒的,遇到大量計算問題會使整個頁面卡住,造成頁面十分卡頓的感覺,在許多情況下,可以將純計算工作移到 Web Worker,例如,如果它不需要 DOM 訪問許可權。資料操作或遍歷(例如排序或搜尋)往往很適合這種模型,載入和模型生成也是如此。

但是,由於 Web Worker 不能訪問 DOM,如果您的工作必須在主執行緒上執行,請考慮一種批量方法,將大型任務分割為微任務,每個微任務所佔時間不超過幾毫秒,並且在每幀的 requestAnimationFrame 處理程式內執行,並且,您將需要使用進度或活動指示器來確保使用者知道任務正在被處理,從而有助於主執行緒始終對使用者互動作出快速響應。

避免微優化 JavaScript

我知道許多人對優化有著極致的追求,可能一個函式比另外一個函式快上 10 倍,比如請求元素的 offsetTop 比計算 getBoundingClientRect() 要快,但是,每幀呼叫這類函式的次數幾乎總是很少,一般只能節省零點幾毫秒的時間。

當然我並不是說這樣做不好,但是這花費的精力和獲得的提升相比起來很不值得,也就是說,花費了大力氣修改,可能介面毫無變化,還會破壞程式碼的結構性,我建議,程式碼結構性和穩定性的重要性遠遠大於微優化。

Style(樣式計算)

大家都清楚重排和重繪這兩個詞,改變 DOM 結構就會導致瀏覽器重新計算元素樣式,在很多情況下還會對整個頁面或頁面的一部分進行佈局(即自動重排)。這就是所謂的樣式的計算。

計算樣式實際上分為兩個步驟:

  1. 建立一組匹配選擇器(瀏覽器計算出給指定元素應用哪些類、偽選擇器和 ID)
  2. 從匹配選擇器中獲取所有樣式規則,並計算出此元素的最終樣式

用於計算某元素計算樣式的時間中大約有 50% 用來匹配選擇器,而另一半時間用於從匹配的規則中構建

這一節其實沒什麼好寫的,其實就兩點需要注意一下:

  • 降低選擇器的複雜性
  • 減少必須計算其樣式的元素數量

降低選擇器的複雜性

例如:

.box:nth-last-child(-n+1) .title {
  /* styles */
}

這個 class ,瀏覽器會查詢這是否為有 title 類的元素,其父元素恰好是負第 N 個子元素加上 1 個帶 box 類的元素?
計算此結果可能需要大量時間,具體取決於所用的選擇器和相應的瀏覽器。
改為這樣可能會更好:

.final-box-title {
  /* styles */
}

當然,有些樣式必不可免會使用到第一種寫法,但是我建議,儘量少用這種寫法。

舉個具體的栗子:

這是頁面上的元素:

<div class="box"></div>
<div class="box"></div>
<div class="box b-3"></div>

這是寫的 css 選擇器

.box:nth-child(3)
.box .b-3

查詢的元素越多,查詢的花費時間越多。
如果 .box:nth-child(3) 花費時間是 2ms, .box .b-3 花費時間是 1ms,如果有 100 個元素,.box:nth-child(3) 花費 200ms,.box .b-3 花費 100ms,時間差距就出來了。

總體來說,計算元素的計算樣式的最糟糕的開銷情況是元素數量乘以選擇器數量,因為需要對照每個樣式對每個元素都檢查至少一次,看它是否匹配。

所以請儘量減少無效的 class,可能寫在頁面上不會造成任何影響,但是這會給瀏覽器造成負擔,所以我建議使用 BEM 命名規範。

建議使用 BEM

BEM(塊、元素、修飾符)之類的編碼方法實際上納入了上述選擇器匹配的效能優勢,因為它建議所有元素都有單個類,並且在需要層次結構時也納入了類的名稱:

.list { }
.list__list-item { }

如果需要一些修飾符,像在上面我們想為最後一個子元素做一些特別的東西,就可以按如下方式新增:

.list__list-item--last-child {}

sass 則可以更好的組織 BEM

.list {
  &__list-item {
    &--last-child {}
  }
}

Layout(佈局)

佈局的過程是上就是重排的過程,重排幾乎將整個頁面重新計算佈局,開銷之大顯而易見。
DOM 的數量以及複雜性將影響到效能。

以下幾點建議可以讓我們優化佈局:

  • 儘可能避免佈局操作
  • 使用 flexbox 而不是浮動佈局
  • 避免強制同步佈局
  • 避免佈局抖動

避免強制同步佈局

強制同步佈局(Forced Synchronous Layout),發生的原因在於在 JavaScript 程式碼階段觸發了 Layout 部分的 CSS 屬性。
例如:讀取某個元素的 offsetWidth 值,就會強迫瀏覽器在此幀就必須更新,瀏覽器會立即計算樣式和佈局,然後更新檢視,此刻,瀏覽器會進入讀取資料/寫入資料的迴圈中。
用一張圖表示:

由於比較抽象,來舉個具體的栗子:

栗子 1:

divs.forEach(function(elem, index, arr) {
  if (window.scrollY < 200) {
    element.style.opacity = 0.5;
  }
});

讀取 window.scrollY 值會造成 Layout,接著設定透明度,會造成瀏覽器 讀取(讀取 scrollY)/ 寫入(opacity),forEach 導致瀏覽器會一直迴圈進行這種強制同步操作。
修改如下:

const positionY = window.scrollY;

divs.forEach(function(elem, index, arr) {
  if (positionY < 200) {
    element.style.opacity = 0.5;
  }
});

先預讀取 scrollY 的值,再進行迴圈寫入操作。

栗子 2:

divs.forEach(function(elem, index, arr) {
  if (elem.offsetHeight < 500) {
    elem.style.maxHeight = '100vh';
  }
});

同上一個問題,讀取 offsetHeight,寫入 maxHeight

修改如下:

if (elem.offsetHeight < 500) { // 先讀取屬性值
  divs.forEach(function(elem, index, arr) { // 再更新樣式
    elem.style.maxHeight = '100vh';
  });
}

栗子 3:

var newWidth = container.offsetWidth;

divs.forEach(function(elem, index, arr) {
  element.style.width = newWidth;
});

這個栗子是正確的,沒問題。

避免佈局抖動

不斷的強制同步會導致佈局抖動。

下面一個栗子,點選 click 之後將藍色寬度設定為和綠色相同:

程式碼如下:

const paragraphs = document.querySelectorAll('p');
const clickme = document.getElementById('clickme');
const greenBlock = document.getElementById('block');

clickme.onclick = function(){
  greenBlock.style.width = '600px';

  for (let p = 0; p < paragraphs.length; p++) {
    let blockWidth = greenBlock.offsetWidth;
    paragraphs[p].style.width = `${blockWidth}px`;
  }
};

大家看看有什麼問題?

問題就在於,迴圈讀取了 greenBlock.offsetWidth,導致瀏覽器不斷進行樣式計算和佈局計算,將此刻的值賦予 paragraphs[p].style.width,強迫在此幀獲得更新值,並做樣式更新,相當於到 Layout 步驟取值,然後打斷,下一個迴圈繼續。

Details 上可以看到「Forced reflow is a likely performance bottleneck.」

解決方法很簡單:

clickme.onclick = function(){
  greenBlock.style.width = '600px';
  const blockWidth = greenBlock.offsetWidth;

  for (let p = 0; p < paragraphs.length; p++) {
    paragraphs[p].style.width = `${blockWidth}px`;
  }
};

提前取出 greenBlock.offsetWidth,然後再批量寫入。

Paint(繪製)

繪製是填充畫素的過程,畫素最終合成到使用者的螢幕上。它往往是管道中執行時間最長的任務。

這過程中其實什麼好說的,就以下幾點需要注意一下:

  • transformopacity 屬性之外,更改任何屬性始終都會觸發繪製。
  • 繪製通常是畫素管道中開銷最大的部分
  • 通過層(z-index)的提升和動畫的編排來減少繪製區域

Composite(合成)

合成是畫素管道的最後一環,合成是將頁面的已繪製部分放在一起以在螢幕上顯示的過程。

此方面有兩個關鍵因素影響頁面的效能:需要管理的合成器層數量,以及您用於動畫的屬性。

  • z-index 層數過多會佔用更多的記憶體,請合理分配
  • 堅持使用 transformopacity 屬性更改來實現動畫,這不會觸發重排和重繪。
  • 使用 will-changetranslateZ 提升移動的元素。

總結

從整篇文章看,Paint(繪製) 和 Composite(合成)是說的最少的內容,因為這一部分僅僅是需要注意的點。

總有人問,從 JavaScriptCSS 哪個入手,效能會更好一點?

其實從畫素管道的角度看,改變 Layout 的成本是比較高的,無論你是使用 JavaScript 還是 CSS

在編寫 JavaScript 程式碼時,不經意間可能會造成強制同步和佈局抖動。

其實完成一個專案的優化不是刻意進行的,而是在一點一滴編碼過程中積累進行的,使優化成為你的習慣,寫出的程式碼自然就有了優化的內容。

最後不好意思推廣一下我基於 Taro 框架寫的元件庫:MP-ColorUI。

點這裡是文件

點這裡是 GitHUb 地址

可以順手 star 一下我就很開心啦,謝謝大家