餓了么的PWA升級實踐

姓名:郭金? 學號:17101223407

轉載自:http://mp.weixin.qq.com/s/nkFDL__jEY07pMCbP3xaqQ

【嵌牛導讀】: PWA作為下一代 Web 應用模型,其嘗試解決的是Web平臺本身的根本性問題:對網絡與瀏覽器UI的硬依賴。因此,任何Web應用都可以從中獲益,這與你是多頁還是單頁、面向桌面還是移動端、是用React還是Vue.js無關。或許,它還終將改變用戶對移動Web的期待。

【嵌牛鼻子】:PWA、多頁應用

【嵌牛提問】:現如今,誰還覺得桌面端的Web只是個看文檔的地方呢?

【嵌牛正文】:

? ? 自Vue.js在官方推特第一次公開到現在,我們就一直在進行著將餓了么移動端網站升級為 Progressive Web App的工作。直到近日在GoogleI/O 2017上登臺亮相,才終于算告一段落。我們非常榮幸能夠發布全世界第一個專門面向國內用戶的PWA,但更榮幸的是能與 Google、 UC以及騰訊合作,一起推動國內Web與瀏覽器生態的發展。

多頁應用、 Vue.js、 PWA?

? ? 對于構建一個希望達到原生應用級別體驗的PWA,目前社區里的主流做法都是采用SPA,即 單頁面應用模型(Single-page App)來組織整個Web應用,業內最有名的幾個PWA案例Twitter Lite、 Flipkart Lite、Housing Go 與 Polymer Shop無一例外。

? ? ? 然而餓了么,與很多國內的電商網站一樣,青睞多頁面應用模型(MPA, Multi-page App)所能帶來的一些好處,也因此在一年多前就將移動站從基于AngularJS的單頁應用重構為目前的多頁應用模型。團隊最看重的優點莫過于頁面與頁面之間的隔離與解耦,這使得我們可以將每個頁面當做一個獨立的“微服務”來看待,這些服務可以被獨立迭代,獨立提供給各種第三方的入口嵌入,甚至被不同的團隊獨立維護。而整個網站則只是各種服務的集合而非一個巨大的整體。

? ? ? 與此同時,我們仍然依賴 Vue.js作為JavaScript框架。 Vue.js除了是React、 AngularJS這種“重型武器”的競爭對手外,其輕量與高性能的優點使得它同樣可以作為傳統多頁應用開發中流行的“jQuery/Zepto/Kissy+模板引擎”技術棧的完美替代。 Vue.js提供的組件系統、聲明式與響應式編程更是提升了代碼組織、共享、數據流控制、渲染等各個環節的開發效率。 Vue 還是一個漸進式框架,如果網站的復雜度繼續提升,我們可以按需、增量地引入Vuex或Vue-Router這些模塊。萬一哪天又要改回單頁呢?(誰知道呢……)

? ? ? 2017年, PWA已經成為Web應用新的風潮。我們決定試試,以我們現有的“Vue.js+多頁”架構,能在升級PWA的道路上走多遠,達到怎樣的效果。

實現“PRPL”模式

? ? “PRPL”(讀作“purple”)是Google工程師提出的一種Web應用架構模式,它旨在利用現代Web平臺的新技術以大幅優化移動Web的性能與體驗,對如何組織與設計高性能的PWA系統提供了一種高層次的抽象。我們并不準備從頭重構我們的Web應用,不過我們可以把實現“PRPL”模式作為我們的遷移目標。“PRPL”實際上是“Push/Preload、 Render、 Precache、 Lazy-Load”的縮寫,我們接下來會展開介紹它們的具體含義。

? ? ? Push/Preload,推送/預加載初始URL路由所需的關鍵資源

? ? ? 無論是HTTP2 Server Push還是,其關鍵都在于,我們希望提前請求一些隱藏在應用依賴關系(Dependency Graph)較深處的資源,以節省HTTP往返、瀏覽器解析文檔,或腳本執行的時間。比如說,對于一個基于路由進行code splitting的SPA,如果我們可以在Webpack清單、路由等入口代碼(entry chunks)被下載與運行之前就把初始URL,即用戶訪問的入口URL路由所依賴的代碼用Server Push推送或進行提前加載。那么當這些資源被真正請求時,它們可能已經下載好并存在緩存中了,這樣就加快了初始路由所有依賴的就緒。

