React 16 載入效能優化指南

騰訊IVWEB團隊發表於2018-07-19

關於 React 應用載入的優化,其實網上類似的文章已經有太多太多了,隨便一搜就是一堆,已經成為了一個老生常談的問題。

但隨著 React 16 和 Webpack 4.0 的釋出,很多過去的優化手段其實都或多或少有些“過時”了,而正好最近一段時間,公司的新專案遷移到了 React 16 和 Webpack 4.0,做了很多這方面的優化,所以就寫一篇文章來總結一下。

零、基礎概念

我們先要明確一次頁面載入過程是怎樣的(這裡我們暫時不討論伺服器端渲染的情況)。

React 16 載入效能優化指南

  1. 使用者開啟頁面,這個時候頁面是完全空白的;
  2. 然後 html 和引用的 css 載入完畢,瀏覽器進行首次渲染,我們把首次渲染需要載入的資源體積稱為 “首屏體積”
  3. 然後 react、react-dom、業務程式碼載入完畢,應用第一次渲染,或者說首次內容渲染
  4. 然後應用的程式碼開始執行,拉取資料、進行動態import、響應事件等等,完畢後頁面進入可互動狀態;
  5. 接下來 lazyload 的圖片等多媒體內容開始逐漸載入完畢;
  6. 然後直到頁面的其它資源(如錯誤上報元件、打點上報元件等)載入完畢,整個頁面的載入就結束了。

所以接下來,我們就分別討論這些步驟中,有哪些值得優化的點。


一. 開啟頁面 -> 首屏

React 16 載入效能優化指南

寫過 React 或者任何 SPA 的你,一定知道目前幾乎所有流行的前端框架(React、Vue、Angular),它們的應用啟動方式都是極其類似的:

  1. html 中提供一個 root 節點
<div id="root"></div>
複製程式碼
  1. 把應用掛載到這個節點上
ReactDOM.render(
  <App/>,
  document.getElementById('root')
);
複製程式碼

這樣的模式,使用 webpack 打包之後,一般就是三個檔案:

  1. 一個體積很小、除了提供個 root 節點以外的沒什麼卵用的html(大概 1-4 KB
  2. 一個體積很大的 js(50 - 1000 KB 不等
  3. 一個 css 檔案(當然如果你把 css 打進 js 裡了,也可能沒有)

這樣造成的直接後果就是,使用者在 50 - 1000 KB 的 js 檔案載入、執行完畢之前,頁面是 完!全!空!白!的!

也就是說,這個時候:

首屏體積(首次渲染需要載入的資源體積) = html + js + css
複製程式碼

1.1. 在 root 節點中寫一些東西

我們完全可以把首屏渲染的時間點提前,比如在你的 root 節點中寫一點東西:

<div class="root">Loading...</div>
複製程式碼

就是這麼簡單,就可以把你應用的首屏時間提前到 html、css 載入完畢

此時:

首屏體積 = html + css
複製程式碼

當然一行沒有樣式的 "Loading..." 文字可能會讓設計師想揍你一頓,為了避免被揍,我們可以在把 root 節點內的內容畫得好看一些:

<div id="root">
    <!-- 這裡畫一個 SVG -->
</div>
複製程式碼

1.2. 使用 html-webpack-plugin 自動插入 loading

實際業務中肯定是有很多很多頁面的,每個頁面都要我們手動地複製貼上這麼一個 loading 態顯然太不優雅了,這時我們可以考慮使用 html-webpack-plugin 來幫助我們自動插入 loading。

var HtmlWebpackPlugin = require('html-webpack-plugin');
var path = require('path');

// 讀取寫好的 loading 態的 html 和 css
var loading = {
    html: fs.readFileSync(path.join(__dirname, './loading.html')),
    css: '<style>' + fs.readFileSync(path.join(__dirname, './loading.css')) + '</style>'
}

var webpackConfig = {
  entry: 'index.js',
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'index_bundle.js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'xxxx.html',
      template: 'template.html',
      loading: loading
    })
  ]
};
複製程式碼

