程式碼快不快?跑個分就知道

奇舞週刊發表於2018-08-20

編者按:本文作者是來自360奇舞團的前端開發工程師劉宇晨,同時也是 W3C 效能工作組成員。

上一回,筆者介紹了 Navigation TimingResource Timing 在監控頁面載入上的實際應用。

這一回,筆者將帶領大家學習 Performance TimelineUser Timing 標準,並使用相應的 API,給前端程式碼“跑個分”。

為什麼要學習這兩個標準?

真實業務中,時而會出現比較消耗效能的操作,特別是頻繁操作 DOM 的行為。那麼如何量化這些操作的效能表現呢?

常見的做法,就是通過分別記錄函式執行前和執行之後的 Date.now(),然後求差,得出具體的執行時間。

記錄一兩個函式還好,多了的話,還需要開發者維護一個全域性的 hash ,用來統計全部資料。

隨著 Performance Timeline + User Timing 標準的推出,開發者可以直接使用相應的 API,瀏覽器便會直接統計相關資訊,從而顯著簡化了衡量前端效能的流程。

什麼是 Performance Timeline

根據 W3C 的定義,Performance Timeline 旨在幫助 Web 開發者在 Web 應用的整個生命週期中訪問、檢測、獲取各類效能指標,並定義相關介面。

什麼是 User Timing

User Timing 相較於 Performance Timeline 而言,更為細節。該標準擴充了原有的 Performance 介面,並新增了供前端開發者主動記錄效能指標的新方法。

截至到 2018 年 7 月 29 日,Performance TimelineUser Timing 的最新標準均為 Level 2,且均處於編輯草稿狀態。

瀏覽器相容性

圖為 Performance Timeline Level 2PerformanceObserver API 的支援情況:

Performance Timeline Level 2 在實際應用時,主要使用 PerformanceObserver API。

Performance Timeline

圖為 User Timing 的支援情況:

User Timing

兩者究竟怎麼使用?

入門級

假如你有一段比較耗效能的函式 foo,你好奇在不同瀏覽器中,執行 foo 所需的時間分別是多少,那麼你可以這麼做:

const prefix = fix => input => `${fix}${input}`
const prefixStart = prefix('start')
const prefixEnd = prefix('end')
const measure = (fn, name = fn.name) => {
  performance.mark(prefixStart(name))
  fn()
  performance.mark(prefixEnd(name))
}
複製程式碼

上述程式碼中,使用了一個新的 API :performance.mark

根據標準,呼叫 performance.mark(markName) 時,發生瞭如下幾步:

  • 建立一個新的 PerformanceMark 物件(以下稱為條目);
  • name 屬性設定為 markName
  • entryType 屬性設定為 'mark'
  • startTime 屬性設定為 performance.now() 的值;
  • duration 屬性設定為 0
  • 將條目放入佇列中;
  • 將條目加入到 performance entry buffer 中;
  • 返回 undefined

關於“放入佇列”的含義,請參見 w3c.github.io/performance… [1] 中 Queue a PerformanceEntry

上述過程,可以簡單理解為,“請瀏覽器記錄一條名為 markName 的效能記錄”。

之後,我們可以通過 performance.getEntriesByType 獲取具體資料:

const getMarks = key => {
  return performance
    .getEntriesByType('mark') // 只獲取通過 performance.mark 記錄的資料
    .filter(({ name }) => name === prefixStart(key) || name === prefixEnd(key))
}
const getDuration = entries => {
  const { start = 0, end = 0 } = entries.reduce((last, { name, startTime }) => {
    if (/^start/.test(name)) {
      last.start = startTime
    } else if (/^end/.test(name)) {
      last.end = startTime
    }
    return last
  })
  return end - start
}
const retrieveResult = key => getDuration(getMarks(key))
複製程式碼

performance.getEntriesByType('mark') 就是指明獲取由 mark 產生的資料。

“獲取個資料,怎麼程式碼還要一大坨?尤其是 getDuration 中,區分開始、結束時間的部分,太瑣碎吧!?“

W3C 效能小組早就料到有人會抱怨,於是進一步設計了 performance.measure 方法~

performance.measure 方法接收三個引數,依次是 measureNamestartMark 以及 endMark

startMarkendMark 很容易理解,就是對應開始和結束時的 markNamemeasureName 則是為每一個 measure 行為,提供一個標識。

呼叫後,performance.measure 會根據 startMarkendMark 對應的兩條記錄(均由 performance.mark 產生),形成一條 entryType'measure' 的新記錄,並自動計算執行時長。

幕後發生的具體步驟,和 performance.mark 很類似,有興趣的讀者可以參考規範中的 3.1.3 小節 www.w3.org/TR/user-tim… [2]

使用 performance.measure 重構一下前的程式碼:

const measure = (fn, name = fn.name) => {
  const startName = prefixStart(name)
  const endName = prefixEnd(name)
  performance.mark(startName)
  fn()
  performance.mark(endName)
  // 呼叫 measure
  performance.measure(name, startName, endName)
}
const getDuration = entries => {
  // 直接獲取 duration
  const [{ duration }] = entries
  return duration
}
const retrieveResult = key => getDuration(performance.getEntriesByName(key))

// 使用時
function foo() {
  // some code
}
measure(foo)
const duration = retrieveResult('foo')
console.log('duration of foo is:', duration)
複製程式碼

如何?是不是更清晰、簡練了~

這裡,我們直接通過 performance.getEntriesByName(measureName) 的形式,獲取由 measure 產生的資料。

非同步函式

非同步函式?async await 來一套:

