工程化體系專欄永遠首發自我的 Github,大家可以關注點贊,通常會早于發布各大平臺一周時間以上。
本文涉及到的源碼及視頻地址:
前言
經常有讀者問我什么是前端工程化?該怎么開始做前端工程化?
聊下來以后得出一些結論:這類讀者普遍就職于中小型公司,前端人員個位數,平時疲于開發,團隊內部幾乎沒有基礎建設,工具很蠻荒。工程化對于這些讀者來說很陌生,基本不知道這到底是什么,或者說認為 Webpack 就是前端工程化的全部了。
筆者目前就職于某廠的基礎架構組,為百來號前端提供基礎服務建設,對于這個領域有些許皮毛經驗。因此有了一些想法,前端搞工程化會是筆者今年開坑的一個系列作品,每塊內容會以文章 + 源碼 + 視頻的方式呈現。
這個系列的產出適用于以下群體:
-
中小廠前端,基建蠻荒,平時疲于業務,不知道業務外怎么做東西能提高自己的競爭力、豐富簡歷
-
公司暫時沒有做基建計劃,只能業余做一些低成本收益高的產品
-
想了解前端工程化
需要說明的是產出只會是一個低成本下的最小可用產品,你可以拿來按需增加功能、參考思路或者純粹當學習一點知識。
什么是前端工程化?
因為是該系列第一篇文章,就先來大致說下什么是前端工程化。
我的理解是前端工程化大體上可以理解為是做提效工程,從寫代碼開始的每一步都可以做工程化。比如說你用 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)
})
}
拿到的數據結構長這樣:
另外這兩個指標還和別的不大一樣,并不是一成不變的。一旦有新的數據符合指標要求,就會更新。
以上就是我們需要獲取的所有性能指標了,當然光獲取到指標肯定是不夠,還需要暴露每個數據給用戶,對于這種統一操作,我們需要封裝一個工具函數出來:
// 打印數據
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 或者進群一起聊聊前端工程化。
想進群的讀者可以關注下我的公眾號「前端真好玩」,發送關鍵字「1」即可獲取入群二維碼。