作者:京東科技 孫凱
相信很多前端開發者在做專案時同時也都做過頁面效能優化,這不單是前端的必備職業技能,也是考驗一個前端基礎是否紮實的考點,而效能指標也通常是每一個開發者的績效之一。尤其馬上接近年關,頁面白屏時間是否過長、首屏載入速度是否達標、動畫是否能流暢執行,諸如此類關於效能更具體的指標和感受,很可能也是決定著年底你能拿多少年終獎回家過年的晴雨表。
關於效能優化,我們一般從以下四個方面考慮:
開發時效能優化
編譯時效能優化
載入時效能優化
執行時效能優化
而本文將從第三個方面展開,講一講哪些因素將影響到頁面載入總時長,談到總時長,那總是避免不了要談及window.onload
,這不但是本文的重點,也是常見頁面效能監控工具中必要的API之一,如果你對自己頁面載入的總時長不滿意,歡迎讀完本文後在評論區交流。
這個掛載到window
上的方法,是我剛接觸前端時就掌握的技能,我記得尤為深刻,當時老師說,「對於初學者,只要在這個方法裡寫邏輯,一定沒錯兒,它是整個檔案載入完畢後執行的生命週期函數」,於是從那之後,幾乎所有的練習demo,我都寫在這裡,也確實沒出過錯。
在MDN
上,關於onload
的解釋是這樣的:load 事件在整個頁面及所有依賴資源如樣式表和圖片都已完成載入時觸發。它與DOMContentLoaded
不同,後者只要頁面 DOM 載入完成就觸發,無需等待依賴資源的載入。該事件不可取消,也不會冒泡。
後來隨著前端知識的不斷擴充,這個方法後來因為有了「更先進」的DOMContentLoaded
,在我的程式碼裡而逐漸被替代了,目前除了一些極其特殊的情況,否則我幾乎很難用到window.onload
這個API,直到認識到它影響到頁面載入的整體時長指標,我才又一次拾起來它。
本章節主要會通過幾個常用的業務場景展開描述,但是有個前提,就是如何準確記錄各種型別資源載入耗時對頁面整體載入的影響,為此,有必要先介紹一下前提。
為了準確描述資源載入耗時,我在本地環境啟動了一個用於資源請求的node
服務,所有的資源都會從這個服務中獲取,之所以不用遠端伺服器資源的有主要原因是,使用本地服務的資源可以在存取的資源連結中設定延遲時間,如存取指令碼資源http://localhost:3010/index.js?delay=300
,因連結中存在delay=300
,即可使資源在300毫秒後返回,這樣即可準確控制每個資源載入的時間。
以下是node
資源請求服務延遲相關程式碼,僅僅是一箇中介軟體:
const express = require("express")
const app = express()
app.use(function (req, res, next) {
Number(req.query.delay) > 0
? setTimeout(next, req.query.delay)
: next()
})
場景一: 使用 async 非同步載入指令碼場景對 onload 的影響
範例程式碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 請求時長為1秒的js資源 -->
<script src="http://localhost:3010/index.js?delay=1000" async></script>
</head>
<body>
</body>
</html>
瀏覽器表現如下:
通過上圖可以看到,瀑布圖中深藍色豎線表示觸發了DOMContentLoaded
事件,而紅色豎線表示觸發了window.onload
事件(下文中無特殊情況,不會再進行特殊標識),由圖可以得知使用了 async 屬性進行指令碼的非同步載入,仍會影響頁面載入總體時長。
場景二:使用 defer 非同步載入指令碼場景對 onload 的影響
範例程式碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 請求時長為1秒的js資源 -->
<script src="http://localhost:3010/index.js?delay=1000" defer></script>
</head>
<body>
</body>
</html>
瀏覽器表現如下:
由圖可以得知使用了 defer 屬性進行指令碼的非同步載入,除了正常的在DOMContentLoaded
之後觸發指令碼執行,也影響頁面載入總體時長。
場景三:非同步指令碼中再次載入指令碼,也就是常見的動態載入指令碼、樣式資源的情況
html 程式碼保持不變,index.js
內範例程式碼:
const script = document.createElement('script')
// 請求時長為0.6秒的js資源
script.src = 'http://localhost:3010/index2.js?delay=600'
script.onload = () => {
console.log('js 2 非同步載入完畢')
}
document.body.appendChild(script)
結果如下:
從瀑布圖可以看出,資源的連續載入,導致了onload事件整體延後了,這也是我們再頁面中非常常見的一種操作,通常懶載入一些不重要或者首屏外的資源,其實這樣也會導致頁面整體指標的下降。
不過值得強調的一點是,這裡有個有意思的地方,如果我們把上述程式碼進行改造,刪除最後一行的document.body.appendChild(script)
,發現 index2 的資源請求並沒有發出,也就是說,指令碼元素不向頁面中插入,指令碼的請求是不會發出的,但是也會有反例,這個我們下面再說。
在本範例中,後來我又把指令碼請求換成了 css 請求,結果是一致的。
場景四:圖片的懶載入/預載入
html 保持不變,index.js 用於載入圖片,內容如下:
const img = document.createElement('img')
// 請求時長為0.5秒的圖片資源
img.src = 'http://localhost:3010/index.png?delay=500'
document.body.appendChild(img)
結果示意:
表現是與場景三一樣的,這個不再多說,但是有意思的來了,不一樣的是,經過測試發現,哪怕刪除最後一行程式碼:document.body.appendChild(img)
,不向頁面中插入元素,圖片也會發出請求,也同樣延長了頁面載入時長,所以部分同學就要注意了,這是一把雙刃劍:當你真的需要懶載入圖片時,可以少寫最後一行插入元素的程式碼了,但是如果大量的圖片載入請求發出,哪怕不向頁面插入圖片,也真的會拖慢頁面的時長。
趁著這個場景,再多說一句,一些埋點資料的上報,也正是藉著圖片有不需要插入dom即可傳送請求的特性,實現成功上傳的。
場景五:普通介面請求
html 保持不變,index.js 內容如下:
// 請求時長為500毫秒的請求介面
fetch('http://localhost:3010/api?delay=500')
結果如下圖:
可以發現普通介面請求的發出,並不會影響頁面載入,但是我們再把場景弄複雜一些,見場景六。
場景六:同時載入樣式、指令碼,指令碼載入完成後,內部http介面請求,等請求結果返回後,再發出圖片請求或修改dom,這也是更貼近生產環境的真實場景
html 程式碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 請求時長為1.2秒的css -->
<link rel="stylesheet" href="http://localhost:3010/index.css?delay=1200">
<!-- 請求時長為0.4秒的js -->
<script src="http://localhost:3010/index.js?delay=400" async></script>
</head>
<body>
</body>
</html>
index.js 程式碼:
async function getImage () {
// 請求時長為0.5秒的介面請求
await fetch('http://localhost:3010/api?delay=500')
const img = document.createElement('img')
// 請求時長為0.5秒的圖片資源
img.src = 'http://localhost:3010/index.png?delay=500'
document.body.appendChild(img)
}
getImage()
結果圖如下:
如圖所示,結合場景五記的結果,雖然普通的 api 請求並不會影響頁面載入時長,但是因為api請求過後,重新請求了圖片資源(或大量操作 dom),依然會導致頁面載入時間變長。這也是我們日常開發中最常見的場景,頁面載入了js,js發出網路請求,用於獲取頁面渲染資料,頁面渲染時載入圖片或進行dom操作。
場景七:頁面多媒體資源的載入
範例程式碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
</head>
<body>
<video src="http://localhost:3010/video.mp4?delay=500" controls></video>
</body>
</html>
結果如圖:
對於視訊這種多媒體資源的載入比較有意思,video 標籤對於資源的載入是預設開啟 preload 的,所以資源會預設進行網路請求(如需關閉,要把 preload 設定為 none ),可以看到紅色豎線基本處於圖中綠色條和藍色條中間(實際上更偏右一些),圖片綠色部分代表資源等待時長,藍色部分代表資源真正的載入時長,且藍色載入條在onload的豎線右側,這說明多媒體的資源確實影響了 onload 時長,但是又沒完全影響,因為設定了500ms的延遲返回資源,所以 onload 也被延遲了500ms左右,但一旦視訊真正開始下載,這段時長已經不記錄在 onload 的時長中了。
其實這種行為也算合理,畢竟多媒體資源通常很大,佔用的頻寬也多,如果一直延遲 onload,意味著很多依賴 onload 的事件都無法及時觸發。
接下來我們把這種情況再複雜一些,貼近實際的生產場景,通常video元素是包含封面圖 poster 屬性的,我們設定一張延遲1秒的封面圖,看看會發生什麼,結果如下:
不出意外,果然封面圖影響了整體的載入時長,魔鬼都在細節中,封面圖也需要注意優化壓縮。
場景八:非同步指令碼和樣式資源一同請求
範例程式碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 請求時長為1秒的css -->
<link rel="stylesheet" href="http://localhost:3010/index.css?delay=1000">
<!-- 請求時長為0.5秒的js -->
<script src="http://localhost:3010/index.js?delay=500" async></script>
</head>
<body>
</body>
</html>
瀏覽器表現如下:
可以看出 css 資源雖然沒有阻塞指令碼的載入,但是卻延遲了整體頁面載入時長,其中原因是css資源的載入會影響 render tree 的生成,導致頁面遲遲不能完成渲染。
如果嘗試把 async 換成 defer,或者乾脆使用同步的方式載入指令碼,結果也是一樣,因結果相同,本處不再舉例。
場景九:樣式資源先請求,再執行內聯指令碼邏輯,最後載入非同步指令碼
我們把場景八的程式碼做一個改造,在樣式標籤和非同步指令碼標籤之間,加上一個只包含空格的內聯指令碼,讓我們看看會發生什麼,程式碼如下:
<!DOCTYPE html>
<html lang="en">
<head>
<script>
console.log('頁面js 開始執行')
</script>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 請求時長為1秒的css -->
<link rel="stylesheet" href="http://localhost:3010/index.css?delay=2000">
<!-- 此標籤僅有一個空格 -->
<script> </script>
<!-- 請求時長為0.5秒的js -->
<script src="http://localhost:3010/index.js?delay=500" async></script>
</head>
<body>
</body>
</html>
index.js 中的內容如下:
console.log("指令碼 js 開始執行");
結果如下,這是一張 GIF,載入可能有點慢:
這個結果非常有意思,他到底發生了什麼呢?
指令碼請求是0.5秒的延遲,樣式請求是2秒
指令碼資源是 async 的請求,非同步發出,應該什麼時候載入完什麼時候執行
但是圖中的結果卻是等待樣式資源載入完畢後才執行
答案就在那個僅有一個空格的指令碼標籤中,經反覆測試,如果把標籤換成註釋,也會出現一樣的現象,如果是一個完全空的標籤,或者根本沒有這個指令碼標籤,那下方的index.js 通過 async 非同步載入,並不會違反直覺,載入完畢後直接執行了,所以這是為什麼呢?
這其實是因為樣式資源下方的 script 雖然僅有一個空格,但是被瀏覽器認為了它內部可能是包含邏輯,一定概率會存在樣式的修改、更新 dom 結構等操作,因為樣式資源沒有載入完(被延遲了2秒),導致同步 js (只有一個空格的指令碼)的執行被阻塞了,眾所周知頁面的渲染和執行是單執行緒的,既然前面已經有了一個未執行完成的 js,所以也導致了後面非同步載入的 js 需要在佇列中等待。這也就是為什麼 async 雖然非同步載入了,但是沒有在載入後立即執行的原因。
場景十:字型資源的載入
範例程式碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<style>
@font-face {
font-family: font-custom;
src: url('http://localhost:3010/font.ttf?delay=500');
}
body {
font-family: font-custom;
}
</style>
</head>
<body></body>
</html>
結果如下:
可以看到,此情況下字型的載入是對 onload 有影響的,然後我們又測試了一下只宣告字型、不使用的情況,也就是刪除上面程式碼中 body 設定的字型,發現這種情況下,字型是不會發出請求的,僅僅是造成了程式碼的冗餘。
前面列舉了大量的案例,接下來我們做個總結,實質性影響 onload 其實就是幾個方面。
圖片資源的影響毋庸置疑,無論是在頁面中直接載入,還是通過 js 懶載入,只要載入過程是在 onload 之前,都會導致頁面 onload 時長增加。
多媒體資源的等待時長會被記入 onload,但是實際載入過程不會。
字型資源的載入會影響 onload。
網路介面請求,不會影響 onload,但需要注意的是介面返回後,如果此時頁面還未 onload,又進行了圖片或者dom操作,是會導致 onload 延後的。
樣式不會影響指令碼的載入和解析,只會阻塞指令碼的執行。
非同步指令碼請求不會影響頁面解析,但是指令碼的執行同樣影響 onload。
圖片或其他資源的預載入可以通過 preload 或 prefetch 請求,這兩種方式都不會影響 onload 時長。
一定注意壓縮圖片,頁面中圖片的載入速度可能對整體時長有決定性影響。
儘量不要做序列請求,沒有依賴關係的情況下,推薦並行。
中文字型包非常大,可以使用字蛛壓縮、或用圖片代替。
靜態資源上 cdn 很重要,壓縮也很重要。
刪除你認為可有可無的程式碼,沒準哪一行程式碼就會影響載入速度,並且可能很難排查。
視訊資源如果在首屏以外,不要開啟預載入,合理使用視訊的 preload 屬性。
async 和 defer 記得用,很好用。
非必要的內容,可以在 onload 之後執行,是時候重新拾起來這個 api 了。