前端搞工程化:從零打造性能檢測庫「源碼 + 視頻」

工程化體系專欄永遠首發自個人 Github,你們能夠關注點贊,一般會早於發佈各大平臺一週時間以上。

本文涉及到的源碼及視頻地址:前端

  • 源碼
  • 視頻,由於製做確定比文字須要的時間多,因此本週纔會更新完畢,你們能夠先對視頻插個眼

前言

常常有讀者問我什麼是前端工程化?該怎麼開始作前端工程化?git

聊下來之後得出一些結論:這類讀者廣泛就任於中小型公司,前端人員個位數,平時疲於開發,團隊內部幾乎沒有基礎建設,工具很蠻荒。工程化對於這些讀者來講很陌生,基本不知道這究竟是什麼,或者說認爲 Webpack 就是前端工程化的所有了。github

筆者目前就任於某廠的基礎架構組,爲百來號前端提供基礎服務建設,對於這個領域有些許皮毛經驗。所以有了一些想法,前端搞工程化會是筆者今年開坑的一個系列做品,每塊內容會以文章 + 源碼 + 視頻的方式呈現。web

這個系列的產出適用於如下羣體:面試

  • 中小廠前端,基建蠻荒,平時疲於業務,不知道業務外怎麼作東西能提升本身的競爭力、豐富簡歷
  • 公司暫時沒有作基建計劃,只能業餘作一些低成本收益高的產品
  • 想了解前端工程化

須要說明的是產出只會是一個低成本下的最小可用產品,你能夠拿來按需增長功能、參考思路或者純粹當學習一點知識。前端工程化

什麼是前端工程化?

由於是該系列第一篇文章,就先來大體說下什麼是前端工程化。瀏覽器

個人理解是前端工程化大致上能夠理解爲是作提效工程,從寫代碼開始的每一步均可以作工程化。好比說你用 IDE 對比記事本寫代碼的體驗及效率確定是不同的;好比說 Webpack 等這類工具也是在幫助咱們提高開發、構建的效率,其餘的工具也就不一一列出了,你們知道意思就好。性能優化

固然了,今天要聊到的性能檢測也是工程化的一部分。畢竟咱們須要有個工具去協助找到應用到底在哪塊地方存在性能短板,能幫助開發者更快地定位問題,而不是在生產環境中讓用戶抱怨產品卡頓。網絡

爲何須要性能檢測?

性能優化是不少前端都繞不開的話題,先不說項目是否須要性能優化,面試的時候這類問題是很常見的。數據結構

可是光會性能優化的手段仍是不夠的,咱們最後仍是須要作出先後數據對比才能體現出此次優化的價值到底有多少,畢竟數據的量化在職場中仍是至關重要的。老闆不知道你具體作的事情,不少東西都得從數據中來看,數據越好看就說明你完成工做的能力越高。

想獲取性能的先後數據變化,咱們確定得用一些工具來作性能檢測。

性能該怎麼檢測?

性能檢測的方式有不少:

  • Chrome 自帶的開發者工具:Performance
  • Lighthouse 開源工具
  • 原生 Performance API
  • 各類官方庫、插件

這些方法各有各的好處,前兩種方式簡單快捷,可以可視化各種指標,可是很難拿到用戶端的數據,畢竟你不大可能讓用戶去跑這些工具,固然除此以外還有一些很小的缺點,好比說拿不到重定向次數等等。

官方庫、插件相比前二者來講會遜色不少,而且只提供一部分核心指標。

原生 Performance API 存在兼容問題,可是能覆蓋到開發生產階段,而且功能也能覆蓋自帶的開發者工具:Performance 工具。不只在開發階段能瞭解到項目的性能指標,還能獲取用戶端的數據,幫助咱們更好地制定優化方案。另外能獲取的指標也很齊全,所以是這次咱們產品的選擇。

固然了這不是多選一的選擇題,咱們在開發階段仍是須要將 Performance 工具及 API 結合起來使用,畢竟他們仍是有着相輔相成的做用。

實戰

這是此處產品的源碼:地址

一些性能指標

在開始實戰前,咱們仍是得來了解一些性能指標,隨着時代發展,其實一些老的性能優化文章已經有點過期了。谷歌一直在更新性能優化這塊的指標,筆者以前寫過一篇文章來說述當下的最新性能指標有哪些,有興趣的讀者能夠先詳細的讀一下。

