使用 React.js 的漸進式 Web 應用程式:第 2 部分 – 頁面載入效能

宅一帆markzhai發表於2019-02-27

系列第二篇,來看看基於 React 路由分塊的頁面載入優化。

使用 React.js 的漸進式 Web 應用程式:第 2 部分 – 頁面載入效能

這是新系列的第二部分,新系列介紹的是使用 Lighthouse 優化移動 web 應用傳輸的技巧。本期,我們關注的是頁面載入效能。

保證頁面載入效能是快的

移動 Web 的速度很關鍵。平均來說,更快的體驗會 延長 70% 的會話 以及兩倍以上更多的移動廣告收益。基於 React 的 Web 效能投資中,Flipkart Lite 使訪問時間提升了三倍, GQ 在流量上得到了 80% 增長,Trainline 在 年收益上增長了 11M 並且 Instagram 的 好感度上升了 33%

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

使用 React.js 的漸進式 Web 應用程式:第 2 部分 – 頁面載入效能

測量並優化一直很重要。Lighthouse 的頁面載入檢測會關注:

關於 PWA 值得關注的有趣指標),Paul Irish 做了很棒的總結。

良好效能的目標:

  • 遵循 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 壓縮包只有 11KB,vendor/React/libraries 壓縮包只有 107KB。實際上,它們是這樣的:

使用 React.js 的漸進式 Web 應用程式:第 2 部分 – 頁面載入效能

之後,對於有小功能的 app 來說,我們會使用 PRPL 這樣的效能模式,這種模式可以充分利用 HTTP/2 的伺服器推送 功能,利用顆粒狀的 “基於路由的分塊” 來得到快速的可互動時間。(可以試試 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 程式碼,然後動態載入其餘部分。

這些年來,很多 app 已經使用了程式碼分割的概念,然而現在用 “基於路由的分塊” 來稱呼它。我們可以通過 Webpack 模組打包器為 React 啟用這個設定。

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

當 Webpack 在 app 程式碼中發現 require.ensure()(在 Webpack 2 中是 System.import)時,支援分割程式碼。這些方法出現的地方被稱為“分割點”,Webpack 會對它們的每一個都生成一個分開的 bundle,按需解決依賴。

// 定義一個 "split-point"
require.ensure([], function () {
   const details = require(`./Details`);
   // 所有被 require() 需要的都會成為分開的 bundle
   // require(deps, cb) 是非同步的。它會非同步載入,並且評估
   // 模組,通過你的 deps 的 exports 呼叫 cb。
});複製程式碼

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

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

    import App from `../containers/App`;

    function errorLoading(err) {
      console.error(`Lazy-loading failed`, err);
    }

    function loadRoute(cb) {
      return (module) => cb(null, module.default);
    }
    export default {
      component: App,
      childRoutes: [
        // ...
        {
          path: `booktour`,
          getComponent(location, cb) {
            System.import(`../pages/BookTour`)
              .then(loadRoute(cb))
              .catch(errorLoading);
          }
        }
      ]
    };複製程式碼

加分項:預載入那些路由!

在我們繼續之前,一個配置可選項是來自 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() 使我們可以按需懶載入路由,在元件被使用前等待拉取:

    const rootRoute = {
      component: Layout,
      path: `/`,
      indexRoute: {
        getComponent (location, cb) {
          require.ensure([], () => {
            cb(null, require(`./Landing`))
          })
        }
      },
      childRoutes: [
        {
          path: `book`,
          getComponent (location, cb) {
            require.ensure([], () => {
              cb(null, require(`./BookTour`))
            })
          }
        },
        {
          path: `details/:id`,
          getComponent (location, cb) {
            require.ensure([], () => {
              cb(null, require(`./Details`))
            })
          }
        }
      ]
    }複製程式碼

注意:我經常配合 CommonChunksPlugin (minChunks: Infinity) 使用上面的配置,這樣不同入口檔案中的相同模組只有一個 chunk。這還 降低 了陷入預設 webpack 執行期。

Brian Holt 在 React 的完整介紹 中對非同步路由載入介紹得很好。。

Brian Holt 在 React 的完整介紹 對非同步路由載入闡述地很全面。通過非同步路由的程式碼分割在 React Router 的最新版本和 新的 React Router V4 上都可以使用。

使用非同步的 getComponent + require.ensure() 的宣告式路由 chunk

有一個可以更快設定程式碼分割的小技巧。在 React Router 中,一個根路由 “/” 對映到 App 元件的 申明式的路由 就像這樣

React Router 也支援 [getComponent](https://github.com/ReactTraining/react-router/blob/master/docs/API.md#getcomponentnextstate-callback) 屬性,十分方便,類似於 component 但卻是非同步的,並且能夠非常快速地設定程式碼分割:

 {
   // 非同步地查詢 components
  cb(null, Stories)
&rcub&rcub />複製程式碼

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

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

    var IndexRoute = require(`react-router/lib/IndexRoute`)
    var App = require(`./App`)
    var Item = require(`./Item`)
    var PermalinkedComment = require(`./PermalinkedComment`) <-- var="" userprofile="require(`./UserProfile`)" notfound="require(`./NotFound`)" top="stories(`news`," `topstories`,="" 500)="" ....="" module.exports="<Route" path="/" component="{App}">
      
      
      
      
      
       <--- <route="" path="newcomments" component="{Comments}/">
      
      
    複製程式碼

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

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

var PermalinkedComment = require(‘./PermalinkedComment’)複製程式碼

然後開始我們的路由..

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

 {
    require.ensure([], require => {
      callback(null, require(`./PermalinkedComment`))
    }, `PermalinkedComment`)
  &rcub&rcub
/>複製程式碼

OMG,太棒了。這..就搞定了。不騙你。我們可以如法炮製剩下的路由,然後執行 webpack。它會正確地找到 require.ensure() 呼叫,並且如我們所願地分割程式碼。

使用 React.js 的漸進式 Web 應用程式:第 2 部分 – 頁面載入效能

將宣告式程式碼分割應用到我們的大部分路由後,我們可以看到路由分塊生效了,只在需要的時候對一個路由(我們能夠預快取在 Service Worker 裡)載入所需程式碼:

使用 React.js 的漸進式 Web 應用程式:第 2 部分 – 頁面載入效能

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

CommonsChunkPlugin

使用 React.js 的漸進式 Web 應用程式:第 2 部分 – 頁面載入效能

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

const CommonsChunkPlugin = require("webpack/lib/optimize/CommonsChunkPlugin");
module.exports = {
    entry: {
        p1: "./route-1",
        p2: "./route-2",
        p3: "./route-3"
    },
    output: {
        filename: "[name].entry.chunk.js"
    },
    plugins: [
        new CommonsChunkPlugin("commons.chunk.js")
    ]
}複製程式碼

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 上的編譯分析器包括 webpack.github.io/analyse/chrisbateman.github.io/webpack-vis…,和 alexkuz.github.io/stellar-web…,這些能方便地明確你專案中最大的模組。

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 推送)能夠推送下來僅僅那次路由需要的分塊 —— 這些是用來啟動應用的必要資源,並會進入網路快取中。

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

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

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

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

  • 一個 bundled 編譯,為沒有 HTTP/2 推送支援的伺服器/瀏覽器優化以最小化往返。對大多數人而言,這是現在預設的訪問內容。

  • 一個沒有 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(最大尺寸),正如我們在下面的例子裡可見的:

    module.exports = {
        entry: "./example",
        output: {
            path: path.join(__dirname, "js"),
            filename: "[chunkhash].js",
            chunkFilename: "[chunkhash].js"
        },
        plugins: [
            new webpack.optimize.AggressiveSplittingPlugin({
                minSize: 30000,
                maxSize: 50000
            }),
    // ...複製程式碼

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

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

通過 Webpack 的快取失效和長期快取

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

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

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

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

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

在 Webpack 中通過 content-hashing 來做資源版本控制

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

filename: ‘[name].[chunkhash].js’,
chunkFilename: ‘[name].[chunkhash].js’複製程式碼

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

相關文章