如何不擇手段提升scroll事件的性能

<!--假裝此處有一個段子作為引子-->

TL;DR

1. chrome devtool 是診斷頁面滾動性能的有效工具

2. 提升滾動時性能,就是要達到fps高且穩。

3. 具體可以從以下方面著手

  • 使用web worker分離無頁面渲染無關的邏輯計算
  • 觸發監聽事件時使用函數節流與函數去抖
  • 使用requestAnimationFrame與requestIdleCallback代替定時器
  • 避免強制重排
  • 提升合成層

場景

滾動行為無時無刻不出現在我們瀏覽網頁的行為中,在許多場景中,我們有有意識地、主動地去使用滾動操作,比如:

  • 懶載入
  • loadmore
  • affix
  • 回到頂部

以上場景伴隨著滾動事件的監聽操作,一不留神可能就讓頁面的滾動不再「如絲般順滑」。

不擇手段打造一個卡頓的scroll場景:

作為一名優秀的前端工程師(未來的),怎麼能容許出現這種情況!不就性能優化嗎,撩起袖子就是干!


原理

在一個流暢的頁面變化效果中(動畫或滾動),渲染幀,指的是瀏覽器從js執行到paint的一次繪製過程,幀與幀之間快速地切換,由於人眼的殘像錯覺,就形成了動畫的效果。那麼這個「快速」,要達到多少才合適呢?

我們都知道,下層建築決定了上層建築。受限於目前大多數屏幕的刷新頻率——60次/s,瀏覽器的渲染更新的頁面的標準幀率也為60次/s--60FPS(frames/per second)。

  • 高於這個數字,在一次屏幕刷新的時間間隔16.7ms(1/60)內,就算瀏覽器渲染了多次頁面,屏幕也只刷新一次,這就造成了性能的浪費。
  • 低於這個數字,幀率下降,人眼就可能捕捉到兩幀之間變化的滯澀與突兀,表現在屏幕上,就是頁面的抖動,大家通常稱之為卡頓

來個比喻。快遞每天整理包裹,並一天一送。如果某天包裹太多,整理花費了太多時間,來不及當日(幀)送到收件人處,那就延期了(丟幀)。

那麼在這16.7ms之內,瀏覽器都幹了什麼呢?

瀏覽器內心OS:不要老抱怨我延期(丟幀),我也很忙的好伐?

幀維度解釋幀渲染過程

瀏覽器渲染頁面的Renderer進程里,涉及到了兩個線程,二者之間通過名為Commit的消息保持同步:

  • Main線程:瀏覽器渲染的主要執行步驟,包含從JS執行到Composite合成的一系列操作(下文會介紹)
  • Compositor線程:接收用戶的一些交互操作(比如滾動) => 喚起Main線程進行操作 => 接收Main線程的操作結果 => commit給真正把頁面draw到屏幕上的GPU進程

標準渲染幀:

在一個標準幀渲染時間16.7ms之內,瀏覽器需要完成Main線程的操作,並commit給Compositor進程

丟幀:

主線程里操作太多,耗時長,commit的時間被推遲,瀏覽器來不及將頁面draw到屏幕,這就丟失了一幀

那麼Main線程里都有些什麼操作會導致延時呢?

進一步解釋瀏覽器主要執行步驟

  • JavaScript:包含與視覺變化效果相關的js操作。包括並不限於:dom更新、元素樣式動態改變、jQuery的animate函數等。
  • Style:樣式計算。這個過程,瀏覽器根據css選擇器計算哪些元素應該應用哪些規則,然後將樣式規則落實到每個元素上去,確定每個元素具體的樣式。
  • Layout:布局。在知道對一個元素應用哪些規則之後,瀏覽器即可開始計算它要佔據的空間大小及其在屏幕的位置。
  • Painting:繪製。繪製是填充像素的過程。它涉及繪出文本、顏色、圖像、邊框和陰影,基本上包括元素的每個可視部分。繪製一般是在多個表面(通常稱為層)上完成的。(paint和draw的區別:paint是把內容填充到頁面,而draw是把頁面反映到屏幕上)
  • Composite:合成。由於頁面的各部分可能被繪製到多層,由此它們需要按正確順序繪製到屏幕上,以便正確渲染頁面。對於與另一元素重疊的元素來說,這點特別重要,因為一個錯誤可能使一個元素錯誤地出現在另一個元素的上層。