固然若是你嫌太長不看,能夠先經過如下思惟導圖簡單瞭解一下:

固然除了這個指標之外,咱們還須要獲取網絡、文件傳輸、DOM等信息豐富指標內容。

Performance 使用

Performance 接口能夠獲取到當前頁面中與性能相關的信息,而且提供高精度的時間戳,秒殺 Date.now()。首先咱們來看下這個 API 的兼容性:

這個百分比其實已經算是兼容度很高了,主流瀏覽器的版本都能很好的支持。

對於 Performance 上 API 具體的講解文中就不贅述了,有興趣的能夠閱讀 MDN 文檔,筆者在這裏只講幾個後續用到的重要 API。

getEntriesByType

這個 API 可讓咱們經過傳入 type 獲取一些相應的信息:

  • frame:事件循環中幀的時間數據。
  • resource:加載應用程序資源的詳細網絡計時數據
  • mark:performance.mark 調用信息
  • measure:performance.measure 調用信息
  • longtask:長任務(執行時間大於 50ms)信息。這個類型已被廢棄(文檔未標註,可是在 Chrome 中使用會顯示已廢棄),咱們能夠經過別的方式來拿
  • navigation:瀏覽器文檔事件的指標的方法和屬性
  • paint:獲取 FP 和 FCP 指標

最後兩個 type 是性能檢測中獲取指標的關鍵類型。固然你若是還想分析加載資源相關的信息的話,那能夠多加上 resource 類型。

PerformanceObserver

PerformanceObserver 也是用來獲取一些性能指標的 API,用法以下:

const perfObserver = new PerformanceObserver((entryList) => {
    // 信息處理
})
// 傳入須要的 type
perfObserver.observe({ type: 'longtask', buffered: true })

結合 getEntriesByType 以及 PerformanceObserver,咱們就能獲取到全部須要的指標了。

上代碼!

由於已經貼了源碼地址,筆者就不貼大段代碼上來了,會把主要的從零到一過程梳理一遍。

首先咱們確定要設計好用戶如何調用 SDK(代指性能檢測庫)?須要傳遞哪些參數?如何獲取及上報性能指標?

通常來講調用 SDK 可能是構建一個實例,因此此次咱們選擇 class 的方式來寫。參數的話暫定傳入一個 tracker 函數獲取各種指標以及 log 變量決定是否打印指標信息,簽名以下:

export interface IPerProps {
  tracker?: (type: IPerDataType, data: any, allData: any) => void
  log?: boolean
}

export type IPerDataType =
  | 'navigationTime'
  | 'networkInfo'
  | 'paintTime'
  | 'lcp'
  | 'cls'
  | 'fid'
  | 'tbt'

接下來咱們寫 class 內部的代碼,首先在前文中咱們知道了 Performance API 是存在兼容問題的,因此咱們須要在調用 Performance 以前判斷一下瀏覽器是否支持:

