【W(wǎng)eb技術(shù)】1005- 關(guān)于 JS 與 CSS 是否阻塞 DOM 的渲染和解析

最近系統(tǒng)梳理HTML5所有涉及到的標簽時,梳理至<link>和<script>標簽時,碰巧想到一個困擾很久的問題,即一般把<script>放在<body>尾部,<link>標簽放在<head>內(nèi)部,而頁面通過CDN引入第三方框架或庫時,基本都是將其<script>標簽放在<link>標簽前面。
可能此方式已經(jīng)成為了約定俗成,但是究竟其好處在哪里,或者說其它的方式為什么不可取,想必你也和我有同樣的疑問,那就接著來往下看吧。
準備工作
首先需要做的準備工作是,搭建一個服務(wù)器,目的是為了返回css樣式和js腳本,并且讓服務(wù)器根據(jù)傳遞的參數(shù),固定延時返回數(shù)據(jù)。
其目錄結(jié)構(gòu)如下,其中index.js和style.css就是用于返回的數(shù)據(jù),app.js為服務(wù)器啟動文件,index.html是用來測試案例的文件,剩余文件或文件夾可以忽略。
├── static
│ ├── index.js
│ ├── style.css
├── app.js
├── index.html
├── package.json
├── node_modules/
復(fù)制代碼
涉及的相關(guān)代碼也貼一下吧,方便復(fù)制調(diào)試。有必要說明一下,本地運行node app.js啟動后,瀏覽器輸入http://127.0.0.1:3000/就能訪問到index.html,而訪問style.css可以輸入http://127.0.0.1:3000/static/style.css?sleep=3000,其中sleep參數(shù)則可自由控制css文件延時返回,例如想要文件5s后返回就設(shè)置sleep=5000。
// app.js
const express = require('express')
const fs = require('fs')
const app = new express()
const port = 3000
const sleepFun = time => {
return new Promise(res => {
setTimeout(() => {
res()
}, time)
})
}
const filter = (req, res, next) => {
const { sleep } = req.query || 0
if (sleep) {
sleepFun(sleep).then(() => next())
} else {
next()
}
}
app.use(filter)
app.use('/static/', express.static('./static/'))
app.get('/', function (req, res, next) {
fs.readFile('./index.html', 'UTF-8', (err, data) => {
if (err) return
res.send(data)
})
})
app.listen(port, () => {
console.log(`app is running at http://127.0.0.1:${port}/`)
})
// static/index.js
var p = document.querySelector('p');
console.log(p);
// static/index.css
p { color: lightblue; }
復(fù)制代碼
接著就是index.html的準備工作,其中HTML部分的架子就長下面那樣,然后你只需要記住DOMContentLoaded事件將在頁面DOM解析完成后觸發(fā)。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<script>
document.addEventListener('DOMContentLoaded', () => {
var p = document.querySelector('p')
console.log(p)
})
</script>
</head>
<body>
<p>hello world</p>
</body>
</html>
復(fù)制代碼
CSS 不會阻塞 DOM 解析,但是會阻塞 DOM 渲染
首先在index.html插入如下<link>標簽,然后在瀏覽器輸入http://127.0.0.1:3000/訪問此頁面。
<head>
<script>
document.addEventListener('DOMContentLoaded', () => {
var p = document.querySelector('p')
console.log(p)
})
</script>
<link rel="stylesheet" href="./static/style.css?sleep=3000">
</head>
<body>
<p>hello world</p>
</body>
復(fù)制代碼
頁面初始顯示為空白,控制臺打印出了p元素,同時瀏覽器標簽頁上加載loading,3s后頁面顯示出淺藍色的hello world。

以上情況也就說明,CSS不會阻塞DOM的解析,如果說CSS阻塞DOM解析的話,那么p標簽不會被解析,進而DOM不會被解析完成,CSS請求過程中也不可能會觸發(fā)DOMContentLoaded事件。而且在css請求過程中,控制臺立即打印出了p元素,由此也驗證了此結(jié)論的正確性。
另一個情況就是,雖然DOM很早就被解析完成,但是p標簽卻遲遲沒有渲染,原因在于CSS樣式還未請求完成,在樣式獲取后hello world才被渲染出來,所以說CSS會阻塞頁面渲染。
簡單闡述一下瀏覽器的解析渲染過程,解析DOM生成DOM Tree,解析CSS生成CSSOM Tree,兩者結(jié)合生成render tree渲染樹,最后瀏覽器根據(jù)渲染樹渲染至頁面。由此可以看出DOM Tree的解析和CSSOM Tree的解析是互不影響的,兩者是并行的。因此CSS不會阻塞頁面DOM的解析,但是由于render tree的生成是依賴DOM Tree和CSSOM Tree的,因此CSS必然會阻塞DOM的渲染。
更為嚴謹一點的說,CSS會阻塞render tree的生成,進而會阻塞DOM的渲染。
JS 會阻塞 DOM 解析
為了避免加載CSS造成的干擾,如下僅關(guān)注JS的執(zhí)行情況,其中for循環(huán)的循環(huán)體中邏輯暫不考慮,僅僅是讓JS執(zhí)行更多時間。
<head>
<script>
document.addEventListener('DOMContentLoaded', () => {
var p = document.querySelector('p')
console.log(p)
})
</script>
</head>
<body>
<script>
const p = document.querySelector('p')
console.log(p)
for (var i = 0, arr = []; i < 100000000; i++) {
arr.push(i)
}
</script>
<p>hello world</p>
</body>
復(fù)制代碼
瀏覽器訪問頁面,初始時為空白且控制臺打印null,瀏覽器loading短暫延時后,控制臺打印出p標簽同時頁面渲染出hello world。