理論上,每次標準的渲染,瀏覽器Main線程需要執行JavaScript => Style => Layout => Paint => Composite五個步驟,但是實際上,要分場景。

指路官網

再進一步解釋瀏覽器渲染流程

流程:

1.Compositor線程接收一個vsync信號,表示這一幀開始

2.Compositor線程接收用戶的交互輸入(比如touchmove、scroll、click等)。然後commit給Main線程,這裡有兩點規則需要注意:

  • 並不是所有event都會commit給Main線程,部分操作比如單純的滾動事件,打字等輸入,不需要執行JS,也沒有需要重繪的場景,Compositor線程就自己處理了,無需請求Main線程
  • 同樣的事件類型,不論一幀內被Compositor線程接收多少次,實際上commit給Main線程的,只會是一次,意味著也只會被執行一次。(HTML5標準里scroll事件是每幀觸發一次)

3.Main線程執行從JavaScript到Composite的過程,也有兩點需要注意:

  • 注意紅線,意思是可能會在JS里強制重排,當訪問scrollWidth系列、clientHeight系列、offsetTop系列、ComputedStyle等屬性時,會觸發這個效果,導致Style和Layout前移到JS代碼執行過程中。
  • 實際上圖中省略了Renderer進程中的其他線程,比如當Main線程走到js執行這一步時,會調起單獨的js線程來執行。另外還有如HTML解釋線程等。

4.當Main線程完成最後合成之後,與Compositor線程使用commit進行通信,Compositor調起Compositor Tile Work(s)來輔助處理頁面。Rasterize意為光柵化,想深入了解什麼是光柵的小夥伴可以戳這裡了解:瀏覽器渲染詳細過程:重繪、重排和composite只是冰山一角

5.頁面paint結束之後,這一幀就結束了。GPU進程里的GPU線程負責把Renderer進程操作好的頁面,交由GPU,調用GPU內方法,由GPU把頁面draw到屏幕上。

6.屏幕刷新,我們就在瀏覽器(屏幕)上看到了新頁面。

接下來,簡要介紹一下,如何使用chrome devtool分析頁面性能。

示意圖(chrome version: 61):

  • 幀率概覽。看頂端綠色長條,越高代表幀率越高,高低起伏多代表幀率變化不穩定,越坑坑窪窪代表容易產生視覺上的卡頓。
  • 分析具體某一幀。如果發現,有哪一幀幀率特別低,可以在中間那一欄找到耗時長的那一幀,點擊進行具體的活動分析。
  • 分析個活動耗時。自由選擇某一段或某一幀觀察這段時間內各項活動的耗時來診斷頁面。(注意顏色)

應該注意,我們可以看見,很少有幀的時間準確卡在了16.7s,實際上每幀達到60fps的幀率,只是一個理想化的數字,瀏覽器執行過程中可能受到各種情況的干擾。而我們人眼也沒有那麼靈敏,只要達到20幀以上,頁面看起來就比較流暢了。尤其是結構複雜,數據較多的頁面,盲目追求60fps只是鑽牛角尖。所以,以我淺見,穩定的fps更能影響scroll效果。

關於更加具體地如何使用chome devtool分析頁面性能,戳:Performance Analysis Reference


解決方案

我們的目標很明確,就是拒絕卡頓!具體說來就是盡量趕在16.7ms之內讓瀏覽器完成五項工作,壓縮每個步驟時間。

使用web worker

當我們了解了瀏覽器渲染時執行的過程,並且清楚瀏覽器內核處理方式(處理js的線程與GUI頁面渲染線程互斥)之後,我們很容易假想出這樣一種狀況:如果js大量的計算和邏輯操作霸佔著瀏覽器,使頁面渲染得不到處理,怎麼辦?

這種情況,很容易造成scroll的卡頓,甚至瀏覽器假死,就像alert()出現一樣。

想像一下吧,本來大家好好地按照生理周期一個接一個上廁所,突然小j便秘了!你說排在他後面的小g急不急,可急死了!

web worker是什麼?

Web Worker為Web內容在後台線程中運行腳本提供了一種簡單的方法。線程可以執行任務而不干擾用戶界面。

這就好像,給容易「便秘」的小j,單獨搭了個簡易廁所。

