Web 端如何低成本打造 Native 體驗?
Web 應(yīng)用在實際體驗上和 Native 應(yīng)用仍然存在非常明顯的差距,那么如何低成本地把一個現(xiàn)有的網(wǎng)站改造成類 Native 的體驗呢?本文分享一種讓網(wǎng)站低成本漸進式實現(xiàn) Native 化體驗的方式——同屏渲染。
Web 端體驗
在有了 PWA(Progressive web apps) 之后,Web Application 也具備了添加到桌面和離線訪問等能力,但是實際體驗上卻總是和 Native 應(yīng)用存在非常明顯的差距。
我們可以看一下 Alibaba 的 M 站和 iOS 應(yīng)用的錄屏(左邊為 WEB,右邊為 iOS APP):
?? 
我們可以看到,對于 Web Applicaiton 來說,在頁面中來回跳轉(zhuǎn)時訪問的總是割裂的,從上一個頁面到下一個頁面需要等 loading ,返回時很多內(nèi)存狀態(tài)又都不在了,導致無法正確定位回之前的列表位置(這一點其實和不同的瀏覽器以及列表本身的實現(xiàn)方式有關(guān),也有一些方案可以規(guī)避這個問題,在這里只是其中一個 case)。
這樣對于用戶的體驗傷害非常明顯,他能明確感覺到自己在用的并非一個 Application 而是一個 Website,而且在進行復雜的操作時整個鏈路也非常容易被中斷。
而其實這種體驗差異的根源,在于 B/S(Browser/Server)和 C/S(Client/Server)的差異。ServiceWorker 雖然提供了一些方案(例如 App Shell)讓我們較低成本的增強原有的體驗,但仍然難以解決頁面之間的割裂問題,很多相同的代碼在不同頁面間重復執(zhí)行,每一次訪問內(nèi)存狀態(tài)就會丟失。
渲染性能
當我們在說體驗的時候會顯得有點主觀,性能相比之下就容易衡量的多,而頁面割裂帶來的最為直觀的體驗差距其實就來自于渲染性能的差異。
在 Web 端一個典型的 CSR(Client Side Rendering)要經(jīng)過的流程大致如下:
?? 
這其中有很多不符合我們預期的地方:
- HTML/JS 都等到點擊后才開始加載(這點可以通過預加載的手段解決,其中 HTML 的預加載可以通過 ServiceWorker 進行)。
 - framework 等 JS 在不同頁面間總是重復執(zhí)行的。
 - 加載 API 的時機非常晚,而加載 API 一般是耗時很長而且可以并行的部分,理論上加載時機越早越好。
 
所以理想中的渲染流程應(yīng)該是下圖這樣:
?? 
其實對于 Native 應(yīng)用也是如此,用戶點擊時基本就會開始加載 API 并且執(zhí)行下個頁面的邏輯。其實一個優(yōu)化的比較好(做了 preload 等)的 SPA 也是類似的效果,我們提前加載好下個頁面的 vendor ,點擊時直接只執(zhí)行下個頁面的邏輯即可。
然而實際上對于一個較大的現(xiàn)存站點來說(例如 m.alibaba.com ),把整個網(wǎng)站作為一個 SPA 來維護是不太現(xiàn)實的,一方面不能適應(yīng)當前多人協(xié)作的現(xiàn)狀,另外一方面穩(wěn)定性上也不能接受修改一個頁面整個網(wǎng)站都要發(fā)布的方案。
那么,如何低成本的把一個現(xiàn)有的網(wǎng)站改造成類 Native 的體驗呢?
同屏渲染
在有了上面的思考后,我們就在想,有沒有一個方案在不做改造的前提下,在用戶點擊后,立即開始數(shù)據(jù)的并行加載,同時把下個頁面動態(tài)的加載進來,選擇性的保留上個頁面的一些內(nèi)容(例如正在加載中的數(shù)據(jù), jsonp , framework 層的對象等)而隔絕其他部分的干擾。
于是針對我們的場景產(chǎn)出了一個同屏渲染的方案:LightHub,所謂同屏渲染,即渲染過程中頁面不需要被卸載,所有的渲染行為都在一個上下文中發(fā)生。
?? 
這里我們需要幾個東西:
- 能直接附著到現(xiàn)有頁面上的沙箱,用于把頁面還原到初始狀態(tài)(同時允許保留部分共享的部分)
 - 過渡動畫
 - API 并行加載
 - 按照瀏覽器行為渲染 HTML
 - 按照瀏覽器行為觸發(fā)事件
 