然後在模板中引用即可:

<!DOCTYPE html>
<html lang="en">
    <head>
        <%= htmlWebpackPlugin.options.loading.css %>
    </head>

    <body>
        <div id="root">
            <%= htmlWebpackPlugin.options.loading.html %>
        </div>
    </body>
</html>
複製程式碼

1.3. 使用 prerender-spa-plugin 渲染首屏

在一些比較大型的專案中,Loading 可能本身就是一個 React/Vue 元件,在不做伺服器端渲染的情況下,想把一個已經元件化的 Loading 直接寫入 html 檔案中會很複雜,不過依然有解決辦法。

prerender-spa-plugin 是一個可以幫你在構建時就生成頁面首屏 html 的一個 webpack 外掛,原理大致如下:

  1. 指定 dist 目錄和要渲染的路徑
  2. 外掛在 dist 目錄中開啟一個靜態伺服器,並且使用無頭瀏覽器(puppeteer)訪問對應的路徑,執行 JS,抓取對應路徑的 html。
  3. 把抓到的內容寫入 html,這樣即使沒有做伺服器端渲染,也能達到跟伺服器端渲染幾乎相同的作用(不考慮動態資料的話)

具體如何使用,可以參考這一篇文章

plugins: [
  new PrerenderSpaPlugin(
    path.join(__dirname, 'dist'),
    [ '/', '/products/1', '/products/2', '/products/3']
  )
]
複製程式碼

1.4. 除掉外鏈 css

截止到目前,我們的首屏體積 = html + css,依然有優化的空間,那就是把外鏈的 css 去掉,讓瀏覽器在載入完 html 時,即可渲染首屏。

實際上,webpack 預設就是沒有外鏈 css 的,你什麼都不需要做就可以了。當然如果你的專案之前配置了 extract-text-webpack-plugin 或者 mini-css-extract-plugin 來生成獨立的 css 檔案,直接去掉即可。

有人可能要質疑,把 css 打入 js 包裡,會丟失瀏覽器很多快取的好處(比如你只改了 js 程式碼,導致構建出的 js 內容變化,但連帶 css 都要一起重新載入一次),這樣做真的值得嗎?

確實這麼做會讓 css 無法快取,但實際上對於現在成熟的前端應用來說,快取不應該在 js/css 這個維度上區分,而是應該按照“元件”區分,即配合動態 import 快取元件。

接下來你會看到,css in js 的模式帶來的好處遠大於這麼一丁點缺點。


二. 首屏 -> 首次內容渲染

React 16 載入效能優化指南

這一段過程中,瀏覽器主要在做的事情就是載入、執行 JS 程式碼,所以如何提升 JS 程式碼的載入、執行效能,就成為了優化的關鍵。

幾乎所有業務的 JS 程式碼,都可以大致劃分成以下幾個大塊:

  1. 基礎框架,如 React、Vue 等,這些基礎框架的程式碼是不變的,除非升級框架;
  2. Polyfill,對於使用了 ES2015+ 語法的專案來說,為了相容性,polyfill 是必要的存在;
  3. 業務基礎庫,業務的一些通用的基礎程式碼,不屬於框架,但大部分業務都會使用到;
  4. 業務程式碼,特點是具體業務自身的邏輯程式碼。

想要優化這個時間段的效能,也就是要優化上面四種資源的載入速度。


2.1. 快取基礎框架

基礎框架程式碼的特點就是必需不變,是一種非常適合快取的內容。

所以我們需要做的就是為基礎框架程式碼設定一個儘量長的快取時間,使使用者的瀏覽器儘量通過快取載入這些資源。

附:HTTP 快取資源小結

HTTP 為我們提供了很好幾種快取的解決方案,不妨總結一下:

1. expires

