關(guān)于網(wǎng)頁內(nèi)容加速黑科技的趣談
數(shù)周前,在倫敦 Heathrow 機場等飛機的空閑中,我順便處理了一些工作上的事情。不經(jīng)意間發(fā)現(xiàn) Github 在性能方面的一些問題,頗為詫異。通過新 tab 打開的頁面,其加載速度竟然比直接點擊鏈接打開的頁面要快。
點擊鏈接的同時復制鏈接并在新的 tab 頁中打開??梢钥吹剑M管先點擊的是鏈接,但渲染更快的卻是新 tab 中打開的頁面。
有一說一
頁面加載的時候,瀏覽器會接收網(wǎng)絡(luò)數(shù)據(jù)流,并將其輸出(pipe)給 HTML 解析器,HTML 解析器再將數(shù)據(jù)輸出到文檔。這意味著,頁面是邊加載邊渲染的。對于一個 100k 的頁面來說,瀏覽器很可能在接收到 20k 數(shù)據(jù)的時候就開始渲染出一些可用內(nèi)容了。
這個偉大又古老的特性,常常被開發(fā)者們有意無意地忽略了。多數(shù)提高加載性能的建議都歸結(jié)于一點,即“展示你所拿到的東西” —— 別怕,千萬不要傻傻等待一切加載完成之后再去展示內(nèi)容。
GitHub 當然是關(guān)注性能的,所以他們使用服務(wù)端渲染。但在同一個 tab 下瀏覽頁面時,他們用 JavaScript 重新實現(xiàn)了導航(navigation)功能,類似下面這樣:
- // …一堆重新實現(xiàn)瀏覽器導航功能代碼…
- const response = await fetch('page-data.inc');
- const html = await response.text();
- document.querySelector('.content').innerHTML = html;
- // …加載更多重新實現(xiàn)導航功能的代碼…
這違反了規(guī)則,因為在 page-data.inc 下載完成之前什么事情都沒干。而服務(wù)端渲染版完全不會這樣囤積內(nèi)容,其內(nèi)容是流式的,這樣就要快得多了。就 Github 的客戶端渲染來說,很多 JavaScript 代碼完全減慢了渲染過程。
這里我僅僅只是拿 Github 舉例子 —— 這種反模式在單頁應(yīng)用中比比皆是。
在頁面之內(nèi)切換內(nèi)容可能確實有些好處,特別是存在大量腳本的情況下,無需重新執(zhí)行全部腳本即可更新內(nèi)容。但我們能否在不放棄流的情況下完成這樣的工作呢?我曾經(jīng)常說 JavaScript 沒有辦法對流進行解析,但其實還是有的……
<iframe> 和 document.write 大法
iframe 早已躋身圈內(nèi)最臭黑科技之列。但下面這個辦法就使用了 iframe 和 document.write(),這樣我們就能將內(nèi)容以流的形式添加到頁面中了。示例如下:
- // 創(chuàng)建 iframe:
- const iframe = document.createElement('iframe');
- // 添加到 document 中 (記得隱藏起來):
- iframe.style.display = 'none';
- document.body.appendChild(iframe);
- // 等待 iframe 加載:
- iframe.onload = () => {
- // 忽略其他 onload 操作:
- iframe.onload = null;
- // 添加一個虛擬標簽:
- iframe.contentDocument.write('<streaming-element>');
- // 引用該元素:
- const streamingElement = iframe.contentDocument.querySelector('streaming-element');
- // 將該元素從 iframe 中取出,并添加到文檔中:
- document.body.appendChild(streamingElement);
- // 寫入一些內(nèi)容 —— 這里應(yīng)該是異步的:
- iframe.contentDocument.write('<p>Hello!</p>');
- // 繼續(xù)寫入內(nèi)容,直到完成:
- iframe.contentDocument.write('</streaming-element>');
- iframe.contentDocument.close();
- };
- // iframe 初始化
- iframe.src = '';
雖然 Hello! 是寫到 iframe 中的,但它卻出現(xiàn)在了父級的 document 中!這是因為解析器維護了一個 敞開元素棧(stack of open elements),新創(chuàng)建的元素會被壓入棧中。就算我們把 <streaming-element/> 元素移出到 iframe 外面也不影響,就是這么任性。
此外,這種技術(shù)處理起 HTML 來,要比 innerHTML 更接近標準的頁面加載解析器。尤其是腳本依然會被下載,并在父級文檔的上下文中執(zhí)行 —— 只是在 Firefox 中完全不會執(zhí)行,但我認為這是個 bug更新: 其實腳本根本不應(yīng)該執(zhí)行(感謝 Simon Pieters 指出這一點),但 Edge、Safari、Chrome 都這么干。
接下來我們只需要從服務(wù)端獲取 HTML 數(shù)據(jù)流,每當一個部分的數(shù)據(jù)到達的時候,就調(diào)用 iframe.contentDocument.write()。流式傳輸和 fetch() 搭配起來會更好,但為了支持 Safari,我們還是使用 XHR 來 hack 一下吧。
我已經(jīng)寫好了一個 demo,可以拿來和 Github 進行對比。下面是在 3G 網(wǎng)絡(luò)下的測試結(jié)果:
使用 iframe 進行流式渲染,頁面加載速度提高了 1.5 s。頭像也提前半秒鐘加載完成 —— 流式渲染意味著瀏覽器可以更早發(fā)現(xiàn)它們,并與內(nèi)容一起并行下載。
上面的方法對 Github 來說還是有效的,因為它的服務(wù)器返回的是 HTML。如果你使用的是框架,由框架自己管理 DOM 的展示,那可能就麻煩一些了。這種情況下可以看看下面這個次優(yōu)選項:
換行符分隔的 JSON
許多網(wǎng)站使用 JSON 驅(qū)動動態(tài)內(nèi)容。何其不幸,JSON 并不是一種對流友好的格式。盡管也有流式 JSON 解析器,可用起來卻并不那么簡單。
所以與其傳輸下面這樣一大塊 JSON 數(shù)據(jù):
- {
- "Comments": [
- {"author": "Alex", "body": "…"},
- {"author": "Jake", "body": "…"}
- ]
- }
還不如像下面這樣一行輸出一個 JSON 對象:
- {"author": "Alex", "body": "…"}
- {"author": "Jake", "body": "…"}
這種被稱為 “換行符分隔的 JSON” 是有標準的:ndjson。給上面的內(nèi)容寫一個解析器就要簡單多了。到了 2017 年,我們也許可以使用一系列組合變換流(composable transform streams)來描述(譯者注:本文寫作于 2016 年 12 月):
- // 在 2017 年的某個時候可能會是這樣:
- const response = await fetch('comments.ndjson');
- const comments = response.body
- // 從字節(jié)到文本:
- .pipeThrough(new TextDecoder())
- // 一直緩沖,直到遇到換行符:
- .pipeThrough(splitStream('\n'))
- // 將內(nèi)容塊解析為JSON:
- .pipeThrough(parseJSON());
- for await (const comment of comments) {
- // 處理每條評論,并將其添加到頁面:
- // (不管你使用的是什么模板或虛擬 DOM)
- addCommentToPage(comment);
- }
在上面的代碼中,splitStream 和 parseJSON 是 可復用變換流(reusable transform streams)。與此同時,為了實現(xiàn)***程度的兼容,我們可以使用 XHR 進行 hack。
我再次新建了一個對比的 demo,下面是 3G 網(wǎng)絡(luò)下的結(jié)果:
與常規(guī) JSON 相比,ND-JSON 提前 1.5s 將內(nèi)容渲染到頁面上,盡管速度不如 iframe 方法那么快。在創(chuàng)建元素之前,必須等待完整的 JSON 對象出現(xiàn)。如果你的 JSON 文件體量巨大,可能會陷入對流的企盼之中。
單頁應(yīng)用?別著急
如前所述,Github 使用了大量的代碼,然而卻帶來這樣的性能問題。在客戶端重新實現(xiàn)導航功能是困難的,如果你需要改變頁面中的大塊內(nèi)容,這么做有可能并不值得。
可以拿我們的嘗試與簡單瀏覽器導航進行對比:
打開一個簡單的沒有使用 JavaScript 瀏覽器導航的服務(wù)端渲染頁面的速度差不多是一樣的。但除去評論列表,測試頁面實在太過簡單。如果在不同頁面之間存在有大量重復的復雜內(nèi)容(主要是指可怕的廣告腳本),結(jié)果可能因?qū)嶋H情況而有差異,但一定要記得進行測試!很可能你編寫了一大堆代碼,然而只能帶來少的可憐的提升,甚至還可能減慢速度。