文章出處

  自 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 吧。

原文來自:Upgrading Ele.me to Progressive Web App

編譯來源:夢想天空 ◆ 關注前端開發技術 ◆ 分享網頁設計資源


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()