const asyncMeasure = async (fn, name = fn.name) => {
  const startName = prefixStart(name)
  const endName = prefixEnd(name)
  performance.mark(startName)
  await fn()
  performance.mark(endName)
  // 呼叫 measure
  performance.measure(name, startName, endName)
}
複製程式碼

回顧

mark measure
作用 進行某個操作時,記錄一個時間戳 針對起始 + 結束的 mark 值,彙總形成一個直接可用的效能資料
不足 對於一個操作,需要兩個時間戳才能衡量效能表現 想要測量多個操作時,需要重複呼叫

以上相關 API,全部來自於 User Timing Level 2 。當加入 Performance Timeline 後,我們可以進一步優化程式碼結構~

進階版

如上文所述,每次想看效能表現,似乎都要主動呼叫一次 retrieveResult 函式。一兩次還好,次數多了,無疑增加了重複程式碼,違反了 DRY 的原則。

Performance Timeline Level 2 中,工作組新增了新的 PerformanceObserver 介面,旨在解決以下三個問題:

  • 每次檢視資料時,都要主動呼叫介面;
  • 當獲取不同型別的資料指標時,產生重複邏輯;
  • 其他資源需要同時操作 performance buffer 時,產生資源競爭情況。

對於前端工程師而言,實際使用時只是換了一套 API 。

依舊是測量某操作的效能表現,在支援 Performance Timeline Level 2 的瀏覽器中,可以這麼寫:

const observer = new PerformanceObserver(list =>
  list.getEntries().map(({ name, startTime }) => {
    // 如何利用資料的邏輯
    console.log(name, startTime)
    return startTime
  })
)
observer.observe({
  entryTypes: ['mark'],
  buffered: true
})
複製程式碼

聰慧的你應該發現了一些變化:

  • 使用了 getEntries 而不是 getEntriesByType
  • 呼叫 observe 方法時,設定了 entryTypesbuffer

因為在呼叫 observe 方法時設定了想要觀察的 entryTypes,所以不需要再呼叫 getEntriesByType

buffered 欄位的含義是,是否向 observerbuffer 中新增該條目(的 buffer),預設值是 false

關於為什麼會有 buffered 的設定,有興趣的讀者可以參考 github.com/w3c/perform… [3]

PerformanceObserver 建構函式

回過頭來看一看 PerformanceObserver

例項化時,接收一個引數,名為 PerformanceObserverCallback,顧名思義是一個回撥函式。

該函式有兩個引數,分別是 PerformanceObserverEntryListPerformanceObserver。前者就是我們關心的效能資料的集合。實際上我們已經見過了好幾次,例如 performance.getEntriesByType('navigation') 就會返回這種資料型別;後者則是例項化物件,可以理解為函式提供了一個 this 值。

所有跟資料有關的具體操作,如上報、列印等,均可以在 PerformanceObserverCallback 中進行。

例項化後,返回一個 observer 物件。該物件具備兩個關鍵方法,分別是 observedisconnect

  • observe 用於告訴瀏覽器,“我想觀察這幾類效能資料”;
  • disconnect 用於斷開觀察,清空 buffer

為什麼會有 disconnect 方法?略具諷刺的一個事實是,長時間持續觀察效能資料,是一個比較消耗效能的行為。因此,最好在“合適”的時間,停止觀察,清空對應 buffer,釋放效能。

使用 PerformanceObserver + performance.measure 對之前程式碼進行重構:

// 在 measure.js 中
const getMeasure = () => {
  const observer = new PerformanceObserver(list => {
    list.getEntries().forEach(({ name, duration }) => {
      console.log(name, duration)
      // 運算元據的邏輯
    })
  })
  // 只需要關注 measure 的資料
  observer.observe({
    entryTypes: ['measure'],
    buffered: true
  })
  return observer
}

// 專案入口檔案的頂部
let observer
if (window.PerformanceObserver) {
  observer = getMeasure()
}

// 某一個合適的時間 不再需要監控效能了
if (observer) {
  observer.disconnect()
}
複製程式碼

如此一來,獲取效能資料的操作實現了 DRY 。

注意事項

假如螢幕前的你已經摩拳擦掌,躍躍欲試,且先緩一緩,看看以下幾點注意事項:

  • 兩個標準提供的效能監測能力,不僅僅侷限於前端程式碼,對於哪些比較複雜的非同步介面,也可以通過 async + await 的形式監測“介面效能”(這裡強調的是使用者感知的效能,因為介面表現會顯著受到網路環境、快取使用、代理伺服器等等的影響);
  • 如果要上報資料,需要思考,相關程式碼是否需要全量推送?更好的方式可能是:(基於 User Agent 的)灰度;
  • 不要是個函式就 measure 一下。應當重點關注可能出現效能瓶頸的場景。且只有真正發生瓶頸時,再嘗試資料上報;
  • 本文目的,並非想要替代各個 benchmark 庫。實際上,基礎的效能測試還是應當由 benchmark 庫完成。本文的關注點,更多在於“監控”,即使用者體驗,發現真實發生的效能瓶頸場景。

總結

Performance Timeline + User Timing 為前端開發者提供了衡量程式碼效能的利器。如果遇到的專案是“效能敏感”型,那麼儘早開始嘗試吧~

鳴謝

高峰、黃小璐、劉博文與李鬆峰等人對文章結構、細節提出了寶貴的修訂意見,排名分先後(時間順序),特此鳴謝。

文內連結

  1. w3c.github.io/performance…

  2. www.w3.org/TR/user-tim…

  3. github.com/w3c/perform…

關於奇舞週刊

《奇舞週刊》是360公司專業前端團隊「奇舞團」運營的前端技術社群。關注公眾號後,直接傳送連結到後臺即可給我們投稿。

程式碼快不快?跑個分就知道

相關文章