React App 效能優化

速度有多重要

在周星馳電影裡有一句話說道:「天下武功,無堅不破,唯快不破」,現在的 Web 應用比以往更具有互動性,搞定性能可以幫助你極大改善使用者的體驗。

而根據 CDN 廠商 Akamai 所發表的報告更指出,當你的網站延遲超過 3 秒,則 53% 的使用者會跳出網站。

檢測指標

在進行優化前,要先知道問題才能對症下藥,Google 有提供兩個常用的檢測工具,一般網頁可以用 PageSpeed Insights 檢測網頁與行動版本的使用者體驗,另外則是利用 Lighthouse 來做 PWA (Progressive Web Apps, 漸進式網頁應用程式) 的相關項目檢測與修改建議。

先檢測看看目前正在運作的網站

使用 PageSpeed Insights

PageSpeed Insights 行動版檢測結果

PageSpeed Insights 網頁版檢測結果

使用 Lighthouse

PWA 只有 45 分,不具有漸進式應用程式的特性

Lighthouse 檢測結果

從檢測報告可看出造成效能低落的兩個主要原因:

  1. JS 文件過大,首次載入會下載超過 1MB 左右的 JavaScript

首次載入 1MB 的 JS 文件

  1. 有些圖片檔案太大

圖片檔案太大

專案架構

針對第一點 JS 檔案過大,先來看看我們的架構和遇到了什麼樣的問題

專案整體採用了以下技術構建

  • SPA (Single Page Application)
  • React SSR (Server-side Rendering)
  • Webpack

我們使用 React 開發 SPA 和 SSR 實現 Universal 應用,然後在以 React 生態系的相關套件完成各種功能,打包工具則是採用 Webpack 進行模組打包。

會有什麼樣的問題

webpack 架構圖

  • 網站所用的 JavaScript、Image、CSS 等靜態資源全部打包在一起
  • 功能越來越多會導致資源文件越來越大
  • 首次讀取網站時加載全部內容而導致載入時間過慢

針對程式方面的優化策略

接著就來分析看看有哪些方案可以實作來改善效能

Code Splitting

對於大型 Web 應用而言,把所有的程式碼放到一個文件的做法效率很差,有些功能一般使用者是不會用到的,可以等跳轉到該頁面時在載入即可。

所有模組打包在一起

Webpack 有個功能可以把你的程式碼分離成獨立的 Chunk,而此 Chunk 可以依照需求動態載入。這個功能就是 Code Splitting,實作方式依照 Webpack 版本也不同,1 版本是用 require.ensure;2 版本也可以用 System.import 來實作。

Route-Level Code Splitting

可以搭配 react-router 的 getComponent,以路由作為分離點產生 chunks 檔案,require 是指向 routing component (container) 的路徑。

以路由為分離點

routes.js 部分程式碼如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export default store => {
// for Server-side check
if (typeof require.ensure !== 'function') {
require.ensure = (deps, cb) => cb(require);
}
async function requireAccessToken() {
const { user } = store.getState().auth;
const loginUser = await getLoginUser(user);
if (loginUser && loginUser.info) {
await store.dispatch(checkTokenAlive(loginUser.info));
}
}
return {
path: '/',
component: require('./containers/App/App'),
indexRoute: {
component: require('./containers/Home/Home')
},
childRoutes: [
{
path: 'course/:uid',
onEnter: requireAccessToken,
getComponent(location, cb) {
require.ensure([], (require) => cb(null, require('./containers/Course/Course/Course')));
},
],
};
};

編譯結果

編譯結果

Component-Level Code Splitting

對於一些元件或模組,不要一開始就載入它們,例如 aws-sdkvideo.js 等比較大包但又不需要馬上用到的模組,Webpack 也可以實現元件層級的分離,require.ensure 也可以應用在 Module。

以元件為中心打包

以使用 aws-sdk 的 S3 為例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function getFile(fileName, callbackFunc) {
updateS3keypair().then(result => {
require
.ensure('aws-sdk/clients/s3', () => {
const S3 = require('aws-sdk/clients/s3');
// Use S3
const s3 = new S3(result);
const params = {
Key: fileName,
};
s3.getObject(params, (err, data) => {
callbackFunc(err || data);
});
})
.catch(err => {
// Handle failure
console.log(err);
});
});
}

再來是 React 的 Component 也可以改寫成 Async Cmponent,不過因為寫 require.ensure 程式碼不好閱讀,且 React 元件在 SSR 也有些雷要處理,因此直接下載 react-async-component 使用會比較無痛。

建立一個 Async Component

1
2
3
4
5
6
7
8
9
10
import { asyncComponent } from 'react-async-component';
export default asyncComponent({
resolve: () =>
new Promise(resolve =>
require.ensure([], require => {
resolve(require('react-datepicker'));
})
),
});

引用 Async Component

1
2
3
import DatePicker from '../DatePicker';
export default () => <DatePicker />;

現在再回頭來看看 require.ensure 這個函式

1
require.ensure(dependencies: String[], callback: function(require), chunkName: String)

第一個參數表示相依模組,通過這個參數,在回調函數被執行前,我們可以將所有需要用到的模組進行宣告。

所以,如果有個模組會依賴其他模組,你可以這樣子寫,這樣所有相依模組都會被打包在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { asyncComponent } from 'react-async-component';
export default asyncComponent({
resolve: () =>
new Promise(resolve =>
require.ensure(
[
'antd/lib/button',
'antd/lib/checkbox',
'antd/lib/dropdown',
'antd/lib/menu',
'antd/lib/pagination',
'antd/lib/radio',
'antd/lib/spin',
],
require => {
resolve(require('antd/lib/table'));
}
)
),
});

Split single bundle to multiple

雖然做了 Code Splitting 將其他程式碼拆出去了,但如果網站功能太多,剩下的那包 JS 還是會很大,這時可以用 Webpack 的 CommonChunksPlugin 拆解 main bundle

webpack 設定如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module.exports = {
entry: {
polyfill: 'babel-polyfill',
vendor: ['babel-polyfill', ...vendor, ...react],
react: ['babel-polyfill', ...react],
redux: ['babel-polyfill', ...redux],
main: ['babel-polyfill', './src/client.js'],
utils: './src/components/index.js',
},
plugins: [
// Extract react from antd and vendor bundle
new webpack.optimize.CommonsChunkPlugin({
name: 'react',
chunks: ['react', 'redux', 'vendor'],
minChunks: 4,
}),
// Extract redux from antd and vendor bundle
new webpack.optimize.CommonsChunkPlugin({
name: 'redux',
chunks: ['redux', 'vendor'],
minChunks: 3,
}),
],
}

參數說明:

  • name: 要把共用代碼提取到在哪個 chunk
  • chunks: 從哪些 chunk 提取共用代碼
  • minChunks: 總共有幾個 chunk

在 entry 設定要打包的 chunk name 與要打包的模組,設定好後還要在 plugins 裡面用 CommonChunkPlugin 將重複的模組提取到指定的 chunk,否則打包完你會發現 vendor 和 redux 的 chunk 裡都有 react。

Async Load JavaScript

要怎麼載入那些打包檔案呢? HTML5 的 script tag 多了 asyncdefer 兩個屬性,可讓 script 以非同步的方式下載而不會停止網頁的繪製。

以下說明差異處

  • 綠色代表 HTML 繪製
  • 灰色代表暫停 HTML 繪製
  • 藍色代表下載 JavaScript
  • 紅色代表執行 JavaScript

script with no attribute

下載 JavaScript 時會中斷網頁的繪製,下載完成後會馬上 Script,等執行完後才會繼續繪製網頁

load script with no attribute

script with async attribute

下載 JavaScript 不會中斷網頁的繪製,下載完成後會先中斷網頁繪製並執行 Script,執行完成後再繼續繪製網頁

load script with async attribute

script with defer attribute

下載 JavaScript 不會中斷網頁的繪製,等網頁繪製完成後才會執行 Script

load script with defer attribute

該用哪個? 一般情況能用 async 就用 async,再來才是 defer,使用的時機點如下

  • 如果 script 沒有相依其他的 scripts 時用 async
  • 如果 script 有相依其他的 script 時用 defer

Optimizing React && Redux

這邊說明如何對你的 React 和 Redux 來進行優化

Enable production build

記得要在 webpack 的 DefinePlugin 中定義 NODE_ENV 參數為 production,可以大幅減少打包檔案大小和提升執行效能

1
2
3
4
5
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production'),
}
})