?? 
沙箱
我們需要一個低成本把頁面還原會初始狀態(tài)、并且允許保留部分對象的沙箱機制,而且最好這個機制是可以直接低成本部署到現(xiàn)有頁面上的。其實這里的訴求和微前端碰到的問題類似,我們受 qiankun 的沙箱機制啟發(fā),只需要在頁面的 中插入一小段內(nèi)聯(lián) JS 記錄:
- window 上的全局變量
 - window/document 的 eventListener
 - 定時器:setInterval/setTimeout/requestAnimationFrame/requestIdleCallback
 - MutationObserver
 
在我們需要時我們只需要清空頁面的 DOM,還原變化的全局變量(這里和 qiankun 一樣采用的淺拷貝),eventListener,定時器和 MutationObserver,就能把頁面還原到初始狀態(tài)。
同時,記錄的狀態(tài)也能封存到一個對象中,當用戶從下個頁面 back 到上個頁面時,我們可以直接把狀態(tài)還原到頁面上。
這里就需要在清空頁面狀態(tài)時選擇性的保留一些需要保留的對象:例如公共的 Framework,JSONP 請求的標簽等。
過渡動畫
這一點其實就沒有多復雜了,在頁面不需要被卸載和重新加載后,我們可以在用戶點擊后立即展示一個動畫。目前采用的只是一個簡單的從右側(cè) slide-in 的動畫。
需要注意的是,由于在繪制動畫的過程中我們往往正在執(zhí)行下個頁面的邏輯,我們需要注意使用 GPU 來繪制動畫,從而確保動畫不會被 JS 執(zhí)行阻塞。這一點對于低端機尤為關(guān)鍵。
API 并行加載
其實在有了上面的沙箱機制后,API 的并行加載就不是難事了,需要注意的是我們需要保護 API 并行加載本身的過程中產(chǎn)生的狀態(tài)(例如 setTimeout ),我們需要實現(xiàn)一個 runInSharedContext 確保這其中的定時器不會在頁面切換時被卸載。
而在下個頁面消費的只需要 window.sharedfetchDataPromise || fetch(url) 就能直接復用并行加載的 API 請求。
在我們的場景下為了讓這個問題更加開發(fā)者無感,封裝了一個叫做 redfox 的工具庫,在同一個頁面環(huán)境執(zhí)行多次相同配置的請求會自動復用,不需要開發(fā)者手動判斷。
按照瀏覽器行為渲染 HTML
這可能是其中最復雜的部分了,在我們抓到下個頁面的 HTML 后,不能只是簡單的document.innerHTML = nextHTML ,這樣會導致和普通的瀏覽器行為完全不一致,樣式加載會導致閃屏,腳本的執(zhí)行順序不符合預期等等。
?? 
所以我們需要自己實現(xiàn)一個 renderHTML ,將抓到的 HTML 解析后模擬瀏覽器的行為進行渲染。
- 播放動畫
 - 先通過樣式隱藏 body
 - 異步將 CSS append 到頁面,等到 head 中的 CSS 加載完成并且動畫播放完成后取消 body 的隱藏
 - 把 JS 按照類型和順序 append 到頁面
 
- inline & 正常的阻塞后續(xù) DOM 和 js 的 append
 - defer 丟到 defer 隊列中
 - async 異步執(zhí)行,不阻塞后續(xù)
 
- 按次序執(zhí)行 defer 隊列
 