以上情況很容易說明JS會阻塞DOM解析了,JS執(zhí)行初控制臺打印null,因為此時p標簽還未被解析,for循環(huán)執(zhí)行時,可以明顯感覺到執(zhí)行耗時,執(zhí)行完成p標簽被解析,此時觸發(fā)DOMContentLoaded事件,控制臺打印出p標簽,同時頁面渲染出hello world。
比較合理的解釋就是,首先瀏覽器無法知曉JS的具體內(nèi)容,倘若先解析DOM,萬一JS內(nèi)部全部刪除掉DOM,那么瀏覽器就白忙活了,所以就干脆暫停解析DOM,等到JS執(zhí)行完成再繼續(xù)解析。
CSS 會阻塞 JS 的執(zhí)行
如下在頁內(nèi)JS腳本前插入<link>標簽,并且延時3s獲取CSS樣式。
<head>
<script>
document.addEventListener('DOMContentLoaded', () => {
var p = document.querySelector('p')
console.log(p)
})
</script>
<link rel="stylesheet" href="./static/style.css?sleep=3000">
<script src="./static/index.js"></script>
</head>
<body>
<p>hello world</p>
</body>
復(fù)制代碼
初始頁面空白,瀏覽器loading加載3s后,控制臺打印出null,緊接著打印出p標簽,同時頁面渲染出淺藍色p標簽。

此情況好像是CSS不僅阻塞了DOM的解析,而且也阻塞了DOM渲染。
但是首先要思考下是什么阻塞了DOM的解析,剛剛已經(jīng)證明了CSS不會阻塞DOM的解析,所以只可能是JS阻塞了DOM解析。但是JS只有兩行代碼,不會阻塞長達3s左右的時間。所以只有一個可能就是CSS會阻塞JS的執(zhí)行。
因此輸出結(jié)果也能大致分析出來了,首先解析到第一個<script>標簽,document綁定上DOMContentLoaded事件,緊接著解析到link標簽,瀏覽器請求CSS樣式,由于CSS不會阻塞DOM解析,因此瀏覽器繼續(xù)向下解析,發(fā)現(xiàn)第二個<script>標簽,瀏覽器請求JS腳本,此時JS獲取完成,但是由于CSS還在獲取,所以不能立即執(zhí)行。
而第二個<script>不能立即執(zhí)行,導(dǎo)致它后面的p標簽也沒辦法解析,原因則是JS會阻塞DOM解析。只有等待到CSS樣式獲取成功后,此時JS立即執(zhí)行,控制臺輸出null,然后瀏覽器繼續(xù)解析到p標簽,解析完成,DOMContentLoaded事件觸發(fā),控制臺輸出p標簽,最后淺藍色hello world渲染至頁面。
其實這樣做也是有道理的,設(shè)想JS腳本中的內(nèi)容是獲取DOM元素的CSS樣式屬性,如果JS想要獲取到DOM最新的正確的樣式,勢必需要所有的CSS加載完成,否則獲取的樣式可能是錯誤或者不是最新的。因此要等到JS腳本前面的CSS加載完成,JS才能再執(zhí)行,并且不管JS腳本中是否獲取DOM元素的樣式,瀏覽器都要這樣做。
回溯文章開頭的那個疑問,所以一般將<script>放在<link>標簽前面是有道理的。
JS 會觸發(fā)頁面渲染
如下CSS采用頁內(nèi)方式,其中顏色名及其rgb值分別為淺綠色lightblue(rgb(144, 238, 144))、粉色pink(rgb(255, 192, 203))。
// index.html
<head>
<style>
p {
color: lightgreen;
}
</style>
</head>
<body>
<p>hello</p>
<script src="./static/index.js?sleep=2000"></script>
<p>beautiful</p>
<style>
p {
color: pink;
}
</style>
<script src="./static/index.js?sleep=4000"></script>
<p>world</p>
<style>
p {
color: lightblue;
}
</style>
</body>
// static/index.js
var p = document.querySelector('p');
var style = window.getComputedStyle(p, null);
console.log(style.color);
復(fù)制代碼
頁面初始渲染出淺綠色hello,緊接著2s后渲染出粉色hello beautiful且控制臺打印rgb(144, 238, 144),然后又2s后渲染出淺藍色hello beautiful world且控制臺打印rgb(255, 192, 203)。