? ? ? ? 在多頁應用中,每一個路由本來就只會請求這個路由所需要的資源,并且通常依賴也都比較扁平。餓了么移動站的大部分腳本依賴都是普通的 <script> 元素,因此他們可以在文檔解析早期就被瀏覽器的preloader掃描出來并且開始請求,其效果其實與顯式的是一致的,見圖1所示。

圖片發自簡書App

圖1 有無 <link rel=“preload”> 的效果對比

? ? ? 我們還將所有關鍵的靜態資源都伺服在同一域名下(不再做域名散列),以更好地利用HTTP2帶來的多路復用(Multiplexing)。同時,我們也在進行著對API進行Server Push的實驗。

? ? ? Render,渲染初始路由,盡快讓應用可被交互

? ? ? 既然所有初始路由的依賴都已經就緒,我們就可以盡快開始初始路由的渲染,這有助于提升應用諸如首次渲染時間、可交互時間等指標。多頁應用并不使用基于JavaScript的路由,而是傳統的HTML跳轉機制,所以對于這一部分,多頁應用其實不用額外做什么。

? ? ? Precache,用Service Worker預緩存剩下的路由

? ? ? ? 這一部分就需要Service Worker的參與了。Service Worker是一個位于瀏覽器與網絡之間的客戶端代理,它已可攔截、處理、響應流經的HTTP請求,使得開發者得以從緩存中向Web應用提供資源而聞名。不過, Service Worker其實也可以主動發起 HTTP 請求,在“后臺”預請求與預緩存我們未來所需要的資源,見圖2所示。

圖片發自簡書App

圖2 Service Worker預緩存未來所需要的資源

? ? ? 我們已經使用Webpack在構建過程中進行.vue編譯、文件名哈希等工作,于是我們編寫了一個Webpack插件來幫助收集需要緩存的依賴到一個“預緩存清單”中,并使用這個清單在每次構建時生成新的Service Worker文件。在新的Service Worker被激活時,清單里的資源就會被請求與緩存,這其實與SW-Precache 這個庫的運行機制非常接近。

? ? ? 實際上,我們只對標記為“關鍵路由”的路由進行依賴收集。你可以將這些“關鍵路由”的依賴理解為我們整個應用的"App Shell" 或者說“安裝包”。一旦它們都被緩存,或者說成功安裝,無論用戶是在線離線,我們的Web應用都可以從緩存中直接啟動。對于那些并不那么重要的路由,我們則采取在運行時增量緩存的方式。我們使用的SW-Toolbox提供了LRU替換策略與TTL失效機制,可以保證我們的應用不會超過瀏覽器的緩存配額。

? ? ? Lazy-Load,按需懶加載、懶實例化剩下的路由

? ? ? 懶加載與懶實例化剩下的路由對于SPA是一件相對麻煩點兒的事情,你需要實現基于路由的code splitting與異步加載。幸運的是,這又是一件不需要多頁應用擔心的事情,多頁應用中的各個路由天生就是分離的。

? ? ? 值得說明的是,無論單頁還是多頁應用,如果在上一步中,我們已經將這些路由的資源都預先下載與緩存好了,那么懶加載就幾乎是瞬時完成的了,這時候我們就只需要付出實例化的代價。

? ? 至此,我們對PRPL的四部分含義做了詳細說明。有趣的是,我們發現多頁應用在實現PRPL這件事甚至比單頁還要容易一些。那么結果如何呢?

? ? ? 根據Google推出的Web性能分析工具Lighthouse(v1.6),在模擬的3G網絡下,用戶的初次訪問(無任何緩存)大約在2秒左右達到“可交互”,可以說非常不錯,見圖3所示。而對于再次訪問,由于所有資源都直接來自于Service Worker緩存,頁面可以在1秒左右就達到可交互的狀態了。

圖片發自簡書App

圖3 Lighthouse跑分結果

? ? ? 但是,故事并不是這么簡單得就結束了。在實際體驗中我們發現,應用在頁與頁的切換時,仍然存在著非常明顯的白屏空隙,見圖4所示。由于PWA是全屏運行,白屏對用戶體驗所帶來的負面影響甚至比以往在瀏覽器內更大。我們不是已經用Service Worker緩存了所有資源了嗎,怎么還會這樣呢?

圖片發自簡書App

圖4 從首頁點擊到發現頁,跳轉過程中的白屏

? ? ? 多頁應用的陷阱:重啟開銷

? ? ? 與SPA不同,在多頁應用中,路由的切換是原生的瀏覽器文檔跳轉(Navigating across documents),這意味著之前的頁面會被完全丟棄而瀏覽器需要為下一個路由的頁面重新執行所有的啟動步驟:重新下載資源、重新解析HTML、重新運行JavaScript、重新解碼圖片、重新布局頁面、重新繪制……即使其中的很多步驟本是可以在多個路由之間復用的。這些工作無疑將產生巨大的計算開銷,也因此需要付出相當多的時間成本。

? ? ? ? 圖5中為我們的入口頁(同時也是最重要的頁面)在兩倍CPU節流模擬下的Profile數據。即使我們可以將“可交互時間”控制在 1 秒左右,我們的用戶仍然會覺得這對于“僅僅切換個標簽”來說實在是太慢了。

圖片發自簡書App

圖5 入口頁在兩倍CPU節流模擬下的Profile數據

? ? ? 巨大的JavaScript重啟開銷

? ? ? 根據Profile,我們發現在首次渲染(First Paint)發生之前,大量的時間(900ms)都消耗在了JavaScript的運行上(Evaluate Script)。幾乎所有腳本都是阻塞的(Parser-blocking),不過因為所有的UI都是由JavaScript/Vue.js驅動的,倒也不會有性能影響。這900ms中,約一半是消耗在Vue.js運行時、組件、庫等依賴的運行上,而另一半則花在了業務組件實例化時Vue.js的啟動與渲染上。從軟件工程角度來說,我們需要這些抽象,所以這里并不是想責怪JavaScript或是Vue.js所帶來的開銷。

? ? ? 但是,在SPA中, JavaScript的啟動成本是均攤到整個生命周期的:每個腳本都只需要被解析與編譯一次,諸如生成Virtual DOM等較重的任務可以只執行一次,像Vue.js的ViewModel或是Virtual DOM這樣的大對象也可以被留在內存里復用。可惜在多頁應用里就不是這樣了,我們每次切換頁面都為JavaScript付出了巨大的重啟代價。

瀏覽器的緩存啊,能不能幫幫忙?

能,也不能。

? ? ? V8提供了代碼緩存(code caching),可以將編譯后的機器碼在本地拷貝一份,這樣我們就可以在下次請求同一個腳本時一次省略掉請求、解析、編譯的所有工作。而且,對于緩存在Service Worker配套的? ? ? ? ? Cache Storage中的腳本,會在第一次執行后就觸發V8的代碼緩存,這對于我們的多頁切換能提供不少幫助。

? ? ? 另外一個你或許聽過的瀏覽器緩存叫做“進退緩存”, Back-Forward Cache,簡稱bfcache。瀏覽器廠商對其的命名各異, Opera稱之為Fast History Navigation, Webkit稱其為Page Cache。但是思路都一樣,就是我們可以讓瀏覽器在跳轉時把前一頁留存在內存中,保留JavaScript與DOM的狀態,而不是全都銷毀掉。你可以隨便找個傳統的多頁網站在iOS Safari上試試,無論是通過瀏覽器的前 進后退按鈕、手勢,還是通過超鏈接(會有一些不同),基本都可以看到瞬間加載的效果。

? ? ? Bfcache其實非常適合多頁應用。但不幸的是,Chrome由于內存開銷與其多進程架構等原因目前并不支持。 Chrome現階段僅僅只是用了傳統的HTTP磁盤緩存,來稍稍簡化了一下加載過程而已。對于Chromium內核霸占的Android生態來說,我們沒法指望了。

為“感知體驗”奮斗

? ? ? 盡管多頁應用面臨著現實中的不少性能問題,我們并不想這么快就妥協。一方面,我們嘗試盡可能減少在頁面達到可交互時間前的代碼執行量,比如減少/推遲一些依賴腳本的執行,還有減少初次渲染的DOM節點數以節省Virtual DOM的初始化開銷。另一方面,我們也意識到應用在感知體驗上還有更多的優化空間。

Chrome產品經理Owen寫過一篇? ? ? ? Reactive Web Design: The secret to building web apps that feel amazing,談到兩種改進感知體驗的手段:一是使用骨架屏(Skeleton Screen)來實現瞬間加載;二是預先定義好元素的尺寸來保證加載的穩定。跟我們的做法可以說不謀而合。

? ? ? 為了消除白屏時間,我們同樣引入了尺寸穩定的骨架屏來幫助我們實現瞬間的加載與占位。即使是在硬件很弱的設備上,我們也可以在點擊切換標簽后立刻渲染出目標路由的骨架屏,以保證UI是穩定、連續、有響應的。我錄了兩個視頻放在Youtube上,不過如果你是國內讀者,你可以直接訪問餓了么移動網站來體驗實地的效果。最終效果如圖6所示。

圖片發自簡書App

圖6 在添加骨架屏后,從發現頁點回首頁的效果

? ? ? 這效果本該很輕松的就能實現,不過實際上我們還費了點功夫。

在構建時使用 Vue 預渲染骨架屏

? ? ? 你可能已經想到了,為了讓骨架屏可以被Service Worker緩存,瞬間加載并獨立于JavaScript渲染,我們需要把組成骨架屏的HTML標簽、 CSS樣式與圖片資源一并內聯至各個路由的靜態*.html文件中。

? ? ? 不過,我們并不準備手動編寫這些骨架屏。你想啊,如果每次真實組件有迭代(每一個路由對我們來說都是一個Vue.js組件),我們都需要手動去同步每一個變化到骨架屏的話,那實在是太繁瑣且難以維護了。好在,骨架屏不過是當數據還未加載進來前,頁面的一個空白版本而已。如果我們能將骨架屏實現為真實組件的一個特殊狀態——“空狀態”的話,從理論上就可以從真實組件中直接渲染出骨架屏來。

? ? ? 而Vue.js的多才多藝就在這時體現出來了,我們真的可以用Vue.js 的服務端渲染模塊來實現這個想法,不過不是用在真正的服務器上,而是在構建時用它把組件的空狀態預先渲染成字符串并注入到HTML模板中。你需要調整Vue.js組件代碼使得它可以在Node上執行,有些頁面對DOM/BOM的依賴一時無法輕易去除得,我們目前只好額外編寫一個*.shell.vue來暫時繞過這個問題。

關于瀏覽器的繪制(Painting)

? ? ? ? HTML文件中有標簽并不意味著這些標簽就能立刻被繪制到屏幕上,你必須保證頁面的關鍵渲染路徑是為此優化的。很多開發者相信將Script標簽放在body的底部就足以保證內容能在腳本執行之前被繪制,這對于能渲染不完整DOM樹的瀏覽器(比如桌面瀏覽器常見的流式渲染)來說可能是成立的。但移動端的瀏覽器很可能因為考慮到較慢的硬件、電量消耗等因素并不這么做。不僅如此,即使你曾被告知設為async或defer的腳本就不會阻塞HTML解析了,但這可不意味著瀏覽 器就一定會在執行它們之前進行渲染。

? ? ? 首先我想澄清的是,根據 HTML 規范 Scripting 章節, async腳本是在其請求完成后立刻運行的,因此它本來就可能阻塞到解析。只有defer(且非內聯)與最新的type=module被指定為“一定不會阻塞解析”(不過defer目前也有點小問題……我們稍后會再提到),見圖7所示。

圖片發自簡書App

圖7 具有不同屬性的Script腳本對HTML解析的阻塞情況

? ? ? 而更重要的是,一個不阻塞HTML解析的腳本仍然可能阻塞到繪制。我做了一個簡化的“最小多頁PWA”(Minimal Multi-page PWA,或MMPWA)來測試這個問題:我們在一個async(且確實不阻塞HTML解析)腳本中,生成并渲染1000個列表項,然后測試骨架屏能否在腳本執行之前渲染出來。圖8是通過USB Debugging在我的Nexus 5真機上錄制的Profile。

圖片發自簡書App

圖8 通過USB Debugging在Nexus 5真機上錄制的Profile

? ? ? 是的,出乎意料嗎?首次渲染確實被阻塞到腳本執行結束后才發生。究其原因,如果我們在瀏覽器還未完成上一次繪制工作之前就過快得進行了DOM操作,我們親愛的瀏覽器就只好拋棄所有它已經完成的像素,且一直要等待到DOM操作引起的所有工作結束之后才能重新進行下一次渲染。而這種情況更容易在擁有較慢CPU/GPU的移動設備上出現。

黑魔法:利用setTimeout()讓繪制提前

? ? ? 不難發現,骨架屏的繪制與腳本執行實際是一個競態。大概是Vue.js太快了,我們的骨架屏還是有非常大的概率繪制不出來。于是我們想著如何能讓腳本執行慢點,或者說,“懶”點。于是我們想到了一個經典的Hack: setTimeout(callback, 0)。我們試著把MMPWA中的DOM操作(渲染1000個列表)放進setTimeout(callback, 0)里……

? ? ? ? 當當!首次渲染瞬間就被提前了,見圖9所示。如果你熟悉瀏覽器的事件循環模型(Event Loop)的話,這招Hack其實是通過setTimeout的回調把DOM操作放到了事件循環的任務隊列中以避免它在當前循環執行,這樣瀏覽器就得以在主線程空閑時喘息一下(更新一下渲染)了。如果你想親手試試 MMPWA的話,你可以訪問github.com/Huxpro/mmpwa 或 huangxuan.me/mmpwa/ ,查看代碼與Demo。我把UI設計成了A/B Test的形式并改為渲染5000個列表項來讓效果 更夸張一些。

圖片發自簡書App

圖9 利用Hack技術,提前完成骨架屏的繪制

? ? ? ? 回到餓了么PWA上,我們同樣試著把new Vue()放到了setTimeout中。果然,黑魔法再次顯靈,骨架屏在每次跳轉后都能立刻被渲染。這時的Profile看起來是這樣的,見圖10所示。

圖片發自簡書App

圖10 為感知體驗進行各種優化后的最終Profile

? ? ? ? 現在,我們在400ms時觸發首次渲染(骨架屏),在600ms時完成真實UI的渲染并達到頁面的可交互。你可以詳細對比下圖9和圖10所示的優化前后Profile的區別。

被我“defer”的有關defer的Bug

? ? ? ? 不知道你發現沒有,在圖10的Profile中,我們仍然有不少腳本是阻塞了HTML解析的。好吧,讓我解釋一下,由于歷史原因,我們確實保留了一部分的阻塞腳本,比如侵入性很強的lib-flexible,我們沒法輕易去除它。不過, Profile里的大部分阻塞腳本實際上都設置了defer,我們本以為他們應該在HTML解析完成之后才被執行,結果被Profile打了一臉。

? ? ? 我和Jake Archibald 聊了一下,果然這是Chrome的Bug: defer的腳本被完全緩存時,并沒有遵守規范等待解析結束,反而阻塞了解析與渲染。Jake已經提交在crbug上了,一起給它投票吧。

? ? ? ? 最后,圖11是優化后的Lighthouse跑分結果,同樣可以看到明顯的性能提升。需要說明的是,能影響Lighthouse跑分的因素有很多,所以我建議你以控制變量(跑分用的設備、跑分時的網絡環境等)的方式來進行對照實驗。

圖片發自簡書App

圖11 優化后的Lighthouse跑分結果

? ? ? 最后為大家展示下應用的架構示意圖,見圖12所示。

圖片發自簡書App

圖12 應用架構示意圖

一些感想

? ? ? 多頁應用仍然有很長的路要走

? ? ? Web是一個極其多樣化的平臺。從靜態的博客,到電商網站,再到桌面級的生產力軟件,它們全都是Web這個大家庭的第一公民。而我們組織Web應用的方式,也同樣只會更多而不會更少:多頁、單頁、 Universal JavaScript應用、 WebGL,以及可以預見的Web Assembly。不同的技術之間沒有貴賤,但是適用場景的差距確是客觀存在的。

? ? ? Jake 曾在 Chrome Dev Summit 2016 上說過“PWA!== SPA”。可是盡管我們已經用上了一系列最新的技術(PRPL、 Service Worker、 App Shell……),我們仍然因為多頁應用模型本身的缺陷有著難以逾越的一些障礙。多頁應用在未來可能會? ? ? 有“bfcache API”、 Navigation Transition等新的規范以縮小跟SPA的距離,不過我們也必須承認,時至今日,多頁應用的局限性也是非常明顯的。

? ? ? 而PWA終將帶領Web應用進入新的時代

? ? ? 即使我們的多頁應用在升級PWA的路上不如單頁應用來得那么閃亮,但是PWA背后的想法與技術卻實實在在地幫助我們在Web平臺上提供了更好的用戶體驗。

? ?

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,045評論 25 708
  • 喝酵素的十大作用 ?維持血液的弱堿性; ?抗炎殺菌作用; ?血液凈化作用; ?增強免疫力; ?修復細胞作用; ?分...
    青島王建成閱讀 614評論 0 0
  • 時間管理是有效的運用時間,降低變動性。通過事先的規劃來提升自己的工作效率。在此簡單分享一些關于時間管理的觀點。 1...
    ppmoon閱讀 971評論 0 49
  • 對于一個要出校園舞臺的大學生來說,有什么比聽前輩的經驗更重要的呢,不管是經歷,還是教訓
    取個靜靜回家吧閱讀 108評論 1 0