去掉你程式碼裡的 document.write("<script...

紫雲飛發表於2016-09-18

在傳統的瀏覽器中,同步的 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 規範也做了對應的修改,說允許瀏覽器做這種優化。

相關文章