去掉你代碼裏的 document.write("<script...

在傳統的瀏覽器中,同步的 script 標籤是會阻塞 HTML 解析器的,無論是內聯的還是外鏈的,比如:

<script src="a.js"></script><script src="b.js"></script><script src="c.js"></script><img src="a.jpg">

在這個例子中,HTML 解析器會先解析到第一個 script 標籤,然後暫停解析,轉而去下載 a.js,下載完後開始執行,執行完後,纔會繼續解析、下載、執行後面的兩個 script 標籤,最後解析那個 img 標籤,下載圖片,展現圖片。假設每個文件的下載時間都是 1 秒,且忽略瀏覽器的執行耗時,那麼你最終會在第 4 秒結束時看到 a.jpg 渲染在了瀏覽器上。

如今的瀏覽器已經不再這麼線性的執行了,在遇到第一個 script 標籤後,主線程中的解析器暫停解析,但瀏覽器會開啓一個新的線程去於預解析後面的 HTML 源碼,同時預加載遇到的CSS、JS、圖片等資源文件,也就是說,在現代瀏覽器中,上面這個例子中的四個資源文件是會被並行下載的,所以不考慮瀏覽器的執行耗時的話,渲染出最後那張圖片只需要 1 秒鐘。

額外小知識:

但瀏覽器能做的僅僅是預解析和預加載,腳本的執行和 DOM 樹的構建仍然必須是線性的,從而頁面的渲染也必須是線性的。腳本必須順序執行這很好理解,比如 b.js 很可能用到 a.js 裏的變量;DOM 樹不能提前構建的原因也能想到,a.js 裏很可能去查詢 DOM 樹,在那時執行 querySelectorAll("script").length 必須是 1,img 的話必須是 0。

但還有一個東西也能解釋上面兩個優化不能做的原因,甚至也能讓預解析和預加載這兩個已經做了的優化失效的東西,那就是 document.write(),document.write 可以在當前執行的 script 標籤之後插入任意的 HTML 源碼,如果你插入一個 "<div>foo</div>" 那還好,但如果插入一個未閉合的開標籤呢,比如:

<script>document.write("<textarea>") // 還可以是 document.write("<!--") 等</script><script src="a.js"></script><script src="b.js"></script><script src="c.js"></script><img src="a.jpg">

當第 1 個 script 標籤執行完畢後,瀏覽器就會發現,因爲 document.write 輸出了一個未閉合的開標籤,所以剛纔做的預解析成果得全部扔掉,重新解析一次,第二次解析後 script 標籤和 img 標籤都成了 textarea 的內容了,因此預加載的 JS 和圖片資源都白加載了。但這種情況畢竟是少數,預解析的利遠遠大於弊,所以瀏覽器們才做了這個優化,MDN 上有一篇文章 列舉了一些會讓瀏覽器做的預解析優化失失效的代碼

本文的主角是用 document.write 輸出一個 script 標籤的情況,比如:

<script src="a.js"></script><script>document.write('<script src="http://thirdparty.com/b.js"><\/script>')</script><script src="c.js"></script>

這個例子中,由於 b.js 是通過 JS 代碼插入的,HTML 預解析器是看不到的,所以只有當 a.js 下載並執行完畢,且第二個內聯的 script 執行完畢後,b.js 纔會開始下載,也就是說,b.js 不能和 a.js 及 c.js 並行下載了,從而導致頁面展現變慢,同樣假設每個文件的下載時間都是 1 秒,那麼這三個文件下載執行完就需要兩秒,就因爲 b.js 不能預加載。在一個外鏈的 JS 文件比如 a.js 中執行 document.write("<script...) 也是類似的效果。

Chrome 的工程師們最近發現,因這種包含於 document.write() 中的 script 標籤而導致的頁面加載變慢的情況非常普遍,同時還發現了個普遍的規律,那就是這些腳本的 URL 如果不是本站的(跨站的),一般都是些廣告和統計功能的第三方腳本,是對頁面正常展現非必須的,如果是本站的,則更可能是當前頁面展現所必須的腳本。

這些工程師們還在 Chrome for Android 中針對 2G 環境做了採樣統計,發現有 7.6% 的頁面包含了至少一個這樣的 script 標籤,而且發現假如禁止加載這些非必要的腳本後,頁面本身的展現速度會有顯著提升:

用 document.write 去加載腳本,絕大多數情況下都是錯誤的做法,是應該被優化的。那該怎麼優化呢?改成普通的 script 標籤放在 HTML 裏面嗎?不行也不該,先來說說爲什麼不行,一般來說,一個腳本之所以要放在 JS 裏去加載,而不是直接放在 HTML 裏,可能的原因有:

1. 腳本的 URL 是不能寫死的,比如要動態添加一些參數,用戶設備的分辨率啊,當前頁面 URL 啊,防止緩存的時間戳啊之類的,這些參數只能先用 JS 獲取到,再比如國內常見的 CNZZ 的統計代碼:

<script>
var cnzz_protocol = (("https:" == document.location.protocol) ? " https://" : " http://");
document.write(unescape("%3Cspan id='cnzz_stat_icon_30086426'%3E%3C/span%3E%3Cscript src='" + 
                        cnzz_protocol + 
                        "w.cnzz.com/c.php%3Fid%3D30086426' type='text/javascript'%3E%3C/script%3E"))
</script>

它之所以爲用戶提供 JS 代碼,而不是 HTML 代碼,是爲了先用 JS 判斷出該用 http 還是 https 協議。

2. 在外鏈的腳本里加載另外一個腳本,這種情況就沒法寫在頁面的 HTML 裏面了,比如百度聯盟的這個腳本里就可能用 document.write 去加載另外一個腳本:

http://cpro.baidustatic.com/cpro/ui/c.js

再來說說爲什麼不該,即便真的有少數的代碼可以優化成 HTML 代碼,比如上面這個 CNZZ 的就可以改成:

<span id='cnzz_stat_icon_30086426'></span><script src='//w.cnzz.com/c.php?id=30086426' type='text/javascript'></script>

這樣瀏覽器就可以預加載了,算是進行優化了,但這並不是最佳的優化,因爲,當你能明顯感覺到你的頁面因爲第三方腳本的原因導致展現緩慢,通常都不是因爲它沒有被預加載,而是因爲它的加載速度比你自己網站的腳本加載速度慢太多,再拿出這個例子:

<script src="a.js"></script><script>document.write('<script src="http://thirdparty.com/b.js"><\/script>')</script><script src="c.js"></script>

thirdparty.com 網站出問題的時候,a.js 和 c.js 1 秒就加載完了,而 b.js 也許需要 10 秒才能加載完,那 c.js 的執行以及後面的 HTML 的渲染就需要等 10 秒鐘,極端情況就是 b.js 一直卡在那裏直到超時,如果這些腳本是放在 head 裏的,那用戶永遠不會看到你的頁面,在國內的人應該早已深有體會,就是那些引用了 Google 統計、廣告等同步版腳本的頁面,這種情況下只靠預加載是解決不了根本問題的。

最佳的做法是把它改成異步執行的,異步的 script 根本不會阻塞 HTML 解析器,也就用不到預解析了。通過 HTML 載入的 script 可以用 async 屬性將它變成異步的:

<span id='cnzz_stat_icon_30086426'></span><script async src='//w.cnzz.com/c.php?id=30086426' type='text/javascript'></script>

當然,這個外鏈的腳本本身也可能需要做相應的調整,比如萬一裏面還有個 document.write,那整個頁面就會被覆蓋了。

上面也說到了,大部分第三方腳本都需要添加動態參數,沒法修改成 HTML 的代碼,所以更加常見的做法是用 document.createElement("script") 配合 appendChild/insertbefore 插入 script,以這種方式插入的 script 都是異步的,比如:

<span id='cnzz_stat_icon_30086426'></span><script>document.head.appendChild(document.createElement('script')).src = '//w.cnzz.com/c.php?id=30086426'</script>

目前國內國外絕大多數的廣告、統計服務提供商都有提供異步版本的代碼,但也有可能沒有,比如 CNZZ 的統計代碼, 看 這裏 和 這裏 。

本着用戶體驗至上的原則,Chrome 的工程師們準備進行一個大膽的嘗試,那就是屏蔽掉這種腳本,具體的屏蔽規則是,符合下面所有這些條件的 script 標籤對應的腳本不會再被 Chrome 執行:

1. 是用 document.write 寫入的

無法預解析和預加載

2. 同步加載的,也就是不帶有 asyc 或 defer 屬性的

即便寫在 document.write 裏,異步的 script 標籤也不會阻塞後面腳本的執行以及後面 HTML 源碼的解析

3. 外鏈的

內聯的反正沒有網絡請求,不影響展現速度,況且誰會去寫 <script>document.write("<script>alert('foo')<\/script>")</script> 這樣的代碼。。

4. 跨站的

上面說過了,跨站的腳本影響頁面本身的內容展現的可能性更小,跨站和跨域的區別,請看我的這篇文章

5. 所在頁面的此次加載不是通過刷新操作觸發的

雖然說第三方腳本影響頁面主體內容和功能的可能性不大,但仍然有這個可能,假如頁面主體內容收到影響了,用戶必然會點刷新,所以刷新的時候,這個屏蔽邏輯得關掉

6. 所在頁面是頂層的(self === top),而不是 iframe

因爲 iframe 往往是廣告之類的小區塊,而用戶想看的主頁面通常是這些 iframe 的父頁面,且 iframe 內的腳本並不會阻塞父頁面的渲染,所以沒必要優化它們

7. 未被緩存

如果這個外鏈腳本已經被緩存了,當然可以直接拿來執行了。

但這畢竟是個 breaking change,考慮用戶體驗的同時也不能不考慮網站本身,所以這個改動會循序漸進的一步一步(我總結成了 4 步)執行,給開發者留出修改自己代碼的時間,具體計劃是:

1. 警告

從 Chrome 53,也就是目前的穩定版開始,開發者工具的控制檯中會出現下面這樣的警告(即便腳本已經被緩存或者頁面是通過刷新操作打開的,也會出現這個警告):

qMJNVrf.png!web

2. 在 2G 網絡下開啓屏蔽( issue 640844 )

從 Chrome 54(2016 年 10 月中旬發佈)開始,在 2G 網絡環境下開啓屏蔽。需要指出的是,屏蔽一個腳本並不是真的不發起請求,而是會發一個異步的請求,且優先級很低(優先級爲 0,Chrome 給每個 http 請求都標有優先級)。這個異步請求的目的不是爲了去執行它(上面也說了,把一個同步腳本直接當成異步腳本去執行,是很可能會出問題的),而是爲了:

(1)爲了把腳本放到緩存裏,也就是說,第一次屏蔽了,第二次翻頁等操作後如果還需用到那個腳本,那它很可能已經在緩存裏了,這也是爲了減少 breaking 的概率。

(2)爲了通知這個腳本所在的服務器,“你的腳本被我屏蔽了”。腳本被屏蔽後異步發起的請求會被 Chrome 添加一個特殊的請求頭 Intervention,值是一個對應的 chromestatus 網址:

Intervention: <https://www.chromestatus.com/feature/5718547946799104>

如果你是一個第三方服務提供者,比如廣告投放系統的負責人,你在你的服務器的訪問日誌裏看到這個請求頭,就說明你的腳本已經被屏蔽了,從 Referer 頭裏也能看到被屏蔽的腳本是在哪個頁面裏被引用的,然後你需要做的是就是讓這個網站把你們提供的代碼更新成異步版本的。

因爲是 2G,所以肯定是移動版的 Chrome,也就是 Chrome for Android,Android WebView 不知道不會開啓,在 6 月份 Chrome 官方 發佈的消息 中說到還沒有定要不要在 WebView 中開啓:  

Will this feature be supported on all six Blink platforms (Windows, Mac, Linux, Chrome OS, Android, and Android WebView)?

This feature will be enabled on Win, Mac, Linux, ChromeOS and Android, but we are still deciding whether it's appropriate to apply this intervention for WebView. 

Chrome for IOS 內核不是 blink,不受影響。

爲了方便調試,在 Chrome PC 版開發者工具中將網絡切換成 2G 也能觸發這個屏蔽規則( 還在實現中 )。

我自己看到的一個到時候可能受到影響的手機網站: https://sina.cn/

3. 在網速較差的 3G 和 WiFi 環境下開啓屏蔽( issue 640846 )

目前還沒有決定從哪個版本開始,如果上一個 2G 階段進行順利,纔可能會進入這個階段,等有消息的時候我會在這裏追加具體開啓的版本號,PC 頁面在這個階段纔會受到影響。

我自己看到的兩個到時候可能受到影響的網站: https://www.baidu.com/ https://www.taobao.com/

4. 完全屏蔽

任何網絡環境都開啓屏蔽,這完全是我的猜測,還沒有看到 Chrome 的人在討論,但即便最後要這樣做了,肯定也需要較長的過度時間。

有些同學可能會問:“我把它放在頁面最底部,總該沒事了吧”。別忘了同步的 script 會阻塞 DOMContentLoaded/load 事件,關掉 *** 運行下面的 demo 試試:

<script>document.addEventListener("DOMContentLoaded", function(){
  alert("執行異步渲染、綁定事件等操作")
})
document.write("<script src=http://www.twitter.com><\/script>")</script>

用 jQuery 的話,所有 $(function(){}) 裏的回調函數都會被卡主,問題依然很嚴重。

最後總結一下:“爲什麼說 document.write("<script...) 不好” - “因爲它本來能夠寫成異步的,卻寫成了同步且不能預加載的”

PS:Chrome 還在做另外一個 優化的嘗試 ,就是開啓一個單獨的 V8 線程用來執行那些包含有 document.write("<script...) 字樣的內聯的 script 標籤中的代碼從而預加載那個腳本,但就像我上面說的(預加載不能解決阻塞問題),即便這個優化真做成了,意義也不大。  

PPS:HTML 規範也做了 對應的修改 ,說允許瀏覽器做這種優化。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章