使用 React.js 的漸進式 Web 應用程序:第 2 部分 - 頁面加載性能

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

使用 React.js 的漸進式 Web 應用程序:第 2 部分 - 頁面加載性能

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

保證頁面加載性能是快的

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

在你的 web app 加載時有一些 關鍵的用戶時刻node

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

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

良好性能的目標:git

  • 遵循 RAIL 性能模型 的 L 部分。A+ 的性能是咱們全部人都必須力求達到的,即使有的瀏覽器不支持 Service Worker。咱們仍然能夠快速地在屏幕上得到一些有意義的內容,而且僅加載咱們所須要的
  • 在典型網絡(3G)和硬件條件下
  • 首次訪問在 5 秒內可交互,重複訪問(Service Worker 可用)則在 2 秒內。
  • 首次加載(網絡限制下),速度指數在 3000 或者更少。
  • 第二次加載(磁盤限制,由於 Service Worker 可用):速度指數 1000 或者更少。

讓咱們再說說,關於經過 TTI 關注交互性。es6

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

爲交互性優化,也就是使得 app 儘快能對用戶可用(好比讓他們能夠四處點擊,app 能夠響應)。這對試圖在移動設備上提供一流用戶體驗的現代 web 體驗很關鍵。github

Lighthouse 目前將 TTI 做爲佈局是否達到穩定的衡量,web 字型是否可見而且主線程是否有足夠的能力處理用戶輸入。有不少方法來手動跟蹤 TTI,重要的是根據指標進行優化會提高你用戶的體驗。web

對於像 React 這樣的庫,你應該關心的是在移動設備上 啓用庫的代價 由於這會讓人們有感知。在 ReactHN,咱們達到了 1700毫秒 內就完成了交互,儘管有多個視圖,但咱們仍是保持整個 app 的大小和執行消耗相對很小:app 壓縮包只有 11KB,vendor/React/libraries 壓縮包只有 107KB。實際上,它們是這樣的:chrome

以後,對於有小功能的 app 來講,咱們會使用 PRPL 這樣的性能模式,這種模式能夠充分利用 HTTP/2 的服務器推送 功能,利用顆粒狀的 「基於路由的分塊」 來獲得快速的可交互時間。(能夠試試 Shop demo 來獲取直觀瞭解)。

Housing.com 最近使用了類 PRPL 模式搭載 React 體驗,得到了不少讚賞:

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 就夠了呢?咱們應該丟開那些無助於得到更快體驗的腳本,來加速得到可交互的路由。

當僅提供用戶一次 route 所須要的最小功能的可用代碼就能夠的時候,避免提供龐大整塊的 bundles(像上圖)。

代碼分割是解決整塊的 bundles 的一個方法。想法大體是在你的代碼中定義分割點,而後分割成不一樣的文件進行按需懶加載。這會改善啓動時間,幫助更迅速地達到可交互狀態。

想象使用一個公寓列表 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 塊,用戶真正訪問這些路由時已經緩存而且可以當即實例化。

筆者寫這篇文章的時候,預加載只能在 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) }} /> 

 複製代碼

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="
  
  
  

  
 
   
    
     
      
       
       
         <--- 
         
          
           
           
          
          
         
        
       
      
     
    
   
 
   
 
  複製代碼 

 

ReactHN 如今提供給用戶一個整塊的 JS bundle,包含全部路由。讓咱們將它轉換爲路由分塊,只提供一次路由真正須要的代碼,從 comment 的永久連接開始(comment/:id):

因此咱們首先刪了對永久連接組件的隱式 require:

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

而後開始咱們的路由..

而後使用聲明式的 getComponent 來更新它。咱們在路由中使用 require.ensure() 調用來懶加載,而這就是咱們所須要作的一切了:

 
  
  { require.ensure([], require => { callback(null, require('./PermalinkedComment')) }, 'PermalinkedComment') }} /> 

 複製代碼

OMG,太棒了。這..就搞定了。不騙你。咱們能夠如法炮製剩下的路由,而後運行 webpack。它會正確地找到 require.ensure() 調用,而且如咱們所願地分割代碼。

將聲明式代碼分割應用到咱們的大部分路由後,咱們能夠看到路由分塊生效了,只在須要的時候對一個路由(咱們可以預緩存在 Service Worker 裏)加載所需代碼:

提醒:有許多可用於 Service Worker 的簡單 Webpack 插件:

CommonsChunkPlugin

爲了識別出在不一樣路由使用的通用模塊並把它們放在一個通用的分塊,須要使用 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 依賴:

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 可視化,帶有每一個文件的代碼行數,以及百分比的統計分析:

你可能也會對來自 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 中對應的部分。

在這裏,咱們必須給予 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 風格性能模式的最小摩擦路徑

PRPL 的一大部分在於顛覆 JS 打包思惟,並像編寫時候那樣精細地傳輸資源(至少從功能獨立模塊角度上)。配合 Webpack,這就是咱們已經說過的路由分塊。

對於初始路由推送關鍵資源。理想狀況下,使用 HTTP/2 服務端推送,但即使沒有它,也不會成爲實現類 PRPL 路徑的阻礙。即使沒有 H/2 推送,你也能夠實現一個大體和「完整」 PRPL 相似的結果,只須要發送 預加載頭 而不須要 H/2。

看看 Flipkart 他們先後的生產瀑布流:

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 文件被正確引用。不一樣之處在於引用

相關文章
相關標籤/搜索