之所以說這是一個簡易廁所,因為它有一些限制

  • 無法訪問DOM節點
  • 無法訪問全局變數或是全局函數
  • 無法調用alert()或者confirm之類的函數
  • 無法訪問window、document之類的瀏覽器全局變數

主線程和 worker 線程之間通過這樣的方式互相傳輸信息:兩端都使用 postMessage() 方法來發送信息, 並且通過 onmessage 這個 event handler來接收信息。 (傳遞的信息包含在 Message 這個事件的數據屬性內) 。數據的交互是通過傳遞副本,而不是直接共享數據。

使用案例 - 判斷素數

案例來自Web Workers, for a responsive JavaScript application

素數,定義為在大於1的自然數中,除了1和它本身以外不再有其他因數。判斷演算法為,以2到它的平方根為界取整數做循環判斷,用它和這個數字求餘數,只要中間任意一次計算得到餘數為零,則能夠確認這個數字不是質數。

code

// in htmln<script type="text/javascript">n// we will use this function in-line in this pagenfunction isPrime(number)n{n if (number === 0 || number === 1) {n return true;n }n var i;n for (i = 2; i <= Math.sqrt(number); i++) {n if (number % i === 0) {n return false;n }n }n return true;n}nn// a large number, so that the computation time is sensiblenvar number = "1000001111111111";n// including the workers codenvar w = new Worker(webworkers.js);n// the callback for the worker to callnw.onmessage = function(e) {n if (e.data) {n alert(number + is prime. Now Ill try calculating without a web worker.);n var result = isPrime(number);n if (result) {n alert(I am sure, it is prime. );n }n } else {n alert(number + is not prime.);n }n};n// sending a message to the worker in order to start itnw.postMessage(number);nn</script>n<p style="height: 200px; width: 400px; overflow: scroll;">nLorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit tristique risus, a rhoncus nisl posuere sed. Praesent vel risus turpis, et fermentum lectus. Ut lacinia nunc dui. Sed a velit orci. Maecenas quis diam neque. Vestibulum id arcu purus, quis cursus arcu. Etiam luctus, risus eu scelerisque scelerisque, sapien felis tincidunt ante, vel pellentesque eros nunc at magna. Nam tincidunt mattis velit ut condimentum. Vivamus ipsum ipsum, venenatis vitae placerat eu, convallis quis metus. Quisque tortor sapien, dapibus non vehicula quis, dapibus at purus. Nunc posuere, ligula sed facilisis sagittis, justo massa placerat nulla, nec pellentesque libero erat ut ligula. Aenean molestie, urna quis molestie auctor, lorem purus hendrerit nisi, vitae tincidunt metus massa et dolor. Sed leo velit, iaculis tristique elementum tincidunt, ornare et tellus. Quisque lacinia felis at est faucibus in facilisis dui consectetur. Phasellus sed ante id tortor pretium ornare. Aliquam ante justo, aliquam ut mollis semper, mattis sit amet urna. Pellentesque placerat, diam nec consectetur blandit, libero metus placerat massa, quis mattis metus metus nec lorem.n</p>nn// in webworkers.jsnfunction isPrime(number)n{n if (number === 0 || number === 1) {n return true;n }n var i;n for (i = 2; i <= Math.sqrt(number); i++) {n if (number % i === 0) {n return false;n }n }n return true;n}nn// this is the point of entry for the workersnonmessage = function(e) {n // you can support different messages by checking the e.data valuen number = e.data;n result = isPrime(number);n // calling back the main threadn postMessage(result);n};n

代碼說明:

  • 使用web worker對一個較大數字(1000001111111111)進行素數判斷
  • 得到結果之後alert(number + is prime. Now Ill try calculating without a web worker.)
  • 在不使用web worker的情況下,對相同數字進行素數判斷,完成後alert(I am sure, it is prime. )
  • 從頁面標籤里的內容的滾動情況判斷兩次計算對瀏覽器/頁面造成的影響

現場還原:

不動戳我

案例總結

從兩次alert之後的段落滾動情況(第二次根本動不了),足以看出大量繁雜的js計算對頁面的影響。恰當地使用web worker,能有效緩解頁面scroll阻塞的情況

而且它的支持率也良好~

在應用方面,Angular已經做了一些嘗試。

解密Angular WebWorker Renderer (一):想辦法打破web worker本身不能操作dom元素等限制,利用web worker執行渲染操作

Learn more about web worker

函數節流與函數去抖

