使用 React.js 的漸進式 Web 應用程式(2)

markzhai發表於2016-11-21

系列 的第二部分會完整地走一遍怎麼使用 Lighthouse. 來優化移動 web apps。這篇文章,我們來看看頁面載入效能。

保證頁面載入效能是快的

移動 Web 的速度很關鍵。平均地,更快的體驗會帶來 70% 更長的會話 以及兩倍以上更多的移動廣告收益。Web 效能的投資像是基於 React 的 Flipkart Lite 獲得了三倍網站瀏覽時間, GQ 在流量上得到了 80% 增長,Trainline 在 年收益上增長了 11M 並且 Instagram 增長了 33% 的印象.

在你的 web app 載入時有一些 關鍵的使用者時刻

使用 React.js 的漸進式 Web 應用程式(2)

測量然後優化總是關鍵的。Lighthouse 的頁面載入檢測會關注:

順帶一提,Paul Irish 做了很了不起的相關總結 PWAs 的有趣指標值得一看。

良好效能的目標:

  • 遵循 RAIL 效能模型 的 L 部分。 A+ 的效能是我們所有人都必須力求達到的,即便有的瀏覽器不支援 Service Worker。我們仍然可以快速地在螢幕上獲得一些有意義的內容,並且僅載入我們所需要的
  • 在典型網路(3G)和硬體條件下
  • 首次訪問在 5 秒內可互動,重複訪問(Service Worker 可用)則在 2 秒內。
  • 首次載入(網路限制下),速度指數在 3000 或者更少。
  • 第二次載入(磁碟限制,因為 Service Worker 可用):速度指數 1000 或者更少。

讓我們再說說,關於通過 TTI 關注互動性。

關注抵達可互動時間(TTI)

為互動性優化,也就是使得 app 儘快能對使用者可用(比如讓他們可以四處點選,app 可以相應)。這對試圖在移動裝置上提供一流使用者體驗的現代 web 體驗很關鍵。

使用 React.js 的漸進式 Web 應用程式(2)

Lighthouse 現在將 TTI 測量為佈局穩定,web 字型可見,並且主執行緒可以響應使用者輸入的時間。有很多方法來手動跟蹤 TTI,重要的是根據指標進行優化會提升你使用者的體驗。

對於像 React 這樣的庫,你應該關心的是在移動裝置上 啟用庫的代價 因為這會讓人們有感知。在 ReactHN,我們達到了 1700毫秒 內可互動,通過保持整個 app 的大小和執行代價相對小,撇開有多個檢視:app bundle gzipped 壓縮後 11KB,107KB 用於我們的 vendor/React/庫 bundle,實踐中有點像這樣:

使用 React.js 的漸進式 Web 應用程式(2)

之後,對於功能顆粒狀的 apps,我們會看看效能模式像是 PRPL,通過在 HTTP/2 伺服器 Push 下利用顆粒狀的 “基於路由的分塊” 來得到快速的可互動時間。(可以試試 Shop demo 來看看我們說的是什麼)。

Housing.com 最近使用了類 PRPL 模式搭載 React 體驗,獲得了很多讚揚:

使用 React.js 的漸進式 Web 應用程式(2)

Housing.com 利用 Webpack 路由分塊,來推遲入口頁面的部分啟動消耗(僅載入 route 渲染所需要的)。更多細節請檢視 Sam Saccone 的優秀 Housing.com 效能檢測.

Flipkart 也做了類似的:

注意:關於什麼是 “到可互動時間”,有很多不同的看法,Lighthouse 對 TTI 的定義也可能會演變。其他跟蹤的方法有導航後第一個 5 秒內 window 沒有長任務的時刻,或者一次文字/內容繪製後第一次 5 秒內 window 沒有長任務的時刻。基本上,就是頁面穩定後多快,使用者可以和 app 互動的。

注意:儘管不是強制的要求,你可能也需要提高視覺完整度(速度指數),通過 優化關鍵渲染路徑關鍵路徑 CSS 優化工具的存在 以及其優化在 HTTP/2 的世界中依然有效。

基於路由分塊以提高效能

Webpack

如果你第一次接觸模組打包工具像是 Webpack,看看 JS 模組化打包器(視訊) 可能會有幫助。