export default class Per {
  constructor(args: IPerProps) {
    // 存儲參數
    config.tracker = args.tracker
    if (typeof args.log === 'boolean') config.log = args.log
    // 判斷是否兼容
    if (!isSupportPerformance) {
      log(`This browser doesn't support Performance API`)
      return
    }
}

export const isSupportPerformance = () => {
  const performance = window.performance
  return (
    performance &&
    !!performance.getEntriesByType &&
    !!performance.now &&
    !!performance.mark
  )
}

以上前置工做完畢之後,就能夠開始寫獲取性能指標數據的代碼了。

咱們首先經過 performance.getEntriesByType('navigation') 來獲取關於文檔事件的指標

這個 API 仍是能拿到挺多事件的時間戳的,若是你想了解這些事件具體含義,能夠閱讀文檔,這裏就不復制過來佔用篇幅了。

看到那麼多字段,可能有的讀者就暈了,那麼多東西我可怎麼算指標。其實不須要擔憂,看完下圖結合剛纔的文檔就好了:

咱們不須要所有利用上得到的字段,重要的指標信息暴露出來便可,照着圖和文檔依樣畫葫蘆就能得出代碼:

export const getNavigationTime = () => {
  const navigation = window.performance.getEntriesByType('navigation')
  if (navigation.length > 0) {
    const timing = navigation[0] as PerformanceNavigationTiming
    if (timing) {
    //   解構出來的字段,太長不貼
      const {...} = timing

      return {
        redirect: {
          count: redirectCount,
          time: redirectEnd - redirectStart,
        },
        appCache: domainLookupStart - fetchStart,
        // dns lookup time
        dnsTime: domainLookupEnd - domainLookupStart,
        // handshake end - handshake start time
        TCP: connectEnd - connectStart,
        // HTTP head size
        headSize: transferSize - encodedBodySize || 0,
        responseTime: responseEnd - responseStart,
        // Time to First Byte
        TTFB: responseStart - requestStart,
        // fetch resource time
        fetchTime: responseEnd - fetchStart,
        // Service work response time
        workerTime: workerStart > 0 ? responseEnd - workerStart : 0,
        domReady: domContentLoadedEventEnd - fetchStart,
        // DOMContentLoaded time
        DCL: domContentLoadedEventEnd - domContentLoadedEventStart,
      }
    }
  }
  return {}
}

你們能夠發現以上得到的指標中有很多是和網絡有關係的,所以咱們還須要結合網絡環境來分析,獲取網絡環境信息很方便,如下是代碼:

export const getNetworkInfo = () => {
  if ('connection' in window.navigator) {
    const connection = window.navigator['connection'] || {}
    const { effectiveType, downlink, rtt, saveData } = connection
    return {
      // 網絡類型,4g 3g 這些
      effectiveType,
      // 網絡下行速度
      downlink,
      // 發送數據到接受數據的往返時間
      rtt,
      // 打開/請求數據保護模式
      saveData,
    }
  }
  return {}
}

拿完以上的指標以後,咱們須要用到 PerformanceObserver 來拿一些核心體驗(性能)指標了。好比說 FP、FCP、FID 等等,內容就包括在咱們上文中看過的思惟導圖中:

在這以前咱們須要先了解一個注意事項:頁面是有可能在處於後臺的狀況下加載的,所以這種狀況下獲取的指標是不許確的。因此咱們須要忽略掉這種狀況,經過如下代碼來存儲一個變量,在獲取指標的時候比較一下時間戳來判斷是否處於後臺中:

document.addEventListener(
  'visibilitychange',
  (event) => {
    // @ts-ignore
    hiddenTime = Math.min(hiddenTime, event.timeStamp)
  },
  { once: true }
)

接下來是獲取指標的代碼,由於他們獲取方式大同小異,因此先把獲取方法封裝一下:

// 封裝一下 PerformanceObserver,方便後續調用
export const getObserver = (type: string, cb: IPerCallback) => {
  const perfObserver = new PerformanceObserver((entryList) => {
    cb(entryList.getEntries())
  })
  perfObserver.observe({ type, buffered: true })
}

咱們先來獲取 FP 及 FCP 指標:

export const getPaintTime = () => {
  const data: { [key: string]: number } = ({} = {})
  getObserver('paint', entries => {
    entries.forEach(entry => {
      data[entry.name] = entry.startTime
      if (entry.name === 'first-contentful-paint') {
        getLongTask(entry.startTime)
      }
    })
  })
  return data
}

拿到的數據結構長這樣:

須要注意的是在拿到 FCP 指標之後須要同步開始獲取 longtask 的時間,這是由於後續的 TBT 指標須要使用 longtask 來計算。

export const getLongTask = (fcp: number) => {
  getObserver('longtask', entries => {
    entries.forEach(entry => {
      // get long task time in fcp -> tti
      if (entry.name !== 'self' || entry.startTime < fcp) {
        return
      }
      // long tasks mean time over 50ms
      const blockingTime = entry.duration - 50
      if (blockingTime > 0) tbt += blockingTime
    })
  })
}

接下來咱們來拿 FID 指標,如下是代碼:

export const getFID = () => {
  getObserver('first-input', entries => {
    entries.forEach(entry => {
      if (entry.startTime < hiddenTime) {
        logIndicator('FID', entry.processingStart - entry.startTime)
        // TBT is in fcp -> tti
        // This data may be inaccurate, because fid >= tti
        logIndicator('TBT', tbt)
      }
    })
  })
}

FID 的指標數據長這樣,須要用戶交互纔會觸發:

在獲取 FID 指標之後,咱們也去拿了 TBT 指標,可是拿到的數據不必定是準確的。由於 TBT 指標的含義是在 FCP 及 TTI 指標之間的長任務阻塞時間之和,但目前好像沒有一個好的方式來獲取 TTI 指標數據,因此就用 FID 暫代了。

最後是 CLS 和 LCP 指標,大同小異就貼在一塊兒了:

export const getLCP = () => {
  getObserver('largest-contentful-paint', entries => {
    entries.forEach(entry => {
      if (entry.startTime < hiddenTime) {
        const { startTime, renderTime, size } = entry
        logIndicator('LCP Update', {
          time: renderTime | startTime,
          size,
        })
      }
    })
  })
}

export const getCLS = () => {
  getObserver('layout-shift', entries => {
    let cls = 0
    entries.forEach(entry => {
      if (!entry.hadRecentInput) {
        cls += entry.value
      }
    })
    logIndicator('CLS Update', cls)
  })
}

拿到的數據結構長這樣:

截屏2021-01-17下午7.37.33
截屏2021-01-17下午7.37.14

另外這兩個指標還和別的不大同樣,並非一成不變的。一旦有新的數據符合指標要求,就會更新。

以上就是咱們須要獲取的全部性能指標了,固然光獲取到指標確定是不夠,還須要暴露每一個數據給用戶,對於這種統一操做,咱們須要封裝一個工具函數出來:

// 打印數據
export const logIndicator = (type: string, data: IPerData) => {
  tracker(type, data)
  if (config.log) return
  // 讓 log 好看點
  console.log(
    `%cPer%c${type}`,
    'background: #606060; color: white; padding: 1px 10px; border-top-left-radius: 3px; border-bottom-left-radius: 3px;',
    'background: #1475b2; color: white; padding: 1px 10px; border-top-right-radius: 3px;border-bottom-right-radius: 3px;',
    data
  )
}
export default (type: string, data: IPerData) => {
  const currentType = typeMap[type]
  allData[currentType] = data
  // 若是用戶傳了回調函數,那麼每次在新獲取指標之後就把相關信息暴露出去
  config.tracker && config.tracker(currentType, data, allData)
}

封裝好函數之後,咱們能夠這樣調用:

logIndicator('FID', entry.processingStart - entry.startTime)

在這裏爲止咱們 SDK 的大致內容已經完成了,咱們能夠按需添加一些小功能,好比說獲取指標分數。

指標分數是官方給的一些建議,你能夠在官方 Blog 或者個人文章中看到定義的數據。

代碼不復雜,咱們就以獲取 FCP 指標的分數爲例演示一下代碼:

export const scores: Record<string, number[]> = {
  fcp: [2000, 4000],
  lcp: [2500, 4500],
  fid: [100, 300],
  tbt: [300, 600],
  cls: [0.1, 0.25],
}

export const scoreLevel = ['good', 'needsImprovement', 'poor']

export const getScore = (type: string, data: number) => {
  const score = scores[type]
  for (let i = 0; i < score.length; i++) {
    if (data <= score[i]) return scoreLevel[i]
  }

  return scoreLevel[2]
}

首先是獲取分數相關的工具函數,這塊反正就是看着官方建議照抄,而後咱們只須要在剛纔獲取指標的地方多加一句代碼便可:

export const getPaintTime = () => {
  getObserver('paint', (entries) => {
    entries.forEach((entry) => {
      const time = entry.startTime
      const name = entry.name
      if (name === 'first-contentful-paint') {
        getLongTask(time)
        logIndicator('FCP', {
          time,
          score: getScore('fcp', time),
        })
      } else {
        logIndicator('FP', {
          time,
        })
      }
    })
  })
}

結束了,有興趣的能夠來這裏讀一下源碼,反正也沒幾行。

最後

文章週末寫的,略顯倉促,若有出錯請斧正,同時也歡迎你們一塊兒探討問題。

想看更多文章能夠關注個人 Github 或者進羣一塊兒聊聊前端工程化。

相關文章
相關標籤/搜索