針對scroll事件中的回調,思路之一是對事件進行「稀釋」,減少事件回調的執行次數。

這就涉及到兩個概念:函數節流和函數去抖

  • 函數節流(throttle):讓函數在指定的時間段內周期性地間斷執行
  • 函數去抖(debounce):讓函數只有在過完一段時間後並且該段時間內不被調用才會被執行

有人這樣比喻:

就像一窩蜂的人去排隊看演出,隊伍很亂,看門的老大爺每隔1秒,讓進一個人,這個叫throttle,如果來了這一窩蜂的人,老大爺一次演出只讓進一個人,下次演出才讓下一個人進,這個就叫debounce

OK, text is long, show you code.

以下code來自underscore.js(類似jQuery的庫,封裝了一些方法)

// Returns a function, that, when invoked, will only be triggered at most oncen // during a given window of time. Normally, the throttled function will runn // as much as it can, without ever going more than once per `wait` duration;n // but if youd like to disable the execution on the leading edge, passn // `{leading: false}`. To disable execution on the trailing edge, ditto.n _.throttle = function(func, wait, options) {n var timeout, context, args, result;n // 標記時間戳n var previous = 0;n // options可選屬性 leading: true/false 表示第一次事件馬上觸發回調/等待wait時間後觸發n // options可選屬性 trailing: true/false 表示最後一次回調觸發/最後一次回調不觸發n if (!options) options = {};nn var later = function() {n previous = options.leading === false ? 0 : _.now();n timeout = null;n result = func.apply(context, args);n if (!timeout) context = args = null;n };nn var throttled = function() {n // 記錄當前時間戳n var now = _.now();n // 如果是第一次觸發且選項設置不立即執行回調n if (!previous && options.leading === false)n // 將記錄的上次執行的時間戳置為當前n previous = now;n // 距離下次觸發回調還需等待的時間n var remaining = wait - (now - previous);n context = this;n args = arguments;nn // 等待時間 <= 0或者不科學地 > wait(異常情況)n if (remaining <= 0 || remaining > wait) {n if (timeout) {n // 清除定時器n clearTimeout(timeout);n // 解除引用n timeout = null;n }n // 將記錄的上次執行的時間戳置為當前n previous = now;nn // 觸發回調n result = func.apply(context, args);n if (!timeout) context = args = null;n }n // 在定時器不存在且選項設置最後一次觸發需要執行回調的情況下n // 設置定時器,間隔remaining時間後執行latern else if (!timeout && options.trailing !== false) {n timeout = setTimeout(later, remaining);n }n return result;n };nn throttled.cancel = function() {n clearTimeout(timeout);n previous = 0;n timeout = context = args = null;n };nn return throttled;n };n

// Returns a function, that, as long as it continues to be invoked, will notn // be triggered. The function will be called after it stops being called forn // N milliseconds. If `immediate` is passed, trigger the function on then // leading edge, instead of the trailing.n _.debounce = function(func, wait, immediate) {n var timeout, result;nn // 定時器設置的回調,清除定時器,執行回調函數funcn var later = function(context, args) {n timeout = null;n if (args) result = func.apply(context, args);n };nn // restArgs函數將傳入的func的參數改造成Rest Parameters —— 一個參數數組n var debounced = restArgs(function(args) {n if (timeout) clearTimeout(timeout);n if (immediate) {n // 立即觸發的條件:immediate為true且timeout為空n var callNow = !timeout;n timeout = setTimeout(later, wait);n if (callNow) result = func.apply(this, args);n } else {n // _.delay方法實際上是setTimeout()包裹了一層參數處理的邏輯n timeout = _.delay(later, wait, this, args);n }nn return result;n });nn debounced.cancel = function() {n clearTimeout(timeout);n timeout = null;n };nn return debounced;n };n

對比以上代碼,我們可以發現,兩種方法應用的場景時有差別的

  • 函數節流:適用於多次提交(commit)的場景,如點擊按鈕提交發送請求的情況
  • 函數去抖:適用於scroll/resize等場景

相對於多次觸發只執行一次的debounce,間隔地執行回調的throttle更能滿足「稀釋」scroll事件的需求。

至於wait的設定值,到底多久執行一次比較合適?很大部分還是取決於具體的場景&代碼複雜度,但是這裡有一個例子可以參考:Learning from Twitter

2011年Twitter出現過滾動性能差到嚴重影響用戶體驗的案例,原因是

It』s a very, very, bad idea to attach handlers to the window scroll event.

Always cache the selector queries that you』re re-using.

最後採用了函數節流的辦法:

var outerPane = $details.find(".details-pane-outer"),n didScroll = false;nn$(window).scroll(function() {n didScroll = true;n});nnsetInterval(function() {n if ( didScroll ) {n didScroll = false;n // Check your page position and thenn // Load in more resultsn }n}, 250);n

示例中給出的數字250,可以給大家參考一下~

去定時器

為什麼定時器會引起掉幀?

如你所見,定時器導致掉幀的原因,就在於無法準確控制回調執行的時機。

即使給定時器設置延時時間wait恰好為16.7ms,也不行。

js的單線程限制了回調會在16.7ms之後加入任務隊列,卻不能保證一定在16.7ms之後觸發。如果當下js正在進行耗時計算,回調就只能等著。所以實際上回調執行的時機,是定時器設置後 >= 16.7ms後。

那麼去定時器是否意味著否定了之前說的函數去抖和函數節流操作?

NONONO,這兩種提升scroll性能的操作應用於不同的場景:

  • scroll過程中伴隨著不直接改變畫面效果的計算操作,如懶載入、loadmore等,在這樣的scroll場景里,我們要不斷進行判斷操作,大量的計算操作就可能阻塞scroll,所以要對操作進行「稀釋」。
  • scroll過程中伴隨著直接改變畫面效果的操作,如動畫、affix引起的scroll滾動等。

案例:在這個世界上,有一種經典的導航欄形式,那就是,affix。

這種導航欄在你scroll時會粘在你的窗口的固定位置(一般是top),並且在你點擊導航欄時自動滾動到頁面對應的target內容。

不動戳我

這是我自己做的一個小demo,利用了setInterval,每16.7ms設置scrollTop + 5px,達到「平滑」滾動的效果。

emmmm,看著不規則的鋸齒,難受。

如果還不夠明顯,試試將wait設為50ms

看起來,要趕上每一個標準幀渲染的時機,不是那麼容易,但是旁友,你聽說過安利嗎?哦走錯片場了,是requestAnimationFrame()requestIdleCallback().

requestAnimationFrame()

The window.requestAnimationFrame() method tells the browser that you wish to perform an animation and requests that the browser call a specified function to update an animation before the next repaint. The method takes a callback as an argument to be invoked before the repaint.

可以將它看做一個鉤子,剛好卡在瀏覽器重繪前向我們的操作伸出橄欖枝。實際上它更像定時器,每秒60次執行回調——符合屏幕的刷新頻率,遇到耗時長的操作,這個數字會降到30來保證穩定的幀數。

語法也很簡單:window.requestAnimationFrame(callback)

更改後的代碼:

const newScrollTop = this.getPosition(this.panes[index].$refs.content).top - this.distancennfunction scrollStep() {n document.documentElement.scrollTop += 5n if (document.documentElement.scrollTop < newScrollTop) {n window.requestAnimationFrame(scrollStep)n }n}nnwindow.requestAnimationFrame(scrollStep)n

與定時器很相似,只是鑒於其一次執行只調用一次回調,所以需要以遞歸的方式書寫。

測試一下:

可以說是很順滑了~

兼容性呢?

Learn more about requestAnimationFrame()

requestIdleCallback()

The window.requestIdleCallback() method queues a function to be called during a browsers idle periods. This enables developers to perform background and low priority work on the main event loop, without impacting latency-critical events such as animation and input response. Functions are generally called in first-in-first-out order; however, callbacks which have a timeout specified may be called out-of-order if necessary in order to run them before the timeout elapses.

意思是,它會在一幀末尾瀏覽器空閑時觸發回調,否則,推遲到下一幀。

看定義,它適合應用於執行在後台運行或者優先度低的任務,但是鑒於我們的案例邏輯和計算都比較簡單,應該能滿足一幀末尾有空閑(畢竟標題是「不擇手段」),have a try.

實際上,基礎使用上requestIdleCallback()requestAnimationFrame()語法相同,代碼修改甚至也只替換了方法名。

應用情況呢?

也是如絲般順滑~仔細看每一幀,我們會發現,Fire Idle Callback正如其定義,出現在每幀的最後。

但是兼容性看起來除了chrome和FireFox之外,就不是那麼友好了:

總結

在追求高性能的渲染效果時,可以考慮用requestIdleCallback()requestAnimationFrame()代替定時器。前者適合流暢的動畫效果場景,後者適用於分離一些優先順序低的操作邏輯,使用時需要考慮清楚。

避免強制重排

記憶力好的同學可能還記得,我們在之前描述瀏覽器渲染過程時,提到一個強制重排的概念,它的特點是,會插隊!

注意紅線,意思是可能會在JS里強制重排,當訪問scrollWidth系列、clientHeight系列、offsetTop系列、ComputedStyle等屬性時,會觸發這個效果,導致Style和Layout前移到JS代碼執行過程中

這個強制重排(force layout)聽起來好像和重排很像啊,那麼它和重排以及重繪是什麼關係呢?

優秀的前端工程師對重繪和重繪的概念已經很熟悉了,我這裡就不再贅述。瀏覽器有自己的優化機制,包括之前提到的每幀只響應同類別的事件一次,再比如這裡的會把一幀里的多次重排、重繪匯總成一次進行處理。

flush隊列是瀏覽器進行重排、重繪等操作的隊列,所有會引起重排重繪的操作都包含在內,比如dom修改、樣式修改等。如果每次js操作都去執行一次重排重繪,那麼瀏覽器一定會卡卡卡卡卡,所以瀏覽器通常是在一定的時間間隔(一幀)內,批量處理隊列里的操作。但是,對於有些操作,比如獲取元素相對父級元素左邊界的偏移值(Element.offsetLeft),但在此之前我們進行了樣式或者dom修改,這個操作還攢在flush隊列里沒有執行,那麼瀏覽器為了讓我們獲取正確的offsetLeft(雖然之前的操作可能不會影響offsetLeft的值),就會立即執行隊列里的操作。

所以我們知道了,就是這個特殊操作會影響瀏覽器正常的執行和渲染,假設我們頻繁執行這樣的特殊操作,就會打斷瀏覽器原來的節奏,增大開銷。

而這個特殊操作,具體指的就是:

  • elem.offsetLeft, elem.offsetTop, elem.offsetWidth, elem.offsetHeight, elem.offsetParent
  • elem.clientLeft, elem.clientTop, elem.clientWidth, elem.clientHeight
  • elem.getClientRects(), elem.getBoundingClientRect()
  • elem.scrollWidth, elem.scrollHeight
  • elem.scrollLeft, elem.scrollTop
  • ...

See more:What forces layout / reflow

解決辦法呢,有倆:

  • 基礎版:使用前面提到過的requestAnimationFrame(),將以上特殊操作彙集並延遲入隊
  • 進階版:使用第三方FastDom幫助我們自動完成讀寫操作的批處理,實際上它也是建立在requestAnimationFrame()上構造的。官方提供的example看起來效果簡直優秀

FastDom works as a regulatory layer between your app/library and the DOM. By batching DOM access we avoid unnecessary document reflows and dramatically speed up layout performance.

Each measure/mutate job is added to a corresponding measure/mutate queue. The queues are emptied (reads, then writes) at the turn of the next frame using window.requestAnimationFrame.

FastDom aims to behave like a singleton across all modules in your app. When any module requires fastdom they get the same instance back, meaning FastDom can harmonize DOM access app-wide.

Potentially a third-party library could depend on FastDom, and better integrate within an app that itself uses it.

總結

謹慎使用以上特殊的讀操作,要使用也盡量彙集、包裹(requestAnimationFrame()),避免單個裸奔。

Learn more about how to giagnose forced synchronous layouts with chrome DevTools

提升合成層

不知道有沒有人,曾經圍坐在黑夜裡的爐火旁邊,聽前端前輩們傳遞智慧的話語 —— 做位移效果時使用tranform代替top/left/bottom/right,尤其是移動端!

why?

因為top/left/bottom/right屬性性能差呀 —— 這類屬性會影響元素在文檔中的布局,可能改變其他元素的位置,引起重排,造成性能開銷

因為tranform屬性性能好呀 —— 使用transform屬性(3D/animation)將元素提升至合成層,省去布局和繪製環節,美滋滋~

說到這裡,你可能還不是太清楚合成層的概念,其實看這篇就夠了:無線性能優化:Composite

但是照顧一下有些「太長不看」貓病的旁友們,在這裡做一些總結。

1.一些屬性會讓元素們創建出不同的渲染層

  • 有明確的定位屬性(relative、fixed、sticky、absolute)
  • 透明的(opacity 小於 1)
  • 有 CSS 濾鏡(fliter)
  • 有 CSS transform 屬性(不為 none)
  • ...

2.達成一些條件,渲染層會提升為合成層

  • 硬體加速的 iframe 元素(比如 iframe 嵌入的頁面中有合成層)
  • 3D 或者 硬體加速的 2D Canvas 元素
  • video 元素
  • 有 3D transform
  • 對 opacity、transform、fliter、backdropfilter 應用了 animation 或者 transition
  • will-change 設置為 opacity、transform、top、left、bottom、right(其中 top、left 等需要設置明確的定位屬性,如 relative 等)
  • ...

提升為合成層幹什麼呢?普通的渲染層普通地渲染,用普通的順序普通地合成不好嗎?非要搞啥特殊待遇!

瀏覽器就說了:我這也是為了大家共同進步(提升速度)!看那些搞特殊待遇的,都是一些拖我們隊伍後腿的(性能開銷大),分開處理,才能保證整個隊伍穩定快速的進步!

特殊待遇:合成層的點陣圖,會交由 GPU 合成,比 CPU 處理要快。當需要 repaint 時,只需要 repaint 本身,不會影響到其他的層。

對布局屬性進行動畫,瀏覽器需要為每一幀進行重繪並上傳到 GPU 中

對合成屬性進行動畫,瀏覽器會為元素創建一個獨立的複合層,當元素內容沒有發生改變,該層就不會被重繪,瀏覽器會通過重新複合來創建動畫幀

所以,從合成層出發,為了優化scroll性能,我們可以做這些:

will-change

提升合成層的有效方式,應用這個屬性,實際上是提前通知瀏覽器,為接下來的動畫效果操作做準備。值得注意的是

  • 不要將 will-change 應用到太多元素上,增加渲染層意味著新的內存分配和更複雜的層的管理
  • 有節制地使用。動態樣式增加比一開始就寫在樣式表裡更能減少不必要的開銷。

示例:

will-change: scroll-position // 表示開發者希望在不久後改變滾動條的位置或者使之產生動畫。n

然後,國際慣例【並不,附上兼容性

除此之外

  • 使用 transform 或者 opacity 來實現動畫效果
  • 對於較少可能變化的區域,防止頁面其他部分重繪時影響這一片,考慮提升至合成層。
  • 提升合成層的hack方法:translateZ(0)

總結

從合成層的角度作為性能提升的下手方向,是值得肯定的,但是具體採用什麼樣的方案,還是要先切實地分析頁面的實際性能表現,根據不同的場景,綜合考慮方案的得失,再總結出正確的優化途徑。

whats more

使用css屬性代替js「模擬操作」

scroll-behavior

The scroll-behavior CSS property specifies the scrolling behavior for a scrolling box, when scrolling happens due to navigation or CSSOM scrolling APIs. Any other scrolls, e.g. those that are performed by the user, are not affected by this property. When this property is specified on the root element, it applies to the viewport instead.

可以藉此實現affix,而不用使用定時器或requestAnimationFrame模擬平滑的scroll操作

demo戳:錨點鏈接+scroll-behavior

但是目前僅是實驗性的功能,殘念


總結

頁面渲染性能的優化涉及方方面面,這裡只是以scroll事件為立足點分析列舉了一些改善的方法,深入性和全面性都不足,但更多希望能起到一個引子的作用,給有心深入的同學一個概括性的印象。


參考鏈接

以下這些大大們的文章都很值得閱讀分析做筆記!

  • 渲染性能
  • 【前端性能】高性能滾動 scroll 及頁面渲染優化
  • 瀏覽器渲染詳細過程:重繪、重排和 composite 只是冰山一角
  • Web Workers, for a responsive JavaScript application
  • JavaScript 函數節流和函數去抖應用場景辨析
  • 從event loop規範探究javaScript非同步及瀏覽器更新渲染時機

推薦閱讀:

「每日一題」你是如何做性能優化的?
Angular AOT優化構建嘗試
PWA 漸進式實踐 (1) - Lighthouse in Action
前端性能優化:客戶端從輸入到展示講解

TAG:前端性能优化 | 前端开发 | 前端工程师 |