expires: Thu, 16 May 2019 03:05:59 GMT
複製程式碼

在 http 頭中設定一個過期時間,在這個過期時間之前,瀏覽器的請求都不會發出,而是自動從快取中讀取檔案,除非快取被清空,或者強制重新整理。缺陷在於,伺服器時間和使用者端時間可能存在不一致,所以 HTTP/1.1 加入了 cache-control 頭來改進這個問題。

2. cache-control

cache-control: max-age=31536000
複製程式碼

設定過期的時間長度(秒),在這個時間範圍內,瀏覽器請求都會直接讀快取。當 expirescache-control 都存在時,cache-control 的優先順序更高。

3. last-modified / if-modified-since

這是一組請求/相應頭

響應頭:

last-modified: Wed, 16 May 2018 02:57:16 GMT
複製程式碼

請求頭:

if-modified-since: Wed, 16 May 2018 05:55:38 GMT
複製程式碼

伺服器端返回資源時,如果頭部帶上了 last-modified,那麼資源下次請求時就會把值加入到請求頭 if-modified-since 中,伺服器可以對比這個值,確定資源是否發生變化,如果沒有發生變化,則返回 304。

4. etag / if-none-match

這也是一組請求/相應頭

響應頭:

etag: "D5FC8B85A045FF720547BC36FC872550"
複製程式碼

請求頭:

if-none-match: "D5FC8B85A045FF720547BC36FC872550"
複製程式碼

原理類似,伺服器端返回資源時,如果頭部帶上了 etag,那麼資源下次請求時就會把值加入到請求頭 if-none-match 中,伺服器可以對比這個值,確定資源是否發生變化,如果沒有發生變化,則返回 304。

上面四種快取的優先順序:cache-control > expires > etag > last-modified


2.2. 使用動態 polyfill

Polyfill 的特點是非必需不變,因為對於一臺手機來說,需要哪些 polyfill 是固定的,當然也可能完全不需要 polyfill。

現在為了瀏覽器的相容性,我們常常引入各種 polyfill,但是在構建時靜態地引入 polyfill 存在一些問題,比如對於機型和瀏覽器版本比較新的使用者來說,他們完全不需要 polyfill,引入 polyfill 對於這部分使用者來說是多餘的,從而造成體積變大和效能損失。

比如 React 16 的程式碼中依賴了 ES6 的 Map/Set 物件,使用時需要你自己加入 polyfill,但目前幾個完備的 Map/Set 的 polyfill 體積都比較大,打包進來會增大很多體積。

還比如 Promise 物件,實際上根據 caniuse.com 的資料,移動端上,中國接近 94% 的使用者瀏覽器,都是原生支援 Promise 的,並不需要 polyfill。但實際上我們打包時還是會打包 Promise 的 polyfill,也就是說,我們為了 6% 的使用者相容性,增大了 94% 使用者的載入體積。

React 16 載入效能優化指南

所以這裡的解決方法就是,去掉構建中靜態的 polyfill,換而使用 polyfill.io 這樣的動態 polyfill 服務,保證只有在需要時,才會引入 polyfill。

具體的使用方法非常簡單,只需要外鏈一個 js:

<script src="https://cdn.polyfill.io/v2/polyfill.min.js"></script>
複製程式碼

當然這樣是載入全部的 polyfill,實際上你可能並不需要這麼多,比如你只需要 Map/Set 的話:

<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=Map,Set"></script>
複製程式碼

動態 polyfill 的原理

如果你用最新的 Chrome 瀏覽器訪問這個連結的話:cdn.polyfill.io/v2/polyfill…,你會發現內容幾乎是空的:

React 16 載入效能優化指南

如果開啟控制檯,模擬 iOS 的 Safari,再訪問一次,你會發現裡面就出現了一些 polyfill(URL 物件的 polyfill):

React 16 載入效能優化指南

