閆興亞 潘治穎 黃姝琦
(西安郵電大學計算機學院 西安 710061)
單頁應用是近幾年來前端技術發(fā)展與落地的最典型場景,Angular、Vue、React等前端框架出現的目的都是從架構層面為單頁應用提供研發(fā)解決方案,提高單頁應用的效率。在傳統(tǒng)單頁應用中,大部分的邏輯都在客戶端,服務端提供接口處理數據并提供空的HTML頁面,其中服務器端可以使用任意一種語言編寫,如 Ruby、Python、Java等[1]。一旦HTML中包含的JavaScript文件被下載,它們將被在客戶端執(zhí)行,從服務器獲取數據并直接渲染HTML頁面。因此用戶將會在加載完整個頁面之前看到幾秒鐘的空頁面或者一直加載控件,對此有很多研究表明用戶對訪問慢站點反應強烈[2]。Amazon claims聲稱“每提升100ms的頁面加載速度將會提升1%的收益”,因此Twitter 40個工程師花費的1年時間去重構,并且經測試,他們實現了服務端渲染整個HTML頁面的站點,其首屏頁面的呈現時間提高了5倍。
除此之外,由于SEO[3](Search Engine Optimization)是通過客戶端向服務器創(chuàng)建請求來解析響應結果的。因此在服務器返回空頁面的情況下,無法進行SEO。
由此可見,服務端渲染十分重要。為達到從服務器獲取整個HTML并使客戶端代碼運行快速且更具靈活性的目的,在2011年Nodejitsu便提出了Isomorphic JavaScript[4]的概念。之所以稱為 Isomorphic JavaScript,因為從某種意義上講,無論應用運行在客戶端還是服務器端,都具有相同的形式或形態(tài)。應用了Isomorphic JavaScript的Web應用,應用和視圖層邏輯都可以在前后端運行,應用的性能得以優(yōu)化并具有更好的維護性,同時可以被SEO?,F如今Angular、Vue、React等大量的框架已經應用了Isomorphic JavaScript的概念。
Facebook創(chuàng)始人之一的Dustin Moskovitz,在其應用Luna中嘗試使用Isomorphic JavaScript進行構建,這是Isomorphic JavaScript最著名的例子之一。Luna在沒有Node.js以前它是構建在v8cgi上,它允許為每一個單獨用戶會話復制一個完整的應用程序到服務器端運行。它為每個用戶創(chuàng)建獨立的進程,運行在客戶端上的也是服務器端的代碼,開啟對整個類的高級優(yōu)化,比如離線支持即時更新。
Mojito[5]是第一個開源的 Isomorphic JavaScript框架,它是完全用Node.js寫的框架。Cocktails平臺首席架構師Bruno Fernandez-Ruiz稱,通過使用Mojito,開發(fā)者編寫的代碼中的95%可以運行在客戶端和服務器端,只有5%的代碼需要根據客戶端做出調整。雅虎希望通過開源Mojito,來創(chuàng)建一個開發(fā)者社區(qū)并推廣該框架。但自從他們在2012年4月開源以來在JavaScript社區(qū)沒有廣泛的流行起來,主要是它依賴于YUI和雅虎這一缺點。
Meteor[6]可能是現今最好的同構項目。Meteor不需要創(chuàng)建低級別的基礎設施(如數據同步)或管道來精簡和編譯代碼,而是讓開發(fā)人員專注于業(yè)務功能。它借用幾個現有的工具和庫,將它們與新的思想以及新的庫、標準和服務結合起來,并將它們捆綁在一起,在同一個框架下捆綁并提供所有必需的組件。對于基于分布式應用平臺原則的應用,Meteor是很好的選擇,但在處理密集計算方面Meteor的性能還有待加強。其次,Meteor對應用的結構和代碼沒有什么約定,并且僅支持MongoDB數據庫,這些都是Meteor有待解決的問題。
除了使用Isomorphic JavaScript,在早些時候,同構庫Rendr的出現允許開發(fā)人員使用Backbone.js+Handlebars.js構建單頁面應用,在服務器端也能全部被渲染。Rendr是為了使Airbnb mobile web有更快的響應速度而創(chuàng)建的產品。對于用戶來說高效可用的響應速度是尤為重要的。
但是由于服務端渲染依賴于視圖層框架的支持,對于沒有使用視圖層框架的項目,Isomorphic JavaScript無法支持。因此使用服務端渲染,雖然項目得以優(yōu)化但喪失了產品的穩(wěn)定性,代價過大。所以,服務端Isomophic Javascript渲染的應用場景具有局限性[4]。而Rendr力求成為一個庫而不是一個框架,所以相比Mojito或Metetor來說,它解決的問題相對較少。
針對沒有使用視圖層框架項目的優(yōu)化問題,本文提出首屏呈現節(jié)點的處理方式的改造,根據單頁應用首屏數據并行式預加載方案,利用瀏覽器漸進式預加載與http的分塊傳輸編碼特性[7],實現應用資源加載、應用初始化、獲取首屏數據的并行處理,從而有效地減少首屏頁面的呈現時間。
對于使用包含大量JavaScript的架構的單頁應用來說,App Shell是一種常用方法。這種方法依賴漸進式緩存應用外殼讓應用運行,并為使用JavaS-cript的每個頁面加載動態(tài)內容。根據這個架構我們可以看出單頁應用首屏呈現節(jié)點可以分解為請求入口文件、渲染應用外殼、渲染首屏片段。
本文在此基礎上進一步將渲染應用外殼和渲染片段細分為請求入口文件、應用資源加載、應用初始化、獲取首屏數據、首屏初始化、組件渲染。
據此首屏呈現耗時的通用計算公式為
請求入口文件+應用資源加載+應用初始化+獲取首屏數據+首屏初始化+組件渲染
應用資源的加載與應用的初始化并不依賴于首屏數據,因此本文首屏數據并行式預加載的核心思路和優(yōu)化收益為
1)優(yōu)化獲取首屏數據的速度;
2)預先加載首屏數據,使得多個串行節(jié)點并行化。
利用分塊傳輸編碼可以將請求的報文逐塊傳輸的特性[7],將包含靜態(tài)資源的標簽進行分塊傳輸。瀏覽器在接收到靜態(tài)資源標簽后會開啟http請求線程,在繼續(xù)解析HTML文檔的同時發(fā)起對靜態(tài)資源的請求[8]。服務器在請求首屏數據完成后將首屏數據片段與應用初始化代碼分塊包含在<script>標簽中分塊傳遞給瀏覽器。由此巧妙地將應用資源加載節(jié)點和首屏數據請求節(jié)點并行化。當應用初始化完畢后,首屏組件直接讀取window.__APP_DATA__數據進行首屏初始化渲染與組件渲染[9]。
具體操作步驟如下:
1)請求首屏數據并在所有數據請求完成后將引用數據資源與應用初始化代碼單獨分離出來,即將它們包含在首屏數據的內聯腳本中,大致如下:
<script>
window.__APP_DATA__=
{/*相關的首屏數據*/};
</script>
<script>{/* 應用初始化代碼 */}</script>
2)將入口HTML文件中的靜態(tài)資源,即靜態(tài)資源包含在各個資源標簽中,如靜態(tài)的導航欄,加載指示器等,大致如下:
<link
rel=“stylesheet”
href=“/*靜態(tài)資源對應的地*/”
></link>
3)在服務器端,將入口HTML文件中的靜態(tài)資源標簽與腳本做并行處理,即以分塊傳輸編碼的方式將響應分塊發(fā)送給瀏覽器[10],在 Node.js[11]中,分塊傳輸編碼的實現方式如下:
res.writeHead(200,
{'Transfer-encoding':'chunked'});
res.write();
項目整體架構如圖1所示。
圖1 項目整體架構圖
首屏節(jié)點呈現耗時的通用計算公式變?yōu)?/p>
請求入口文件+Max(應用資源下載,請求首屏數據)+應用初始化+首屏初始化渲染+組件渲染。
此時,首屏各節(jié)點耗時如圖2所示。
圖2 一輪優(yōu)化后首屏各節(jié)點耗時圖
從上節(jié)分析中可知,應用初始化節(jié)點耗時很明顯,同時該節(jié)點要進行必須等待資源文件下載完畢,但理論上可以不依賴首屏數據,所以可以將應用初始化與首屏數據的獲取并行處理。
但是如果直接將應用初始化和首屏數據的獲取并行化,那么應用初始化會在應用資源文件下載完畢后進行,所以當獲取首屏數據時間大于應用資源加載時間與應用初始化時間時,應用會在沒有首屏數據的情況下進入首屏渲染節(jié)點,從而導致異常。
為了解決這個問題,本文將首屏數據片段的輸出變成promise片段。此時應用資源下載完畢后可以無視首屏數據的完成度,直接進入應用初始化節(jié)點,首屏渲染在數據promise被resolve后進行即可。通過對數據片段的promise化改造,使得應用初始化節(jié)點也加入了并行隊列。
首屏呈現耗時的通用計算公式變?yōu)?/p>
請求入口文件+Max(應用資源加載+應用初始化,請求首屏數據)+首屏初始化渲染 +組件渲染。
此時,首屏各節(jié)點耗時如圖3所示。
圖3 二輪優(yōu)化后首屏各節(jié)點耗時圖
電子合同系統(tǒng)是專門為銷售與商家設計的線上電子合同簽署的Web應用。與傳統(tǒng)的紙質合同相比,電子合同具有成本低、安全性高、便于監(jiān)管等優(yōu)點。該系統(tǒng)基于Node.js開發(fā)[12],采用單頁面應用的技術架構,實現了前后端分離,代碼的可維護性與可讀性較高。由于本項目歷史悠久不支持視圖層框架,所以無法做服務端渲染,用戶將會在加載完整個頁面之前看到幾秒鐘的空頁面或者一直加載控件,對此有很多研究表明用戶對訪問慢站點反應強烈。因此,我們采用首屏數據并行加載方案對其進行優(yōu)化。
1)以分塊傳輸編碼[7]的方式將響應報文分塊發(fā)送給瀏覽器,在Node.js中,分塊傳輸編碼的實現方式如下:
res.writeHead(200,
{'Transfer-encoding':'chunked'});
res.write(`<link
rel=“stylesheet”href=“/* 資源地址 */”>
</link>`);
2)將數據層簡單適配下Node端完成數據漸進式預加載。大概如下:
(1)將數據片段的輸出變成 promise[13]片段
(2)resolve promise片段,該片段在數據請求成功返回后輸出,大概如下:window。__APP_DATA__。resolves.userInfo(
null,data);
(3)reject promise[14]片段,該片段在數據請求失敗后輸出,大概如下:window。__APP_DATA__。resolves.userInfo(
error);
此時應用資源加載完畢后可以無視首屏數據的完成度,直接進入應用的初始化節(jié)點,首屏初始化渲染在數據promise被resolve后渲染即可:
window.__APP_DATA__.appData.then(data=>component.render());
通過對數據片段的promise化改造,使得應用初始化節(jié)點也加入了并行隊列。
在可用性層面上,整體的系統(tǒng)流暢性不錯,但在網速較慢的情況下,首頁和部分頁面打開極其慢,極大制約了該系統(tǒng)的使用并降低了用戶體驗水平,這也是絕大多數單頁面應用普遍存在的一個問題。
電子合同簽約系統(tǒng)優(yōu)化操作之前,整個首屏呈現timeline如下:
1)首屏呈現時間為185ms(請求入口文件)+500ms(應用資源加載)+950ms(應用初始化)+1050ms(獲取首屏數據)+350ms(首屏初始化渲染)+50ms(組件渲染)=3085ms。
2)實現資源文件下載與首屏數據請求節(jié)點并行后,最終并行化這塊耗時為Max(應用資源加載,獲取首屏數據)=1050ms。
根據變化后的節(jié)點我們算出首屏呈現時間為:2585ms。
3)應用用初始化,資源文件下載,首屏數據請求節(jié)點并行后,最終并行化這塊耗時為Max(應用資源加載+應用初始化,獲取首屏數據)=1450ms。
根據變化后的節(jié)點我們算出首屏呈現時間為2035ms。
經過上述2個步驟改進,應用首屏呈現時間從3085ms->2585ms->2035ms,總體效果約為34%。
在實際項目中耗時是在1935ms左右,比2035ms還要小,主要原因如下:
1)用戶在請求入口文件中半個RTT時間,服務器就開始了數據請求。
2)數據請求在服務端進行減少了瀏覽器與服務端的請求創(chuàng)建開銷,同時數據請求在內網進行,總體調用速度也會加快。
當首屏數據請求數超過瀏覽器并發(fā)請求數時,該方案收益會更明顯,因為Node端沒有并發(fā)限制,甚至在Node端與后端服務的交互中可以采用更高效的協(xié)議如HTTP2來提高調用速度。
我們在單頁應用的性能優(yōu)化上基于很樸素的并行化理念實施了首屏數據漸進式預加載方案,在實際項目中也得到了較為明顯的效果,減少了1050ms的加載時間,整體的節(jié)點變化如下。
優(yōu)化前首屏各節(jié)點耗時如圖4所示。
圖4 優(yōu)化前首屏各節(jié)點耗時圖
優(yōu)化后首屏各節(jié)點耗時如圖5所示。
圖5 優(yōu)化后首屏各節(jié)點耗時圖
最終數據漸進式預加載方案的首屏呈現時間計算公式為
請求入口文件+Max(應用資源加載+應用初始化,獲取首屏數據)+首屏初始化+組件渲染。
單頁應用作為在用戶體驗方面能夠與桌面程序媲美的Web應用,其應用場景越來越廣泛。一個單頁應用是否成功,很大程度上取決于其用戶體驗的好壞,提升用戶體驗的一個關鍵因素便是縮短首屏頁面呈現時間。
本文所提出的首屏數據并行式預加載方案能夠能有效減少首屏呈現時間,并且具有可操作性強、實現成本低的優(yōu)點。一方面,對客戶端代碼來說本方案基本可以做到透明化,在實際的開發(fā)過程中采用基于AOP攔截方案,通過配置化的方式讓客戶端的代碼改造僅局限在配置文件,應用代碼基本未改動。另一方面,分層合理的應用只需要將數據層簡單適配下Node端即可完成數據漸進式預加載,這對底層基礎框架在視圖層沒有支持同構的應用來說,整個改造成本可以說大大減小,且收益明顯。