上述結(jié)果大致分析為瀏覽器首先解析第一個<style>標簽和hello文本的p標簽,此時繼續(xù)向下解析發(fā)現(xiàn)了第一個<script>標簽,緊接著觸發(fā)一次渲染,由于此過程非常快所以頁面初始就能看到淺綠色hello。
然后瀏覽器發(fā)出JS請求,2s后JS獲取完成立即運行控制臺輸出rgb(144, 238, 144),JS運行完成后瀏覽器繼續(xù)向下解析到beautiful文本的p標簽和第二個<style>標簽,再繼續(xù)向下解析發(fā)現(xiàn)了第二個<script>標簽,觸發(fā)一次渲染,這個過程也是非常快,所以可以看到控制臺輸出結(jié)果和渲染粉色hello beautiful幾乎是同時的。
解析到第二個<script>標簽時,瀏覽器不會發(fā)出請求(稍作解釋),2s后獲取到JS腳本并執(zhí)行,控制臺輸出rgb(255, 192, 203),緊接著瀏覽器繼續(xù)向下解析到world文本的p標簽和第三個<style>標簽,此時DOM解析完成,再進行正常的渲染,這個過程也是非??欤砸材芸吹娇刂婆_輸出結(jié)果和渲染淺藍色hello beautiful world幾乎是同時的。
現(xiàn)在來解答剛才那個問題,瀏覽器解析DOM時,雖然會一行一行向下解析,但是它會預(yù)先加載具有引用標記的外部資源(例如帶有src標記的<script>標簽),而在解析到此標簽時,則無需再去加載,直接運行,以此提高運行效率。所以就會有上述兩個輸出結(jié)果間隔2s的情況,而不是4s,因為瀏覽器預(yù)先就一起加載了兩個<script>腳本,第一個<script>腳本加載完成時,第二個<script>腳本還剩大概2s加載完成。
而這個結(jié)論才是解釋為何CSS會阻塞JS的執(zhí)行的真正原因,瀏覽器無法預(yù)先知道腳本的具體內(nèi)容,因此在碰到<script>標簽時,只好先渲染一次頁面,確保<script>腳本內(nèi)能獲取到DOM的最新的樣式。倘若在決定渲染頁面時,還有尚未加載完成的CSS樣式,只能等待其加載完成再去渲染頁面。
Body 內(nèi)的 CSS
來看一個較為特殊的情況。
<head>
<script>
document.addEventListener('DOMContentLoaded', () => {
var p = document.querySelector('p')
console.log(p)
})
</script>
</head>
<body>
<p>hello</p>
<link rel="stylesheet" href="./static/style.css?sleep=3000">
<p>world</p>
</body>
復(fù)制代碼
按照上述的所有結(jié)論,預(yù)先分析一下運行結(jié)果,首先瀏覽器解析<script>腳本,document上綁定了DOMContentLoaded事件,緊接著瀏覽器繼續(xù)向下解析,發(fā)現(xiàn)了文本為hello的p標簽和<link>標簽,瀏覽器發(fā)起CSS請求,由于CSS不會阻塞DOM解析,瀏覽器繼續(xù)向下解析至文本為world的p標簽,此時頁面解析完成,DOMContentLoaded事件觸發(fā)控制臺輸出p標簽,3s后頁面渲染出淺藍色hello world。
因此按照分析,初始時頁面空白,瀏覽器loading加載3s后,控制臺打印出p標簽,同時頁面渲染出淺藍色hello world。
但是實際結(jié)果并不是這樣,而是頁面初始就渲染出hello,3s后頁面渲染出淺藍色hello world并且打印p標簽。

如下是我個人的分析和理解,首先是瀏覽器解析并運行<script>標簽,然后在解析文本為hello的p標簽,當解析到<link>標簽時,觸發(fā)一次渲染,然后瀏覽器發(fā)起CSS請求,但是此時瀏覽器不會繼續(xù)向下解析,而是將<link>標簽當做是DOM的一部分,換句話說瀏覽器將其認為是特殊的DOM元素,這個DOM元素的特殊性就在于需要進行加載,因此瀏覽器不會繼續(xù)向下解析,所以也就沒有DOMContentLoaded的輸出結(jié)果。
3s后<link>這個特殊的DOM元素解析完成,瀏覽器繼續(xù)向下解析world文本的p標簽,此時觸發(fā)DOMContentLoaded事件,再進行正常的渲染,頁面渲染出淺藍色hello world,由于此過程非???,所以控制臺輸出和渲染淺藍色hello world幾乎是同時的。
上述僅僅是我個人的分析和猜測,可以不必理會,僅作為討論,所以也不敢妄下結(jié)論,誤人子弟,此小節(jié)僅走馬觀花即可。
綜上所述
綜合上述所有情況,可以得出如下結(jié)論。
CSS不會阻塞DOM解析,但是會阻塞DOM渲染,嚴謹一點則是CSS會阻塞render tree的生成,進而會阻塞DOM的渲染JS會阻塞DOM解析CSS會阻塞JS的執(zhí)行瀏覽器遇到 <script>標簽且沒有defer或async屬性時會觸發(fā)頁面渲染Body內(nèi)部的外鏈CSS較為特殊,請慎用

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點擊“閱讀原文”查看 120+ 篇原創(chuàng)文章