這就是 polyfill.io 的原理,它會根據你的瀏覽器 UA 頭,判斷你是否支援某些特性,從而返回給你一個合適的 polyfill。對於最新的 Chrome 瀏覽器來說,不需要任何 polyfill,所以返回的內容為空。對於 iOS Safari 來說,需要 URL 物件的 polyfill,所以返回了對應的資源。

React 16 載入效能優化指南


2.3. 使用 SplitChunksPlugin 自動拆分業務基礎庫

Webpack 4 拋棄了原有的 CommonChunksPlugin,換成了更為先進的 SplitChunksPlugin,用於提取公用程式碼。

它們的區別就在於,CommonChunksPlugin 會找到多數模組中都共有的東西,並且把它提取出來(common.js),也就意味著如果你載入了 common.js,那麼裡面可能會存在一些當前模組不需要的東西。

而 SplitChunksPlugin 採用了完全不同的 heuristics 方法,它會根據模組之間的依賴關係,自動打包出很多很多(而不是單個)通用模組,可以保證載入進來的程式碼一定是會被依賴到的。

下面是一個簡單的例子,假設我們有 4 個 chunk,分別依賴了以下模組:

chunk 依賴模組
chunk-a react, react-dom, componentA, utils
chunk-b react, react-dom, componentB, utils
chunk-c angular, componentC, utils
chunk-d angular, componentD, utils

如果是以前的 CommonChunksPlugin,那麼預設配置會把它們打包成下面這樣:

包名 包含的模組
common utils
chunk-a react, react-dom, componentA
chunk-b react, react-dom, componentB
chunk-c angular, componentC
chunk-d angular, componentD

顯然在這裡,react、react-dom、angular 這些公用的模組沒有被抽出成為獨立的包,存在進一步優化的空間。

現在,新的 SplitChunksPlugin 會把它們打包成以下幾個包:

包名 包含的模組
chunk-a~chunk-b~chunk-c~chunk-d utils
chunk-a~chunk-b react, react-dom
chunk-c~chunk-d angular
chunk-a componentA
chunk-b componentB
chunk-c componentC
chunk-d componentD

這就保證了所有公用的模組,都會被抽出成為獨立的包,幾乎完全避免了多頁應用中,重複載入相同模組的問題。

具體如何配置 SplitChunksPlugin,請參考 webpack 官方文件

注:目前使用 SplitChunksPlugin 存在的坑

雖然 webpack 4.0 提供的 SplitChunksPlugin 非常好用,但截止到寫這篇文章的時候(2018年5月),依然存在一個坑,那就是 html-webpack-plugin 還不完全支援 SplitChunksPlugin,生成的公用模組包還無法自動注入到 html 中。

可以參考下面的 issue 或者 PR:

2.4. 正確使用 Tree Shaking 減少業務程式碼體積

Tree Shaking 這已經是一個很久很久以前就存在的 webpack 特性了,老生常談,但事實上不是所有的人(特別是對 webpack 不瞭解的人)都正確地使用了它,所以我今天要在這裡囉嗦地再寫一遍。

例如,我們有下面這樣一個使用了 ES Module 標準的模組:

// math.js
export function square(x) {
  return x * x
}

export function cube(x) {
  return x * x * x
}
複製程式碼

然後你在另一個模組中引用了它:

// index.js
import { cube } from './math'
cube(123)
複製程式碼

經過 webpack 打包之後,math.js 會變成下面這樣:

/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
/* unused harmony export square */
/* harmony export (immutable) */ __webpack_exports__["a"] = cube;
function square(x) {
  return x * x;
}

function cube(x) {
  return x * x * x;
}
複製程式碼

注意這裡 square 函式依然存在,但多了一行 magic comment:unused harmony export square

隨後的壓縮程式碼的 uglifyJS 就會識別到這行 magic comment,並且把 square 函式丟棄。

