React 16 加載性能優化指南


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

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

零、基礎概念

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

請輸入圖片描述

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

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

一. 打開頁面 -> 首屏

請輸入圖片描述

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

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

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-achunk-bchunk-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.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.1. LazyLoad

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

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

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

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

4.2. placeholder

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

為了避免這種突然撐開的情況,我們要做的就是提前設置占位元素,也就是 placeholder:
請輸入圖片描述
已經有一些現成的第三方組件可以用了:

另外還可以參考 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 提升加載體驗。

本文參考鏈接:https://juejin.im/post/5b506ae0e51d45191a0d4ec9


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM