如何使頁面交互更流暢

流暢性

本篇是基於 FDCon2019 上《讓你的網頁更絲滑by劉博文》的覆盤文。該課題也是博主感興趣的領域, 後續會結合 React 的 Schedule 與該文進行進一步整合, 個人博客

  • 被動交互: animation
  • 主動交互: 鼠標、鍵盤

被動交互

當前市面上的設備頻率在 60 HZ 以上。

主動交互

跑如下界面 https://code.h5jun.com/pojob

結合如下代碼塊, 可以看到 100ms 以下的點擊是順暢的, 而超過 100ms 的點擊就會有卡頓現象。

var observer = new PerformanceObserver(function(list) {
  var perfEntries = list.getEntries()
  console.log(perfEntries)
});
observer.observe({entryTypes: ["longtask"]});

讓用戶感覺到流暢

衡量一個網頁/App 是否流暢有個比較好用的 Rail 模型, 它大概有以下幾個評判標準值。

Response —— 100ms
Animation —— 16.7ms
Idle —— 50ms
Load —— 1000ms

像素管道

像素管道一般由 5 個部分組成。JavaScript、樣式、佈局、繪製、合成。如下圖所示:

渲染性能

保證主動交互讓用戶感覺流暢

function App() {
  useEffect(() => {
    setTimeout(_ => {
      const start = performance.now()
      while (performance.now() - start < 1000) { }
      console.log('done!')
    }, 5000)
  })
  return (
    <input type="text" />
  );
}

一般超過 50 ms 認爲是 long task(長任務), long task 會阻塞 main thread 的運行, 如下是兩種解決方案。

Web Worker

app.js 代碼如下:

import React, {useEffect} from 'react'
import WorkerCode from './worker'

function App() {
  useEffect(() => {
    const testWorker = new Worker(WorkerCode)
    setTimeout(() => {
      testWorker.postMessage({})
      testWorker.onmessage = function(ev) {
        console.log(ev.data)
      }
    }, 5000)
  })
  return (
    <input type="text" />
  );
}

worker.js 代碼如下:

const workerCode = () => {
  self.onmessage = function() {
    const start = performance.now()
    while (performance.now() - start < 1000) { }
    postMessage('done!')
  }
}

此時在輸入框輸入時沒有卡頓的感覺。

Time Slicing

下面是另外一種使頁面流暢的方法 —— Time Slicing(時間分片)。

觀察 Chrome 的 Performance, 火焰圖如下,

從火焰圖可以看出主線程被拆分爲了多個時間分片, 所以不會造成卡頓。時間分片的代碼片段如下所示:

function timeSlicing(gen) {
  if (typeof gen === 'function') gen = gen()
  if (!gen || typeof gen.next !== 'function') return

  (function next() {
    const res = gen.next() // ①
    if (res.done) return // ⑤
    setTimeout(next) // ③
  })()
}

// 調用時間分片函數
timeSlicing(function* () {
  const start = performance.now()
  while (performance.now() - start < 1000) {
    console.log('執行邏輯')
    yield // ②
  }
  console.log('done') // ④
})

該函數雖然代碼量不長, 但卻不易理解。前置知識 Generator

下面對該函數進行分析:

  1. 往時間分片函數 timeSlicing 中傳入 generator 函數;
  2. 函數的執行順序 —— ①、②、③、① (此時有個競賽的關係, 如果 performance.now() - start < 1000 則繼續 ②、③, 如果 performance.now() - start >= 1000 則跳出循環執行 ④、⑤);

conclusion

針對 long task 會阻塞 main thread 的運行的情形, 給出兩種解決方案:

  • Web Worker: 使用 Web Worker 提供的多線程環境來處理 long task;
  • Time Slicing: 將主線程上的 long task 進行時間分片;

保證被動交互讓用戶感覺流暢

保證 16.7ms 有新的一幀傳輸到界面上。除去用戶的邏輯代碼, 一幀內留給瀏覽器整合的時間大概只有 6ms 左右, 回到像素管道上來, 我們可以從這幾方面進行優化:

避免 CSS 選擇器嵌套過深

Style 這部分的優化在 css 樣式選擇器的使用, css 選擇器使用的層級越多, 耗費的時間越多。以下是測試 css 選擇器不同層級篩選相同元素的一次測試結果。

div.box:not(:empty):last-of-type span         2.25ms
index.html:85 .box--last span                 0.28ms
index.html:85 .box:nth-last-child(-n+1) span  2.51ms

避免佈局重排

// 先修改值
el.style.witdh = '100px'
// 後取值
const width = el.offsetWidth

這段代碼有什麼問題呢?

可以看到它會造成佈局重排。

應對的策略是調整它們的執行順序,

// 先取值
const width = el.offsetWidth
// 後修改值
el.style.witdh = '100px'

可以看到經過調換順序後, 後執行的 el.style.width 會新開一個像素管道, 而不會在原先的像素管道進行重排。

此外不要在循環中執行如下的操作,

for (var i = 0; i < 1000; i++) {
  const newWidth = container.offsetWidth; // ①
  boxes[i].style.width = newWidth + 'px'; // ②
}

可以在火焰圖中看到它發生了重繪的警告,

執行順序是 ①②①②①②①..., 假若我們在第一個 ① 後面插入一條豎線後 ①|②①②①②①, 其就變成先修改值後取值的情景, 所以也就發生了重繪!

正確的使用姿勢應該如下:

const newWidth = container.offsetWidth;
for (var i = 0; i < 1000; i++) {
  boxes[i].style.width = newWidth + 'px';
}

避免重繪

創建 Layers(圖層) 可以避免重繪,

{
  transform: translateZ(0);
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章