但是一定要注意!!! webpack 2.0 開始原生支援 ES Module,也就是說不需要 babel 把 ES Module 轉換成曾經的 commonjs 模組了,想用上 Tree Shaking,請務必關閉 babel 預設的模組轉義:

{
  "presets": [
    ["env", {
      "modules": false
      }
    }]
  ]
}
複製程式碼

另外,Webpack 4.0 開始,Tree Shaking 對於那些無副作用的模組也會生效了。

如果你的一個模組在 package.json 中說明了這個模組沒有副作用(也就是說執行其中的程式碼不會對環境有任何影響,例如只是宣告瞭一些函式和常量):

{
  "name": "your-module",
  "sideEffects": false
}
複製程式碼

那麼在引入這個模組,卻沒有使用它時,webpack 會自動把它 Tree Shaking 丟掉:

import yourModule from 'your-module'
// 下面沒有用到 yourModule
複製程式碼

這一點對於 lodash、underscore 這樣的工具庫來說尤其重要,開啟了這個特性之後,你現在可以無心理負擔地這樣寫了:

import { capitalize } from 'lodash-es';
document.write(capitalize('yo'));
複製程式碼

三、首次內容渲染 -> 可互動

React 16 載入效能優化指南

這一段過程中,瀏覽器主要在做的事情就是載入及初始化各項元件

3.1. Code Splitting

大多數打包器(比如 webpack、rollup、browserify)的作用就是把你的頁面程式碼打包成一個很大的 “bundle”,所有的程式碼都會在這個 bundle 中。但是,隨著應用的複雜度日益提高,bundle 的體積也會越來越大,載入 bundle 的時間也會變長,這就對載入過程中的使用者體驗造成了很大的負面影響。

為了避免打出過大的 bundle,我們要做的就是切分程式碼,也就是 Code Splitting,目前幾乎所有的打包器都原生支援這個特性。

Code Splitting 可以幫你“懶載入”程式碼,以提高使用者的載入體驗,如果你沒辦法直接減少應用的體積,那麼不妨嘗試把應用從單個 bundle 拆分成單個 bundle + 多份動態程式碼的形式。

比如我們可以把下面這種形式:

import { add } from './math';
console.log(add(16, 26));
複製程式碼

改寫成動態 import 的形式,讓首次載入時不去載入 math 模組,從而減少首次載入資源的體積。

import("./math").then(math => {
  console.log(math.add(16, 26));
});
複製程式碼

React Loadable 是一個專門用於動態 import 的 React 高階元件,你可以把任何元件改寫為支援動態 import 的形式。

import Loadable from 'react-loadable';
import Loading from './loading-component';

const LoadableComponent = Loadable({
  loader: () => import('./my-component'),
  loading: Loading,
});

export default class App extends React.Component {
  render() {
    return <LoadableComponent/>;
  }
}
複製程式碼

上面的程式碼在首次載入時,會先展示一個 loading-component,然後動態載入 my-component 的程式碼,元件程式碼載入完畢之後,便會替換掉 loading-component

下面是一個具體的例子:

React 16 載入效能優化指南

以這個使用者主頁為例,起碼有三處元件是不需要首次載入的,而是使用動態載入:標題欄、Tab 欄、列表。首次載入實際上只需要載入中心區域的使用者頭像、暱稱、ID即可。切分之後,首屏 js 體積從 40KB 縮減到了 20KB.

3.2. 編譯到 ES2015+ ,提升程式碼執行效率

相關文章:《Deploying ES2015+ Code in Production Today》

如今大多數專案的做法都是,編寫 ES2015+ 標準的程式碼,然後在構建時編譯到 ES5 標準執行。

比如一段非常簡潔的 class 語法:

class Foo extends Bar {
    constructor(x) {
        super()
        this.x = x;
    }
}
複製程式碼

會被編譯成這樣:

"use strict";

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }

function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }

var Foo = function (_Bar) {
  _inherits(Foo, _Bar);

  function Foo(x) {
    _classCallCheck(this, Foo);

    var _this = _possibleConstructorReturn(this, (Foo.__proto__ || Object.getPrototypeOf(Foo)).call(this));

    _this.x = x;
    return _this;
  }

  return Foo;
}(Bar);
複製程式碼

但實際上,大部分現代瀏覽器已經原生支援 class 語法,比如 iOS Safari 從 2015 年的 iOS 9.0 開始就支援了,根據 caniuse 的資料,目前移動端上 90% 使用者的瀏覽器都是原生支援 class 語法的:

React 16 載入效能優化指南

其它 ES2015 的特性也是同樣的情況。

也就是說,在當下 2018 年,對於大部分使用者而言,我們根本不需要把程式碼編譯到 ES5,不僅體積大,而且執行速度慢。我們需要做的,就是把程式碼編譯到 ES2015+,然後為少數使用老舊瀏覽器的使用者保留一個 ES5 標準的備胎即可。

具體的解決方法就是 <script type="module"> 標籤。

支援 <script type="module"> 的瀏覽器,必然支援下面的特性:

  • async/await
  • Promise
  • Class
  • 箭頭函式、Map/Set、fetch 等等...

而不支援 <script type="module"> 的老舊瀏覽器,會因為無法識別這個標籤,而不去載入 ES2015+ 的程式碼。另外老舊的瀏覽器同樣無法識別 nomodule 熟悉,會自動忽略它,從而載入 ES5 標準的程式碼。

簡單地歸納為下圖:

React 16 載入效能優化指南

根據這篇文章,打包後的體積和執行效率都得到了顯著提高:

React 16 載入效能優化指南

四、可互動 -> 內容載入完畢

React 16 載入效能優化指南

這個階段就很簡單了,主要是各種多媒體內容的載入

4.1. LazyLoad

懶載入其實沒什麼好說的,目前也有一些比較成熟的元件了,自己實現一個也不是特別難:

當然你也可以實現像 Medium 的那種載入體驗(好像知乎已經是這樣了),即先載入一張低畫素的模糊圖片,然後等真實圖片載入完畢之後,再替換掉。

實際上目前幾乎所有 lazyload 元件都不外乎以下兩種原理:

  • 監聽 window 物件或者父級物件的 scroll 事件,觸發 load;
  • 使用 Intersection Observer API 來獲取元素的可見性。

4.2. placeholder

我們在載入文字、圖片的時候,經常出現“閃屏”的情況,比如圖片或者文字還沒有載入完畢,此時頁面上對應的位置還是完全空著的,然後載入完畢,內容會突然撐開頁面,導致“閃屏”的出現,造成不好的體驗。

為了避免這種突然撐開的情況,我們要做的就是提前設定佔位元素,也就是 placeholder:

React 16 載入效能優化指南

已經有一些現成的第三方元件可以用了:

另外還可以參考 Facebook 的這篇文章:《How the Facebook content placeholder works》

五、總結

這篇文章裡,我們一共提到了下面這些優化載入的點:

  1. 在 HTML 內實現 Loading 態或者骨架屏;
  2. 去掉外聯 css;
  3. 快取基礎框架;
  4. 使用動態 polyfill;
  5. 使用 SplitChunksPlugin 拆分公共程式碼;
  6. 正確地使用 Webpack 4.0 的 Tree Shaking;
  7. 使用動態 import,切分頁面程式碼,減小首屏 JS 體積;
  8. 編譯到 ES2015+,提高程式碼執行效率,減小體積;
  9. 使用 lazyload 和 placeholder 提升載入體驗。

實際上可優化的點還遠不止這些,這裡推薦一些相關資源給大家閱讀:

希望這篇文章,能拯救你下半年的 KPI :)


《IVWEB 技術週刊》 震撼上線了,關注公眾號:IVWEB社群,每週定時推送優質文章。

相關文章