Make use of the shouldComponentUpdate method

針對效能較差的頁面,可以使用 React 推出的 Performance Tools 檢測,再針對效能較差的元件寫 shouldComponentUpdate 函式減少不必要的更新。也有 Chrome Extension ,把 Perf 指到 window 全域變數就可以使用

1
2
3
import Perf from 'react-addons-perf';
window.Perf = Perf;

另外在 React 15.4 中引入了新的效能檢測工具,可以方便與 Chrome DevTools 整合使用,簡化我們找尋效能問題的困難,使用操作可以參考 React 官方的介紹

react-perf-chrome-timeline

Batch Actions into a Single Dispatch

你應該把所有 dispatch action 寫在一起,並使用 redux-sagaall effect 來處理多個 API 的呼叫和 Action 的調用。

不要這樣子寫

1
2
3
4
5
componentDidMount() {
this.props.fetchCategories();
this.props.fetchUserCount();
this.props.fetchUserCount();
}

這樣寫會比較好

1
2
3
componentDidMount() {
this.props.fetch();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export function* fetch() {
const [
{ response: { data: { results: courses } } },
{ response: { data: banner } },
{ response: { data: usercount } },
] = yield all([
call(webApi.getCourses, post),
call(webApi.getAllBannersByUser),
call(webApi.countAllUsers),
]);
yield all([
put({
type: GET_COURSES_SUCCESS,
data: { results: setCoverPhoto(courses, payload.entities.categories) },
}),
categories.length === 0
? put({ type: GET_COURSE_CATEGORY_SUCCESS, result: payload })
: null
]);
}

Service-Worker

這邊就不說明 PWA 是什麼和怎麼建立 service-worker 了,有興趣可參考其他文章

  • 透過各種技術及設計的優化來達到應用程式的體驗,並保留網頁的優勢,藉此做到最好的使用者體驗 (User Experience)
  • PWA 介紹

可以通過在 webpack 中使用 sw-precache-webpack-pluginhtml-webpack-plugin 這兩個插件引入 service-worker 實現 PWA

CDN && Image Compression

  • 一定要壓縮圖片,如果有 CDN 就傳上去提升存取速度
  • 小於 10kb 的圖檔可轉成 base64 的編碼以減少 HTTP Request 數量

優化成果

對打包檔案進行優化後,初次載入的 JS 大小縮減了一半降為 500KB 左右,且分割出多個 chunk 並以非同步的方式載入網頁,加速載入速度。

拆成多個 Bundle 並減少體積

再重新對優化後的網站進行檢測

PageSpeed Insights

PageSpeed Insights 重新檢測結果

PageSpeed Insights 重新檢測結果

Lighthouse

Lighthouse 重新檢測結果

可以看到分數獲得了顯著的提升,而因為還有許多以前上傳在 AWS S3 的圖片尚未進行壓縮,因此 Insights 行動版和 Lighthouse Performance 分數會較低,目前主要的效能瓶頸卡在圖片,預計之後會再進行改善。

圖片太大導致載入時間過長