一些今天的 JavaScript 工具使得將你的所有指令碼打包成一個 bundle.js 檔案幷包含所有頁面變得簡單。這意味著很多時候,你可能要載入很多對當前路由來說並不需要的程式碼。為什麼一次路由需要載入 500KB 的 JS,而事實上 50KB 就夠了呢?我們應該丟開那些無助於獲得更快體驗的指令碼,來加速獲得可互動的路由。

使用 React.js 的漸進式 Web 應用程式(2)

當僅提供使用者一次 route 所需要的最小功能可達程式碼就可以的時候,避擴音供龐大整塊的 bundles(像上圖)。

程式碼分割是解決整塊的 bundles 的一個方法。想法大致是在你的程式碼中定義分割點,然後分割成不同的檔案進行按需懶載入。這會提升啟動時間,幫助我們更快地可互動。

使用 React.js 的漸進式 Web 應用程式(2)

想象使用一個公寓列表 app。如果我們登陸的路由是列出我們所在區域的地產(route-1)—— 我們不需要檢視完整的地產詳情的程式碼(route-2)或者預約一次看房(route-3),所以我們可以僅提供使用者列表路由所需要的 JavaScript,然後動態載入剩下的。

這些年來,程式碼分割的想法已經被很多 apps 使用,但現在用 “基於路由的分塊” 來稱呼它。通過 Webpack 模組打包器,我們可以啟用 React 上的安裝。

實踐基於路由的程式碼分塊

Webpack 支援當它發現一個 require.ensure() 被使用的時候將你的 app 程式碼分割成塊(或者在 Webpack 2,一個 System.import)。這些被稱為 “分割點”,Webpack 會對它們的每一個都生成一個分開的 bundlea,按需解決依賴。

當你的程式碼需要某些東西,Webpack 會發起一個 JSONP 請求來從伺服器獲得它。這個和 React Router 結合工作得很好,我們可以在對使用者渲染檢視之前在依賴(塊)中懶載入一個新的路由。

Webpack 2 支援 使用 React Router 的自動程式碼分割 因為它可以處理模組上的 System.import 呼叫為 import 語句,將匯入的檔案和它們的依賴一起打包。依賴不會與你在 Webpack 設定中的初始入口衝突。

附錄:預載入那些路由!

在我們繼續之前,除了剛才的方法,另一個可選的是 來自 Resource Hints。這個提供給我們一個方法來宣告式地獲取資源而不執行它們。預載入可以用來預載入使用者可能要去的路由所需要的 Webpack 塊,於是快取已經為他們準備好了,可以在需要的時候立即可用。

使用 React.js 的漸進式 Web 應用程式(2)

在寫的時候,預載入只能在 Chrome 中進行,但是在支援的瀏覽器中被處理為漸進式增加。

注意:html-webpack-plugin 的 模板和自定義事件 可以使用最小的改變來讓簡化這個過程。然後你應該保證預載入的資源真正會對你大部分的使用者瀏覽過程有用。

非同步載入路由

讓我們回到程式碼分割(code-splitting)—— 在一個使用 React 和 React Router 的 app 裡,我們可以使用 require.ensure() 以在 ensure 被呼叫的時候非同步載入一個元件。順帶一提,如果任何人在探索伺服器渲染,這個在 Node 裡需要被 node-ensure 包來填充。Pete Hunt 在 Webpack How-to 涵蓋了非同步載入。

在下面的例子裡,require.ensure() 使我們可以按需懶載入路由,在元件被使用前等待拉取:

注意:我經常通過 CommonChunksPlugin(minChunks:
Infinity)來進行上面的安裝,所以在我不同的入口點之間有一個帶有通用模組的 chunk。這也 極力降低了 陷入預設 Webpack runtime 的可能。

Brian Holt 在 React 的完整介紹 對非同步路由載入涵蓋得很好。通過非同步路由的程式碼分割在 React Router 的目前版本和 新的 React Router V4 都可以使用。

通過非同步的 getComponent + require.ensure() 的簡單宣告式路由分塊

這有有一個小貼士,可以使程式碼分割的安裝更快。在 React Router,一個 宣告式的路由 來將路由 “/” 對映到元件 App 看上去像 .

React Router 也支援一個方便的 [getComponent](https://github.com/ReactTraining/react-router/blob/master/docs/API.md#getcomponentnextstate-callback) 屬性,類似於 component 但卻是非同步的,對快速安裝上程式碼分割超級有用

getComponent 函式引數包括下一個狀態(我設定為 null)和一個回撥。

讓我們新增一些基於路由的程式碼分割到 ReactHN。我們會從 routes 檔案中的一段開始 —— 它定義了元件的 require 呼叫和對每個路由的 React Router 路由(比如 news, item, poll, job, comment 永久連結等):

 

ReactHN 現在提供給使用者一個整塊包含所有路由的 JS bundle。讓我們將它轉換為路由分塊,只提供一次路由真正需要的程式碼,從 comment 的永久連結開始(comment/:id):

所以我們首先刪了對永久連結元件的隱式 require:

然後開始我們的路由..

然後使用宣告式的 getComponent 來更新它。我們在路由中使用 require.ensure() 呼叫來懶載入,而這就是我們所需要做的一切了:

Orz 太美了。這..就搞定了。不騙你。我們可以把這個用到剩下的路由上,然後執行 webpack。它會正確地找到 require.ensure() 呼叫,然後如我們想要地區分隔程式碼。

使用 React.js 的漸進式 Web 應用程式(2)

在應用宣告式的程式碼分割到更多我們的路由後,可以看到路由分塊在運作,僅僅會載入一次路由所需要的程式碼(在 Service Worker 可以預快取起來):

使用 React.js 的漸進式 Web 應用程式(2)

提醒:有許多可用於 Service Worker 的簡單 Webpack 外掛:

CommonsChunkPlugin

使用 React.js 的漸進式 Web 應用程式(2)

為了識別出在不同路由使用的通用模組並把它們放在一個通用的分塊,需要使用 CommonsChunkPlugin。它需要在每個頁面 requires 兩個 script 標籤,一個用於 commons 分塊,另一個用於一次路由的入口分塊。

Webpack 的 — display-chunks 標誌 對於檢視模組在哪個分塊中出現很有用。這個幫助我們減少分塊中重複的依賴,並且能提示在你的專案中開啟 CommonChunksPlugin 是否值得。這是一個帶有多個元件的專案,在不同分塊間檢測到重複的 Mustache.js 依賴:

使用 React.js 的漸進式 Web 應用程式(2)

Webpack 1 也支援通過 DedupePlugin 以在你的依賴樹中進行依賴庫的去重。在 Webpack 2,tree-shaking 應該淘汰了這個的需求。

更多 Webpack 的小貼士

  • 你的程式碼庫中 require.ensure() 呼叫的數目通常會關聯到生成的 bundles 的數目。在程式碼庫中大量使用 ensure 的時候意識到這點很有用。
  • Webpack2 的 Tree-shaking 會幫助刪除沒用的 exports,這可以讓你的 bundle 尺寸變小。
  • 另外,小心避免在 通用/共享的 bundles 裡面呼叫 require.ensure()。你可能會發現這會建立入口點引用,關於那些已經被載入了的依賴的假設。
  • 在 Webpack 2,System.import 目前不支援服務端渲染,但我已經在 StackOverflow 分享了怎麼去處理這個問題。
  • 如果需要優化編譯速度,可以看看 Dll pluginparallel-webpack 以及目標的編譯。
  • 如果你希望通過 Webpack 非同步 或者 延遲 指令碼,看看 script-ext-html-webpack-plugin

在 Webpack 編譯中檢測臃腫

Webpack 社群有很多建立在 Web 上的編譯分析器包括 http://webpack.github.io/analyse/https://chrisbateman.github.io/webpack-visualizer/,和 https://alexkuz.github.io/stellar-webpack/。這些對於理解你最大的模組是什麼很有用。

source-map-explorer (來自 Paul Irish) 通過 source maps 來理解程式碼臃腫,也超級棒的。看看這個對 ReactHN Webpack bundle 的 tree-map 視覺化,帶有每個檔案的程式碼行數,以及百分比的統計分析:

使用 React.js 的漸進式 Web 應用程式(2)

你可能也會對來自 Sam Saccone 的 coverage-ext 感興趣,它可以用來對任何 webapp 生成程式碼覆蓋率。這個對於理解你的程式碼中有多少實際會被執行到很有用。

程式碼分割(code-splitting)之上:PRPL 模式

Polymer 發現了一個有趣的 web 效能模式,用於精細服務的 apps,稱為 PRPL(看看 Kevin 的 I/O 演講)。這個模式嘗試為互動性優化,並且代表了:

  • (P)ush,對於初始路由推送關鍵資源。
  • (R)ender,渲染初始路由,並使它儘快變得可互動。
  • (P)re-cache,通過 Service Worker 預快取剩下的路由。
  • (L)azy-load,根據使用者在應用中的移動懶載入並懶初始化 apps 中對應的部分。

使用 React.js 的漸進式 Web 應用程式(2)

在這裡,我們必須給予 Polymer Shop demo 大大的讚賞,因為它向我們展示了真實移動裝置上的道路。使用 PRPL(在這種情況下通過 HTML Imports,從而利用瀏覽器的後臺 HTML parser 的好處)。螢幕上的畫素你都可以使用。這裡額外的工作在於分塊和保持可互動。在一臺真實移動裝置上,我們可以在 1.75 秒內達到可互動。1.3 秒用於 JavaScript,但它都被打散了。在那以後所有功能都可以用了。

你到現在應該已經成功享受到講應用打碎到更精細的分塊的好處了。當使用者第一次訪問我們的 PWA,假設說他們去到一個特定的路由。伺服器(使用 H/2 Push)能夠推送下來僅僅那次路由需要的分塊 —— 這些是用來啟動應用的必要資源,並會進入網路快取中。

一旦它們被推送下來了,我們就能高效地準備好未來會被載入的頁面分塊到快取中。當應用啟動後,檢查路由並指導我們想要的已經在快取中了,所以我們就能使得應用的首次載入非常快 —— 不僅僅是閃屏 —— 而是使用者請求的可互動內容。

下一部分是儘快渲染這個檢視的內容。第三部分是,當使用者在看當前的檢視的時候,使用 Service Worker 來開始預快取所有其他使用者還沒有請求的分塊和路由,將它們安裝到 Service Worker 的快取中。

此時,整個應用(或者大部分)都已經可以離線使用了。當使用者導航到應用的不同部分,我們可以從 Service Worker 的快取中懶載入下面的部分。不需要網路載入 —— 因為它們已經被預快取了。瞬間載入碉堡了!❤

PRPL 可以被應用到任何 app,正如 Flipkart 最近在他們的 React 棧上所展示的。完全使用 PRPL 的 Apps 可以利用 HTTP/2 伺服器推送的快速載入,通過產生兩種編譯版本,並根據瀏覽器的支援提供不同版本:

  • 一個 bundled 編譯,為沒有 HTTP/2 推送支援的伺服器/瀏覽器優化以最小化往返。For most of us, this is what we ship today by default.
  • 一個沒有 bundled 編譯,用於支援 HTTP/2 推送的伺服器/瀏覽器,使得首次繪製更快。

這個部分基於我們在之前討論的路由分塊的想法。通過 PRPL,伺服器和我們的 Service Worker 協作來為非活動路由預快取資源。當一個使用者在你的 app 中瀏覽並改變路由,我們對尚未快取的路由進行懶載入,並建立請求的檢視。

實現 PRPL

太長了,所以沒有看:Webpack 的 require.ensure() 以及非同步的 ‘getComponent’,還有 React Router 是到 PRPL 風格效能模式的最小摩擦路徑

使用 React.js 的漸進式 Web 應用程式(2)

PRPL 的一大部分在於將 JS 捆包思維方式放下,並像編寫時候那樣精細地傳輸資源(至少從功能獨立模組角度上)。通過 Webpack,這就是我們已經說過的路由分塊。

對於初始路由推送關鍵資源。理想情況下,使用 HTTP/2 服務端推送,但即便沒有它,也不會成為實現類 PRPL 路徑的阻礙。即便沒有 H/2 推送,你也可以實現一個大致和“完整” PRPL 類似的結果,只需要傳送 預載入頭 而不需要 H/2。

看看 Flipkart 他們前後的生產瀑布:

使用 React.js 的漸進式 Web 應用程式(2)

Webpack 已經通過 AggressiveSplittingPlugin 的形式支援了 H/2。

AggressiveSplittingPlugin 分割每個塊直到它到達了指定的 maxSize,正如我們在下面的例子裡可見的:

 

檢視官方 plugin page,以獲得關於更多細節的例子。學習 HTTP/2 推送實驗的課程真實世界 HTTP/2 也值得一讀。

  • 渲染初始路由:這實在取決於你使用的框架和庫。
  • 預快取剩下的路由。對於快取,我們依賴於 Service Worker。sw-precache 對於生成用於靜態資源預快取的 Service Worker 很棒,對於 Webpack 我們可以使用 SWPrecacheWebpackPlugin
  • 按需懶載入並建立剩下的路由 —— 在 Webpack 領域,require.ensure() 和 System.import() 是你的朋友。

通過 Webpack 的 Cache-busting 和長期快取

為什麼關心靜態資源版本?

靜態資源指的是我們頁面中像是指令碼,stylesheets 和圖片這樣的資源。當使用者第一次訪問我們頁面的時候,他們需要其需要的所有資源。比如說當我們落到一個路由的時候,JavaScript 塊和上次訪問之際並沒有改變 —— 我們不必重新抓取這些指令碼因為他們已經在瀏覽器快取中存在了。更少的網路請求對 web 效能來說是收益。

通常地,我們使用對每個檔案設定 expires 頭 來達到目的。一個 expires 頭只意味著我們可以告訴瀏覽器,避免在指定時間內(比如說1年)發起另一個對該檔案的請求到伺服器。隨著程式碼演變和重新部署,我們想要確保使用者可以獲得最新的檔案,如果沒有改變的話則不需要重新下載資源。

Cache-busting 通過在檔名後面附加字串來完成這個 —— 他可以是一個編譯版本(比如 src=”chunk.js?v=1.2.0”),一個 timestamp 或者別的什麼。我傾向於新增一個檔案內容的 hash 到檔名(比如 chunk.d9834554decb6a8j.js)因為這個在檔案內容發生改變的時候總是會改變。MD5 hashing 在 Webpack 社群經常被用來做這個來生成 16 位元組長的 ‘概要’。

通過 Webpack 的靜態資源長期快取 是關於這個主題的優秀讀物,你應該去看一看。我試圖在下面涵蓋其涉及到的主要內容。

在 Webpack 中通過內容雜湊來做資源版本

在 Webpack 設定中加上如下內容來啟用基於內容雜湊的資源版本 [chunkhash]

我們也想要保證常規的 [name].js 和 內容雜湊 ([name].[chunkhash].js) 檔名在我們的 HTML 檔案被正確引用。不同之處在於引用

下面是一個註釋了的 Webpack 設定樣例,包括了一些其他的外掛來使得長期快取的安裝更優雅。

現在我們有了這個 chunk-manifest JSON 的編譯,我們需要把它內聯(inline)到我們的 HTML,那麼 Webpack 就能實際在頁面啟動時真正對其有訪問權。所以在 標籤中加上上面的輸出。

通過使用 html-webpack-plugin 可以實現自動將指令碼內聯到 HTML 中。

注意:Webpack 理想上可以通過 no shared ID range 來簡化啟用長期快取的步驟(見~4–1)。

如果要學習更多 HTTP 的 快取最佳實踐,可以閱讀 Jake Archibald 的優秀文章。

更多閱讀

高階模組打包優化讀物

在系列文章第三篇中,我們會來看看 怎麼使你的 React PWA 能離線和斷續的網路狀態下工作.

如果你新接觸 React,我發現 Wes Bos 寫的 給新手的 React 很棒。

感謝 Gray Norton, Sean Larkin, Sunil Pai, Max Stoiber, Simon Boudrias, Kyle Mathews 和 Owen Campbell-Moore 的校對。

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

使用 React.js 的漸進式 Web 應用程式(2) 使用 React.js 的漸進式 Web 應用程式(2)

相關文章