【譯】React 應用效能調優

zhe.zhang發表於2019-03-03

React 應用效能調優

案例研究

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

【譯】React 應用效能調優

作為一個 web app 來說,它的程式碼量是非常小的,大概只有 10,000 行。這是一個基於 Webpack 的 React/Redux 應用,有一個比較輕量的後端 Node 服務(基於 Express 和 MongoDB)。我們 90% 的程式碼都在前端。在 Github 上你可以看到我們的原始碼。

前端效能可以從很多角度來考量。但是從歷史角度來看,我更注重於頁面載入後的一些點:比如確保滾動的連貫性,以及動畫的流暢性。

相比之下,我對於頁面載入時間的關注比較少,至少在一些小型專案上是這樣的。畢竟它並不需要傳輸太多的程式碼;它肯定是很快就能被訪問並使用的,對吧?

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

我意識到我得在這個問題上投入一些時間和精力。如果人們需要盯著一個空白的螢幕看 5s 的話,那我的動畫做的再漂亮也沒用了。

總而言之,我在這週末嘗試了 6 種技術,並且現在只需要 2300ms 左右就可以在頁面上展示一些有意義的內容了 —— 減少了大約 50% 的時間!

這篇部落格是我嘗試的具體技術的研究案例以及他們的工作情況,更廣泛地來說,這裡記錄了我在解決問題時所學到的知識,以及我在提出解決方案時的一些思路。

方法論

所有的分析都使用了相同的設定:

  • “Fast 3G” 的網速。
  • 桌面端解析度。
  • 禁止 HTTP 快取。
  • 已登入,並且這個賬戶關注了 16 個電視節目。

基準值

我們需要一個可以用來比較結果的基準值!

我們測試的頁面是主登入頁的摘要檢視,這是資料量最大的頁面,因此它也有最大的優化空間

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

【譯】React 應用效能調優

每個節目都有自己的卡片,並且每一集都有自己的一個小方塊,藍色的方塊意味著這一集已經被觀看了。

這是我們在 3G 網路下做基準測試的 profile 檢視,看起來效能就不怎麼樣。

【譯】React 應用效能調優

首次有效渲染:~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')));
複製程式碼

通過使用這個非常簡單的解決方案,讓我們看看我們的時間線有什麼變化:

【譯】React 應用效能調優

首次有效渲染: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 架構,我們只做了一些很小的改動就完成了大部分的功能。

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

【譯】React 應用效能調優

棒極了!雖然通過這些很小的截圖很難說明什麼,但是我們在 2600ms 時的那次渲染已經可以展示一些內容了;它包括一個完整的節目列表以及從之前的會話裡儲存的劇集資訊。

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

雖然這並沒有影響到實際的載入時間(我們仍然需要呼叫哪些 API,並且在這上面耗時),但是使用者可以直接拿到資料,所以感知速度的提升非常明顯。

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

這個方案在一些 non-perf 的情況下有一些額外的優勢。舉個例子,使用者可以更改節目的順序,但可能由於會話的結束導致資料丟失了。現在,當他們返回頁面時,之前的偏好還是被儲存了下來!

但是,這也有一個缺點:我不清楚你是否在等待新的資料載入。我計劃在角落裡新增一個載入框以顯示是否還有其他請求正在載入。

另外,你可能會想“這對於老使用者來說可能不錯,但是對於新使用者並沒有什麼用處!”。你說的沒錯,但實際上,這也確實不適用於新使用者。新使用者並沒有關注的節目,只有一個引導他們新增節目的提示,因此他們的頁面載入的非常快。所以,對於所有的使用者來說,不管是新使用者還是老使用者,我們都已經有效避免了那種一直盯著黑屏的體驗。

圖片和懶載入

即使有了這個最新的改進,圖片的載入仍然花費了很多的時間。這個時間線裡沒有展示出來,但是在 3G 網路下,所有的圖片載入一共耗費了超過 12 秒。

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

【譯】React 應用效能調優

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

為了解決這個問題,我一開始的想法是使用一個類似於 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>
複製程式碼

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

【譯】React 應用效能調優

我們的首次有效渲染時間沒什麼變化,但是圖片載入的時間有了明顯的降低:

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

眼尖的讀者可能已經注意到了,這個時間線上只下載了 6 集的資料而不是全部的 16集。因為我最初的嘗試(也是我記憶中唯一一個嘗試)就是懶載入節目卡片,而並不僅僅是懶載入圖片。

不過,相比我這週末解決的問題,它也引發了更多的問題,因此我對它進行了一些簡化。但是這並不會影響圖片載入時間的優化。

程式碼分割

我敢肯定,程式碼分割是一個非常明智的決定。

因為現在有一個顯而易見的問題,我們的程式碼 bundle 只有一個。讓我們使用程式碼分割來減少一個請求所需要的程式碼量!

我使用的路由方案是 React Router 4,它的文件上有一個很簡單的建立 <Bundle /> 元件的例子。我設定了幾個不同的配置,但是最終程式碼並沒有比較有效的分割。

最後,我將移動端和桌面端的檢視做了分離。移動版有自己的檢視,它使用了一個滑動庫,一些自定義的靜態資源和幾個額外的元件。令人吃驚的是,這個分離出來的 bundle 非常的小 —— 壓縮前大概只有 30kb —— 但是它還是帶來了一些顯著的影響:

【譯】React 應用效能調優

首次有效渲染: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 上並沒有什麼益處:

【譯】React 應用效能調優

首次有效渲染時間: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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章