這個部分的行為比較復雜,需要在較多的場景進行測試,以及有相應(yīng)的單元測試保障邏輯的正確性。
?? 
按瀏覽器行為觸發(fā)事件
其實和上面渲染 HTML 相似,在渲染的過程中需要按照瀏覽器的行為觸發(fā)相應(yīng)的事件。
例如上個頁面卸載時依次觸發(fā) beforeunload => pagehide => unload ,在下個頁面加載時先把 readyState 重置,然后按照次序觸發(fā) domInteractive defer 的執(zhí)行和 DOMContentLoaded。
同樣的,單元測試在這個環(huán)節(jié)是必須的。
分析
Timeline 分析
從 Chrome 最后的 Timeline 看執(zhí)行邏輯基本是符合我們預期的,點擊后的瞬間 API 開始加載并且基本上就開始全力執(zhí)行下個頁面的渲染邏輯。
Framework 層的代碼基本也不需要再重復執(zhí)行。
?? 
內(nèi)存壓力
對于這種不卸載頁面的方案來說最容易引起擔憂的可能就是內(nèi)存泄露問題,其實按照上面的沙箱機制,只要我們確保 DOM、全局變量、定時器、時間監(jiān)聽等能夠被正確清除,與之相關(guān)的閉包等就不會賴在內(nèi)存中不走。
從我們本地多次頻繁點擊切換頁面的反應(yīng)看,內(nèi)存隨著頁面的切換返回也會一次次回到初始狀態(tài),從理論上不存在直接導致內(nèi)存泄露的缺陷。
?? 
然而,由于我們允許在頁面間保留一部分的公共區(qū)域(上面稱之為 Service Layer),另外沙箱本身是一個約定沙箱而非安全沙箱(例如往 Element.prototype.xxx 屬性寫東西就無法被攔截),對于一些不規(guī)范的寫法仍然存在內(nèi)存泄露的風險。
這一點可能需要和 Native 端類似的內(nèi)存壓力監(jiān)控等方式來長期觀察。
分階段打點
由于整個 HTML 渲染過程都是我們自己實現(xiàn)的,所以整個渲染的各個階段可以自己打點記錄一些時間,下面就是一個例子:API 從 JS 請求到拿到耗時 124ms ,而實際上整個取數(shù)據(jù)(提前并行取的)花了 350ms 。每一個 script 開始執(zhí)行和執(zhí)行耗時也可以通過這種方式打上來。
?? 
這也可以為我們的頁面優(yōu)化提供一些指導,例如 JS 的執(zhí)行時間是不是過晚,某段 JS 的執(zhí)行時間是不是過長。
效果
最終的對比效果如下,左為同屏渲染,右為正常跳轉(zhuǎn),從線上的數(shù)據(jù)看性能提升大約從 2.8s => 1.8s 。
?? 
除了異步渲染的頁面外,我們針對一些原先是 SSR 的頁面也做了非常低成本的接入(不需要改造頁面,但是享受到的受益相對也更有限)。
?? 
但僅僅是上面這種跳轉(zhuǎn)體驗和返回體驗的改善,就讓我們的 Just For U 模塊的曝光屏數(shù)有穩(wěn)定 3% 的增長。
總結(jié)
總結(jié)一下:
- 類似 SPA 體驗的客戶端渲染可以讓 Web 的體驗更接近 Native。
 - 同屏渲染是一種讓網(wǎng)站低成本漸進式實現(xiàn) Native 化體驗的方式。
 - 更加沉浸的體驗確實會讓用戶有意愿進行更多地瀏覽。
 
局限
上面的方案仍然存在一些局限性,例如前面提到的需要開發(fā)者防范內(nèi)存泄露的問題,同時因為 History API 的限制,頁面必須是同域的,否則跳轉(zhuǎn)的 URL 無法滿足預期。
未來
關(guān)注 Chrome 動態(tài)的同學也會了解到 Chrome 最近也退出了一個新的提案:Portal API,就是旨在解決我們上面提到的 Web 體驗割裂的問題。
能夠提供一個類似 iframe 的沙箱,以較低的成本實現(xiàn)頁面間的跳轉(zhuǎn)過渡等。在未來 Protal 普及后(至少 Chrome 發(fā)布, Safari 跟進后),我們就可以在新版本的瀏覽器中拋棄現(xiàn)在使用 JS 實現(xiàn)的沙箱機制,使用更加安全(且炫酷)的 Portal API 來實現(xiàn)同屏渲染。
在 Protal API 的支持下,我們也可以克服無法跨域的問題,按照目前的草案,Portal 是支持跨域跳轉(zhuǎn)的。















 
 
 






 
 
 
 