【譯】React 應用性能調優

React 應用性能調優

案例研究

最近幾周,我一直在爲 Tello 工做,這是一個跟蹤和管理電視節目的 web app:html

做爲一個 web app 來講,它的代碼量是很是小的,大概只有 10,000 行。這是一個基於 Webpack 的 React/Redux 應用,有一個比較輕量的後端 Node 服務(基於 Express 和 MongoDB)。咱們 90% 的代碼都在前端。在 Github 上你能夠看到咱們的源碼。前端

前端性能能夠從不少角度來考量。可是從歷史角度來看,我更注重於頁面加載後的一些點:好比確保滾動的連貫性,以及動畫的流暢性。react

相比之下,我對於頁面加載時間的關注比較少,至少在一些小型項目上是這樣的。畢竟它並不須要傳輸太多的代碼;它確定是很快就能被訪問並使用的,對吧?android

然而,當我作了一些基準測試後,我驚奇地發現我這個 10k 行代碼的小應用在 3G 網絡下竟如此的慢~~,大約 5s 後才能顯示一些有意義的內容,而且須要 15s 才能解決全部的網絡請求。webpack

我意識到我得在這個問題上投入一些時間和精力。若是人們須要盯着一個空白的屏幕看 5s 的話,那個人動畫作的再漂亮也沒用了。ios

總而言之,我在這週末嘗試了 6 種技術,而且如今只須要 2300ms 左右就能夠在頁面上展現一些有意義的內容了 —— 減小了大約 50% 的時間!git

這篇博客是我嘗試的具體技術的研究案例以及他們的工做狀況,更普遍地來講,這裏記錄了我在解決問題時所學到的知識,以及我在提出解決方案時的一些思路。github

方法論

全部的分析都使用了相同的設置:web

  • 「Fast 3G」 的網速。
  • 桌面端分辨率。
  • 禁止 HTTP 緩存。
  • 已登陸,而且這個帳戶關注了 16 個電視節目。

基準值

咱們須要一個能夠用來比較結果的基準值!shell

咱們測試的頁面是主登陸頁的摘要視圖,這是數據量最大的頁面,所以它也有最大的優化空間

這個摘要部分就像下面這樣包含了一組卡片:

每一個節目都有本身的卡片,而且每一集都有本身的一個小方塊,藍色的方塊意味着這一集已經被觀看了。

這是咱們在 3G 網絡下作基準測試的 profile 視圖,看起來性能就不怎麼樣。

首次有效渲染:~5000ms 首張圖片加載:~6500ms 全部請求結束:>15,000ms

天哪,直到 5s 左右頁面才展現了一些有意義的內容。第一張圖片在 6.5s 左右的時候加載完成,全部的網絡請求足足花了 15s 才結束。

這個時間線視圖提供了一系列的內容。讓咱們仔細研究一下這之間究竟發生了什麼:

  1. 首先,最初的 HTML 被加載。由於咱們的應用不是服務端渲染的,這部分很是的快。
  2. 以後,開始下載整個 JS bundle。這部分花費了好久的時間。🚩
  3. JS下載完後,React 開始遍歷組件樹,計算初始化時掛載的狀態,而且將它推送到 DOM 上。這部分有一個 header,一個 footer,和一大片的黑色區域。🚩
  4. 掛載 DOM 後,這個應用發現它還須要一些數據,所以它向 /me 發起了一個 GET 請求來獲取用戶數據,以及他們關心的節目列表和看過的劇集。
  5. 一旦咱們拿到了關鍵的節目列表,就能夠開始請求下面的內容:
    • 每一個節目的圖片
    • 每一個節目的劇集列表

這些數據都來自 TV Maze 的 API

  • 你可能會想爲何我不在個人數據庫裏存儲這些劇集信息呢,這樣我就不須要調用 TV Maze 的接口了。其實緣由主要是 TV Maze 的數據更加真實;它有全部新的劇集的信息。固然,我也能夠在第四步的時候在服務端上拉取這些數據,但是這會增長這一步的響應時間,如此一來用戶就只能盯着一大片空白的黑色區域了。另外,我喜歡比較輕量的服務端。

還有一個可行方法就是設置一個定時任務,天天都去同步 TV Maze 的數據,而且只在我沒有最新數據的時候纔會去拉取。不過我仍是喜歡實時的數據,所以這個方案一直都沒有實施。

一次明顯的提高

目前來看,最大的瓶頸就是初始的 JS bundle 體積太大了,下載它耗費了太多的時間。

bundle 的體積有 526kb,並且目前它尚未被壓縮,咱們須要使用 Gzip 來解救它。

經過 Node/Express 的服務端很容易實現 Gzip;咱們只須要安裝 compression 模塊並將它做爲一個 Express 中間件使用就能夠了。

const path = require('path');

const express = require('express');
const compression = require('compression');


const app = express();

// 只須要將 compression 做爲一個 Express 中間件!
app.use(compression());

app.use(express.static(path.join(rootDir, 'build')));
複製代碼

經過使用這個很是簡單的解決方案,讓咱們看看咱們的時間線有什麼變化:

首次有效渲染:5000ms -> 3100ms 首張圖片加載:6500ms -> **4600ms **全部數據加載完成:6500ms -> **4750ms **全部圖片加載完成:~15,000ms -> ~13,000ms

代碼體積從 526kb 壓縮到只有 156kb,而且它對頁面加載速度形成了巨大的變化。

使用 LocalStorage 緩存

帶着前一步的明顯進步,我又回過頭來看了下時間線。首次渲染時在 2400ms 時觸發的,但此次並無什麼意義。3100 ms 時才真正有內容展現,可是直到 5000ms 左右才獲取到全部的劇集數據。

我開始考慮使用服務端渲染,可是這也解決不了問題。服務端仍須要調用數據庫,而後調用 TV Maze 的 API。更糟糕的是,在這段時間裏用戶只能傻盯着白花花的屏幕。

若是使用 local-storage 呢?咱們能夠把全部的狀態變動都存儲到瀏覽器上,並在用戶數據返回的時候對這個本地狀態進行補充。首屏的數據多是舊的,可是不要緊!真實的數據很快就能加載回來,而且這會使得首次加載的體驗很是快。

由於這個 app 使用了 Redux,因此持久化數據是很是簡單的。首先,咱們須要一個方案來保證 Redux 狀態變化時更新 localStorage:

import { LOCAL_STORAGE_REDUX_DATA_KEY } from '../constants';
import { debounce } from '../utils'; // generic debounce util

// 當咱們的頁面首次加載時,一堆 redux actions 會迅速被 dispatch
// 每一個節目都要獲取它們的劇集,因此最小的 action 數量是 2n (n 是節目的數量)
// 咱們不須要太過於頻繁的更新 localStorage,能夠對他作 debounce
// 若是傳入 null,咱們會抹去數據,一般用來在登陸登出時消除持久狀態
const updateLocalStorage = debounce(
  value =>
    value !== null
      ? localStorage.setItem(LOCAL_STORAGE_REDUX_DATA_KEY, value)
      : localStorage.removeItem(LOCAL_STORAGE_REDUX_DATA_KEY),
  2500
);


// store 更新時,將相關部分存儲到 localStorage 中
export const handleStoreUpdates = function handleStoreUpdates(store) {
  // 忽略 modals 和 flash 消息,他們不須要被存儲
  const { modals, flash, ...relevantState} = store.getState();

  updateLocalStorage(JSON.stringify(relevantState));
}

// 在退出登陸時用來清除數據的一個函數
export const clearReduxData = () => {
  // 當即清除存儲在 localStorage 中的數據
  window.localStorage.removeItem(LOCAL_STORAGE_REDUX_DATA_KEY);


  // 由於刪除是同步的,而持久化數據是異步的,所以這裏會致使一個微妙的 bug:
  // 存儲的數據會被刪除,可是稍後又會被填充上
  // 爲了解決這個問題,咱們會傳入一個 null,來終止當前隊列全部的更新
 
  updateLocalStorage(null);
  
  // 咱們須要觸發異步和同步的操做。
  // 同步操做保證數據能夠馬上被刪除,因此若是用戶點擊退出後馬上關閉頁面,數據也能被刪除
};
複製代碼

下一步,咱們須要讓 Redux store 訂閱這個函數,以及用前一次會話的數據對它進行初始化。

import { LOCAL_STORAGE_REDUX_DATA_KEY } from './constants';
import { handleStoreUpdates } from './helpers/local-storage.helpers';
import configureStore from './store';


const localState = JSON.parse(
  localStorage.getItem(LOCAL_STORAGE_REDUX_DATA_KEY) || '{}'
);

const store = configureStore(history, localState);

store.subscribe(() => {
  handleStoreUpdates(store);
});
複製代碼

雖然還有幾個遺留的小問題,可是得益於 Redux 架構,咱們只作了一些很小的改動就完成了大部分的功能。

讓咱們再來看看新的時間線:

棒極了!雖然經過這些很小的截屏很難說明什麼,可是咱們在 2600ms 時的那次渲染已經能夠展現一些內容了;它包括一個完整的節目列表以及從以前的會話裏保存的劇集信息。

首次有效渲染:3100ms -> **2600ms **獲取劇集數據:4750ms -> 2600ms (!)

雖然這並無影響到實際的加載時間(咱們仍然須要調用哪些 API,而且在這上面耗時),可是用戶能夠直接拿到數據,因此感知速度的提高很是明顯。

在內容已經出現的狀況下,頁面仍在繼續變化,這是一種很是流行的技術,可讓頁面更快地展示,而且當新的內容可用時,頁面發生更新。但是我更喜歡當即呈現最終的 UI。

這個方案在一些 non-perf 的狀況下有一些額外的優點。舉個例子,用戶能夠更改節目的順序,但可能因爲會話的結束致使數據丟失了。如今,當他們返回頁面時,以前的偏好仍是被保存了下來!

可是,這也有一個缺點:我不清楚你是否在等待新的數據加載。我計劃在角落裏添加一個加載框以顯示是否還有其餘請求正在加載。

另外,你可能會想「這對於老用戶來講可能不錯,可是對於新用戶並無什麼用處!」。你說的沒錯,但實際上,這也確實不適用於新用戶。新用戶並無關注的節目,只有一個引導他們添加節目的提示,所以他們的頁面加載的很是快。因此,對於全部的用戶來講,無論是新用戶仍是老用戶,咱們都已經有效避免了那種一直盯着黑屏的體驗。

圖片和懶加載

即便有了這個最新的改進,圖片的加載仍然花費了不少的時間。這個時間線裏沒有展現出來,可是在 3G 網絡下,全部的圖片加載一共耗費了超過 12 秒。

緣由很簡單:TV Maze 返回了一張巨大的電影海報風格的照片,然而我只須要一個狹長的條狀圖,用於幫助用戶一眼就能分辨出節目。

左邊:被下載的圖片 ················ 右邊:真正用到的圖片

爲了解決這個問題,我一開始的想法是使用一個相似於 ImageMagick 的 CLI 工具,我在製做 ColourMatch 時使用過它。

當用戶添加一個新的節目時,服務端將請求一個圖片的副本,使用 ImageMagick 將圖片的中間裁剪出來併發送給 S3,而後客戶端會使用 S3 的 url 而非 TV Maze 的圖片連接。

不過,我決定使用 Imgix 來完成這個功能。Imgix 是一個基於 S3(或者其餘雲存儲提供商) 的圖片服務,它容許你動態建立裁剪過或者調整了大小的圖片。你只須要使用下面這樣的連接,它就會建立並提供合適的圖片。

https://tello.imgix.net/some_file?w=395&h=96&crop=faces
複製代碼

它還有一個優點就是可以找到圖片中有趣的區域並作裁剪。你會注意到,在上面的左/右照片對比中,它將 4 個騎車的孩子裁剪了出來,而非僅僅裁剪出圖片的中心

爲了配合 Imgix 的工做,你的圖片須要可以經過 S3 或者相似的服務被獲取到。這裏是一段個人後端代碼片斷,當添加一個新的節目時會上傳一張圖片:

const ROOT_URL = 'https://tello.imgix.net';

const uploadImage = ({ key, url }) => (
  new Promise((resolve, reject) => {
    // 有些狀況下節目沒有一個連接,這時候跳過這種狀況
    if (!url) {
      resolve();
      return;
    }

    request({ url, encoding: null }, (err, res, body) => {
      if (err) {
        reject(err);
      }

      s3.putObject({
        Key: key,
        Bucket: BUCKET_NAME,
        Body: body,
      }, (...args) => {
        resolve(`${ROOT_URL}/${key}`);
      });
    });
  })
);
複製代碼

經過對每一個新的節目調用這個 Promise,咱們獲取了能夠被動態裁剪的圖片。

在客戶端,咱們使用 srcsetsizes 這兩個圖片屬性來確保圖片是基於窗口大小和像素比來提供的:

const dpr = window.devicePixelRatio;

const defaultImage = 'https://tello.imgix.net/placeholder.jpg';

const buildImageUrl = ({ image, width, height }) => (`
  ${image || defaultImage}?fit=crop&crop=entropy&h=${height}&w=${width}&dpr=${dpr} ${width * dpr}w
`);


// Later, in a render method:
<img
  srcSet={`
    ${buildImageUrl({
      image,
      width: 495,
      height: 128,
    })},
    ${buildImageUrl({
      image,
      width: 334,
      height: 96,
    })}
  `}
  sizes={`
    ${BREAKPOINTS.smMin} 334px,
    495px
  `}
/>
複製代碼

這確保了移動設備能獲取更大版本的圖像(由於這些卡片佔據了整個視口的寬度),而桌面客戶端獲得的是一個較小的版本。

懶加載

如今,每張圖片都變小了,可是咱們仍是一次性加載了整個頁面的圖片!在個人大型桌面窗口上,每次只能看到 6 個節目,可是咱們在頁面加載的時候一次性獲取了所有的 16 張圖片。

值得慶幸的是,有一個很棒的庫 react-lazyload 提供了很是便利的懶加載功能。代碼示例以下:

import LazyLoad from 'react-lazyload';

// In some render method somewhere:
<LazyLoad once height={UNITS_IN_PX[6]} offset={50}>
  <img
    srcSet={`...omitted`}
    sizes={`...omitted`}
  />
</LazyLoad>
複製代碼

來吧,讓咱們再來看看時間線。

咱們的首次有效渲染時間沒什麼變化,可是圖片加載的時間有了明顯的下降:

首張圖片:4600ms -> 3900ms 全部可見範圍內的圖片:~9000ms -> 4100ms

眼尖的讀者可能已經注意到了,這個時間線上只下載了 6 集的數據而不是所有的 16集。由於我最初的嘗試(也是我記憶中惟一一個嘗試)就是懶加載節目卡片,而並不只僅是懶加載圖片。

不過,相比我這週末解決的問題,它也引起了更多的問題,所以我對它進行了一些簡化。可是這並不會影響圖片加載時間的優化。

代碼分割

我敢確定,代碼分割是一個很是明智的決定。

由於如今有一個顯而易見的問題,咱們的代碼 bundle 只有一個。讓咱們使用代碼分割來減小一個請求所須要的代碼量!

我使用的路由方案是 React Router 4,它的文檔上有一個很簡單的建立 <Bundle /> 組件的例子。我設置了幾個不一樣的配置,可是最終代碼並無比較有效的分割。

最後,我將移動端和桌面端的視圖作了分離。移動版有本身的視圖,它使用了一個滑動庫,一些自定義的靜態資源和幾個額外的組件。使人吃驚的是,這個分離出來的 bundle 很是的小 —— 壓縮前大概只有 30kb —— 可是它仍是帶來了一些顯著的影響:

首次有效渲染:2600ms -> 2300ms 首張圖片加載:3900ms -> 3700ms

經過此次嘗試讓我學到了一件事:代碼分割的效果很大程度上取決於你的應用類型。在我這個 case 裏,最大的依賴就是 React 和它生態系統裏的一些庫,然而這些代碼是整站都須要的而且不須要被分離出來

在頁面加載時,咱們能夠在路由層面對組件進行分割以得到一些邊際效益,可是這樣的話,每當路由變化時都會形成額外的延遲;到處都要處理這種小問題並不有趣。


一些其餘方法的嘗試和思考

服務端渲染

個人想法是在服務端渲染一個 "shell" —— 一個有正確佈局的佔位圖,只是沒有數據。

可是我預見到一個問題,由於客戶端已經經過 localStorage 獲取前一次會話的數據了,而且它使用這個數據進行了初始化。可是此時服務端是不知情的,因此我須要處理客戶端與服務器之間的標記不匹配。

我認爲雖然我能夠經過 SSR 將個人首次有效渲染時間減小半秒,可是在那時整個網站都是不能交互的;當一個網站看起來已經準備好了但其實不是的時候,讓人以爲很是奇怪。

另外,SSR 也會增長複雜性,而且下降開發速度。性能很重要,可是足夠好就夠了。

有一個我很感興趣可是沒時間研究的問題是 —— 編譯時 SSR。它可能這隻適用於一些靜態頁面,好比登出頁,可是我以爲它是很是有效的。做爲我構建過程的一部分,我會建立並持久化存儲 index.html,並經過 Node 服務器將它做爲一個純 HTML 文件提供給用戶。客戶端仍然會下載並運行 React,所以頁面仍然是可交互的,可是服務端不須要花時間去構建了,由於我已經在代碼部署時直接將這些頁面構建好了。

CDN 的依賴

還有一個我認爲有很大潛力的想法就是將 React 和 ReactDOM 託管到 CDN 上。

Webpack 使得這很容易實現;你能夠經過定義 externals 關鍵字避免將它們打包到你的 bundle 中。

// webpack.config.prod.js
{
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
}
複製代碼

這種方法有兩個優點:

  • 從 CDN 獲取一個流行的庫,它有很大可能已經被用戶緩存了
  • 依賴關係能夠被並行化,能夠同時下載你的代碼,而不是下載一個大文件

我很驚訝的發現,至少在 CDN 未緩存的最壞狀況下,將 React 移到 CDN 上並無什麼益處:

首次有效渲染時間:2300ms -> 2650ms

你可能會發現 React 和 React DOM 是和個人主要軟件包並行下載的,而且它確實拖慢了總體的時間。

我並非想說使用 CDN 是一個壞主意。在這方面我並非很專業而且極可能是我作錯了,而不是這個想法的問題!至少在個人 case 裏它並無生效。

譯者注: 這裏將 React 放在 CDN 上的方案,在本地無緩存的狀況下很明顯沒什麼優點,由於你的總代碼體積不會減小,你的帶寬沒有變化,JS是並行下載可是串行執行,因此總的下載時間和執行時間並不會有什麼優點;反而因爲 http 創建連接的損耗可能會減慢速度,這也是咱們說要儘量減小 http 請求的緣由;並且因爲是本地測試,CDN 的優點可能並無體現。 可是我以爲這種方案仍是可取的,主要有兩點:1. 由於有 CDN,能夠保證大部分人的下載速度,而放在你的服務器上其實因爲傳輸的問題不少人下載會很是慢;2. 因爲將 React 相關的庫抽離,後續每次更改代碼和發佈後這部分代碼都是走的緩存,能夠減小後續用戶的加載時間


結論

經過這篇文章,我但願傳達出兩個觀點:

  1. 小型程序的開箱即用性很是高,可是一個週末就能夠帶來一個巨大的提高。這要感謝 Chrome 開發者工具,它能夠幫你快速確認項目的瓶頸,而且讓你驚訝的發現項目裏有如此多的性能窪地。也能夠將一些複雜的任務交給像 Imgix 這樣的低成本或者免費的服務商。
  2. 每一個應用都是不一樣的,這篇文章詳細介紹了 Tello 的一些技巧,可是這些技巧的關注點比較特別。即便這些技巧不適用於你的應用,但我但願我已經把理念表達清楚了:性能取決於 web 開發者的創造性。

舉個例子,在一些傳統的觀念看來,服務端渲染是一個必經之路。可是我在的應用裏,基於 local-storage 或者 service-workers 來作前端渲染則是一個更好的選擇!也許你能夠在編譯時作一些工做,減小 SSR 的耗時,又或者學習 Netflix,徹底不將 React 傳遞給前端

當你作性能優化時,你會發現這很是須要創造力和開闊的思路,而這也是它最有趣的的地方。
複製代碼

很是感謝您的閱讀!我但願這篇文章能給您帶來幫助:)。若是您有什麼想法能夠聯繫個人 Twitter

能夠在 Github 上查看 Tello 的源碼****🌟


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索