去掉你代碼里的 document.write("


在傳統的瀏覽器中,同步的 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 去加載另外一個腳本:

再來說說為什么不該,即便真的有少數的代碼可以優化成 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,也就是目前的穩定版開始,開發者工具的控制台中會出現下面這樣的警告(即便腳本已經被緩存或者頁面是通過刷新操作打開的,也會出現這個警告):

2016.10.6 追加,從 Chrome 55 開始,除了上面的警告,這個被警告的腳本的 HTTP 請求會被添加一個額外的請求頭,方便該腳本的維護者提前知道自己的腳本在未來會被屏蔽:

Intervention:<https://www.chromestatus.com/feature/5718547946799104>; level="warning" 

比如下面是百度首頁一個被警告腳本的 HTTP 請求頭截圖:

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

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

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

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

如果你是一個第三方服務提供者,比如廣告投放系統的負責人,你在你的服務器的訪問日志里看到這個請求頭,就說明你的腳本已經被屏蔽了,從 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,不受影響。

2016.10.20 追加,推遲到了 Chrome 55。

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

2016.10.6 追加,上面的這個 issue 已經 fixed 了,但我發現開發者工具模擬成 2G 並不能觸發真實的屏蔽,可能人家只是為了方便自己寫測試代碼,開發者工具並沒有支持,我在 issue 下面問了,目前還沒回。不過我發現另外一個開啟真實屏蔽的方法,就是打開 chrome://flags/#disallow-doc-written-script-loads,開啟這個選項后,所有網絡環境下符合那 7 個條件腳本都會被真實的屏蔽掉,比如百度首頁這個腳本:

這兩個請求的 URL 是一模一樣的,上面那個是原來的請求,被屏蔽了,會報 ERR_CACHE_MISS 的錯誤,下面那個是異步發起的請求。

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

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

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

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

4. 完全屏蔽

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

有些同學可能會問:“我把它放在頁面最底部,總該沒事了吧”。別忘了同步的 script 會阻塞 DOMContentLoaded/load 事件,關掉 vpn 運行下面的 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 規范也做了對應的修改,說允許瀏覽器做這種優化。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM