1. 程式人生 > >經典文摘:餓了麼的 PWA 升級實踐(結合Vue.js)

經典文摘:餓了麼的 PWA 升級實踐(結合Vue.js)

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

您可能感興趣的相關文章

 多頁應用、Vue、PWA?

對於構建一個希望達到原生應用級別體驗的 PWA,目前社群裡的主流做法都是採用 SPA,即單頁面應用模型(Single-page App)來組織整個 web 應用,業內最有名的幾個 PWA 案例 

Twitter Lite、 Flipkart LiteHousing Go 與 Polymer Shop 無一例外。

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

與此同時,我們仍然依賴 Vue.js

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

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

實現 “PRPL” 模式

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

1. PUSH/PRELOAD,推送/預載入初始 URL 路由所需的關鍵資源。

無論是 HTTP2 Server Push 還是 <link rel="preload">,其關鍵都在於,我們希望提前請求一些隱藏在應用依賴關係(Dependency Graph)較深處的資源,以節省 HTTP 往返、瀏覽器解析文件、或指令碼執行的時間。比如說,對於一個基於路由進行 code splitting 的 SPA,如果我們可以在 webpack 清單、路由等入口程式碼(entry chunks)被下載與執行之前就把初始 URL,即使用者訪問的入口 URL 路由所依賴的程式碼用 Server Push 推送或 <link rel="preload"> 進行提前載入。那麼當這些資源被真正請求時,它們可能已經下載好並存在在快取中了,這樣就加快了初始路由所有依賴的就緒。

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

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

2. RENDER,渲染初始路由,儘快讓應用可被互動

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

3. PRE-CACHE,用 Service Worker 預快取剩下的路由

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

我們已經使用 Webpack 在構建過程中進行 .vue 編譯、檔名雜湊等工作,於是我們編寫了一個 webpack 外掛來幫助我們收集需要快取的依賴到一個“預快取清單”中,並使用這個清單在每次構建時生成新的 Service Worker 檔案。在新的 Service Worker 被啟用時,清單裡的資源就會被請求與快取,這其實與 SW-Precache 這個庫的執行機制非常接近。

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

4. LAZY-LOAD 按需懶載入、懶例項化剩下的路由

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

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

這四句話即是 PRPL 的全部了。有趣的是,我們發現多頁應用在實現 PRPL 這件事甚至比單頁還要容易一些。那麼結果如何呢?

根據 Google 推出的 Web 效能分析工具 Lighthouse(v1.6),在模擬的 3G 網路下,使用者的初次訪問(無任何快取)大約在 2 秒左右達到“可互動”,可以說非常不錯。而對於再次訪問,由於所有資源都直接來自於 Service Worker 快取,頁面可以在 1 秒左右就達到可互動的狀態了。

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

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

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

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

圖中為我們的入口頁(同時也是最重的頁面)在 2 倍 CPU 節流模擬下的 profile 資料。即使我們可以將“可互動時間”控制在 1 秒左右,我們的使用者仍然會覺得這對於“僅僅切換個標籤”來說實在是太慢了。

巨大的 JavaScript 重啟開銷

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

但是,在 SPA 中,JavaScript 的啟動成本是均攤到整個生命週期的: 每個指令碼都只需要被解析與編譯一次,諸如生成 Virtual DOM 等較重的任務可以只執行一次,像 Vue 的 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 上,不過如果你是國內讀者,你可以直接訪問餓了麼移動網站來體驗實地的效果 ;) 最終效果如下圖所示。

在新增骨架屏後,從發現頁點回首頁的效果

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

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

你可能已經想到了,為了讓骨架屏可以被 Service Worker 快取,瞬間載入並獨立於 JavaScript 渲染,我們需要把組成骨架屏的 HTML 標籤、CSS 樣式與圖片資源一併內聯至各個路由的靜態 *.html 檔案中。

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

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

關於瀏覽器的繪製(Painting)

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

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

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

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

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

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

噹噹!首次渲染瞬間就被提前了。如果你熟悉瀏覽器的事件迴圈模型(event loop)的話,這招 Hack 其實是通過 setTimeout 的回撥把 DOM 操作放到了事件迴圈的任務佇列中以避免它在當前迴圈執行,這樣瀏覽器就得以在主執行緒空閒時喘息一下(更新一下渲染)了。如果你想親手試試 MMPWA 的話,你可以訪問 github.com/Huxpro/mmpwa 或 huangxuan.me/mmpwa/訪問程式碼與 Demo。我把 UI 設計為了 A/B Test 的形式並改為渲染 5000 個列表項來讓效果更誇張一些。

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

現在,我們在 400ms 時觸發首次渲染(骨架屏),在 600ms 時完成真實 UI 的渲染並達到頁面的可互動。你可以拉上去詳細對比下優化前後 profile 的區別。

被我 “defer” 的有關 defer 的 Bug

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

我和 Jake Archibald 聊了一下,果然這是 Chrome 的 Bug:defer 的指令碼被完全快取時,並沒有遵守規範等待解析結束,反而阻塞瞭解析與渲染。Jake 已經提交在 crbug 上了,一起給它投票吧~

最後,是優化後的 Lighthouse 跑分結果,同樣可以看到明顯的效能提升。需要說明的是,能影響 Lighthouse 跑分的因素有很多,所以我建議你以控制變數(跑分用的裝置、跑分時的網路環境等)的方式來進行對照實驗。

最後附上一張圖,這張圖當時是做給 Addy Osmani 的 I/O 演講用的,描述了餓了麼 PWA 是如何結合 Vue 來實現多頁應用的 PRPL 模式,可以作為一個架構的參考與示意圖。

一些感想

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

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 平臺上提供了更好的使用者體驗。

PWA 作為下一代 Web 應用模型,其嘗試解決的是 web 平臺本身的根本性問題:對網路與瀏覽器 UI 的硬依賴。因此,任何 web 應用都可以從中獲益,這與你是多頁還是單頁、面向桌面還是移動端、是用 React 還是 Vue 無關。或許,它還終將改變使用者對移動 web 的期待。現如今,誰還覺得桌面端的 web 只是個看文件的地方呢?

還是那句老話:讓我們的使用者,也像我們這般熱愛 web 吧。