React 16 加載性能優化指南

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

但隨着 React 16 和 Webpack 4.0 的發佈,不少過去的優化手段其實都或多或少有些「過期」了,而正好最近一段時間,公司的新項目遷移到了 React 16 和 Webpack 4.0,作了不少這方面的優化,因此就寫一篇文章來總結一下。html

零、基礎概念

咱們先要明確一次頁面加載過程是怎樣的(這裏咱們暫時不討論服務器端渲染的狀況)。前端

請輸入圖片描述

  1. 用戶打開頁面,這個時候頁面是徹底空白的;
  2. 而後 html 和引用的 css 加載完畢,瀏覽器進行首次渲染,咱們把首次渲染須要加載的資源體積稱爲 「首屏體積」
  3. 而後 react、react-dom、業務代碼加載完畢,應用第一次渲染,或者說首次內容渲染
  4. 應用的代碼開始執行,拉取數據、進行動態import、響應事件等等,完畢後頁面進入可交互狀態;
  5. 接下來 lazyload 的圖片等多媒體內容開始逐漸加載完畢;
  6. 而後直到頁面的其它資源(如錯誤上報組件、打點上報組件等)加載完畢,整個頁面的加載就結束了。

因此接下來,咱們就分別討論這些步驟中,有哪些值得優化的點。react

一. 打開頁面 -> 首屏

請輸入圖片描述

寫過 React 或者任何 SPA 的你,必定知道目前幾乎全部流行的前端框架(React、Vue、Angular),它們的應用啓動方式都是極其相似的:webpack

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

這樣的模式,使用 webpack 打包以後,通常就是三個文件:git

  1. 一個體積很小、除了提供個 root 節點之外的沒什麼卵用的html(大概 1-4 KB)
  2. 一個體積很大的 js(50 - 1000 KB 不等)
    3一個 css 文件(固然若是你把 css 打進 js 裏了,也可能沒有)

這樣形成的直接後果就是,用戶在 50 - 1000 KB 的 js 文件加載、執行完畢以前,頁面是 完!全!空!白!的!。github

也就是說,這個時候:web

首屏體積(首次渲染須要加載的資源體積) = html + js + cssexpress

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

咱們徹底能夠把首屏渲染的時間點提早,好比在你的 root 節點中寫一點東西:json

<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,這樣即便沒有作服務器端渲染,也能達到跟服務器端渲染幾乎相同的做用(不考慮動態數據的話)

具體如何使用,能夠參考這一篇文章

lugins: [
  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 的模式帶來的好處遠大於這麼一丁點缺點。

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

請輸入圖片描述

這一段過程當中,瀏覽器主要在作的事情就是加載、運行 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% 用戶的加載體積。
請輸入圖片描述

因此這裏的解決方法就是,去掉構建中靜態的 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…,你會發現內容幾乎是空的:
請輸入圖片描述

若是打開控制檯,模擬 iOS 的 Safari,再訪問一次,你會發現裏面就出現了一些 polyfill(URL 對象的 polyfill):

請輸入圖片描述

這就是 polyfill.io 的原理,它會根據你的瀏覽器 UA 頭,判斷你是否支持某些特性,從而返回給你一個合適的 polyfill。對於最新的 Chrome 瀏覽器來講,不須要任何 polyfill,因此返回的內容爲空。對於 iOS Safari 來講,須要 URL 對象的 polyfill,因此返回了對應的資源。
請輸入圖片描述

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'));

3、首次內容渲染 -> 可交互

請輸入圖片描述
這一段過程當中,瀏覽器主要在作的事情就是加載及初始化各項組件

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
下面是一個具體的例子:
請輸入圖片描述
以這個用戶主頁爲例,起碼有三處組件是不須要首次加載的,而是使用動態加載:標題欄、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 語法的:
請輸入圖片描述

其它 ES2015 的特性也是一樣的狀況。

也就是說,在當下 2018 年,對於大部分用戶而言,咱們根本不須要把代碼編譯到 ES5,不只體積大,並且運行速度慢。咱們須要作的,就是把代碼編譯到 ES2015+,而後爲少數使用老舊瀏覽器的用戶保留一個 ES5 標準的備胎便可。

具體的解決方法就是 <script type="module"> 標籤。
支持 <script type="module"> 的瀏覽器,必然支持下面的特性:

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

而不支持 <script type="module"> 的老舊瀏覽器,會由於沒法識別這個標籤,而不去加載 ES2015+ 的代碼。另外老舊的瀏覽器一樣沒法識別 nomodule 熟悉,會自動忽略它,從而加載 ES5 標準的代碼。

簡單地概括爲下圖:
請輸入圖片描述

根據這篇文章,打包後的體積和運行效率都獲得了顯著提升

4、可交互 -> 內容加載完畢

請輸入圖片描述
這個階段就很簡單了,主要是各類多媒體內容的加載

4.1. LazyLoad

懶加載其實沒什麼好說的,目前也有一些比較成熟的組件了,本身實現一個也不是特別難:

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

實際上目前幾乎全部 lazyload 組件都不外乎如下兩種原理:

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

4.2. placeholder

咱們在加載文本、圖片的時候,常常出現「閃屏」的狀況,好比圖片或者文字尚未加載完畢,此時頁面上對應的位置仍是徹底空着的,而後加載完畢,內容會忽然撐開頁面,致使「閃屏」的出現,形成很差的體驗。

爲了不這種忽然撐開的狀況,咱們要作的就是提早設置佔位元素,也就是 placeholder:
請輸入圖片描述
已經有一些現成的第三方組件能夠用了:

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

5、總結

這篇文章裏,咱們一共提到了下面這些優化加載的點:

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

本文參考連接:http://www.javashuo.com/article/p-uacupgyu-v.html

相關文章
相關標籤/搜索