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