關於 React 應用加載的優化,其實網上相似的文章已經有太多太多了,隨便一搜就是一堆,已經成爲了一個老生常談的問題。css
但隨着 React 16 和 Webpack 4.0 的發佈,不少過去的優化手段其實都或多或少有些「過期」了,而正好最近一段時間,公司的新項目遷移到了 React 16 和 Webpack 4.0,作了不少這方面的優化,因此就寫一篇文章來總結一下。html
咱們先要明確一次頁面加載過程是怎樣的(這裏咱們暫時不討論服務器端渲染的狀況)。前端
因此接下來,咱們就分別討論這些步驟中,有哪些值得優化的點。react
寫過 React 或者任何 SPA 的你,必定知道目前幾乎全部流行的前端框架(React、Vue、Angular),它們的應用啓動方式都是極其相似的:webpack
<div id="root"></div>
ReactDOM.render( <App/>, document.getElementById('root') );
這樣的模式,使用 webpack 打包以後,通常就是三個文件:git
這樣形成的直接後果就是,用戶在 50 - 1000 KB 的 js 文件加載、執行完畢以前,頁面是 完!全!空!白!的!。github
也就是說,這個時候:web
首屏體積(首次渲染須要加載的資源體積) = html + js + cssexpress
咱們徹底能夠把首屏渲染的時間點提早,好比在你的 root 節點中寫一點東西:json
<div class="root">Loading...</div>
就是這麼簡單,就能夠把你應用的首屏時間提早到 html、css 加載完畢
此時:
首屏體積 = html + css
固然一行沒有樣式的 "Loading..." 文本可能會讓設計師想揍你一頓,爲了不被揍,咱們能夠在把 root 節點內的內容畫得好看一些:
<div id="root"> <!-- 這裏畫一個 SVG --> </div>
實際業務中確定是有不少不少頁面的,每一個頁面都要咱們手動地複製粘貼這麼一個 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>
在一些比較大型的項目中,Loading 可能自己就是一個 React/Vue 組件,在不作服務器端渲染的狀況下,想把一個已經組件化的 Loading 直接寫入 html 文件中會很複雜,不過依然有解決辦法。
prerender-spa-plugin 是一個能夠幫你在構建時就生成頁面首屏 html 的一個 webpack 插件,原理大體以下:
具體如何使用,能夠參考這一篇文章
lugins: [ new PrerenderSpaPlugin( path.join(__dirname, 'dist'), [ '/', '/products/1', '/products/2', '/products/3'] ) ]
截止到目前,咱們的首屏體積 = 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 代碼,均可以大體劃分紅如下幾個大塊:
想要優化這個時間段的性能,也就是要優化上面四種資源的加載速度。
基礎框架代碼的特色就是必需且不變,是一種很是適合緩存的內容。
因此咱們須要作的就是爲基礎框架代碼設置一個儘可能長的緩存時間,使用戶的瀏覽器儘可能經過緩存加載這些資源。
HTTP 爲咱們提供了很好幾種緩存的解決方案,不妨總結一下:
expires: Thu, 16 May 2019 03:05:59 GMT
在 http 頭中設置一個過時時間,在這個過時時間以前,瀏覽器的請求都不會發出,而是自動從緩存中讀取文件,除非緩存被清空,或者強制刷新。缺陷在於,服務器時間和用戶端時間可能存在不一致,因此 HTTP/1.1 加入了 cache-control
頭來改進這個問題。
cache-control: max-age=31536000
設置過時的時間長度(秒),在這個時間範圍內,瀏覽器請求都會直接讀緩存。當 expires
和 cache-control
都存在時,cache-control
的優先級更高。
這是一組請求/相應頭
響應頭:
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。
這也是一組請求/相應頭
響應頭:
etag: "D5FC8B85A045FF720547BC36FC872550"
請求頭:
if-none-match: "D5FC8B85A045FF720547BC36FC872550"
原理相似,服務器端返回資源時,若是頭部帶上了 etag
,那麼資源下次請求時就會把值加入到請求頭 if-none-match
中,服務器能夠對比這個值,肯定資源是否發生變化,若是沒有發生變化,則返回 304。
上面四種緩存的優先級:cache-control > expires > etag > last-modified
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>
若是你用最新的 Chrome 瀏覽器訪問這個連接的話:cdn.polyfill.io/v2/polyfill…,你會發現內容幾乎是空的:
若是打開控制檯,模擬 iOS 的 Safari,再訪問一次,你會發現裏面就出現了一些 polyfill(URL 對象的 polyfill):
這就是 polyfill.io 的原理,它會根據你的瀏覽器 UA 頭,判斷你是否支持某些特性,從而返回給你一個合適的 polyfill。對於最新的 Chrome 瀏覽器來講,不須要任何 polyfill,因此返回的內容爲空。對於 iOS Safari 來講,須要 URL 對象的 polyfill,因此返回了對應的資源。
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 官方文檔。
雖然 webpack 4.0 提供的 SplitChunksPlugin 很是好用,但截止到寫這篇文章的時候(2018年5月),依然存在一個坑,那就是 html-webpack-plugin 還不徹底支持 SplitChunksPlugin,生成的公用模塊包還沒法自動注入到 html 中。
能夠參考下面的 issue 或者 PR:
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'));
這一段過程當中,瀏覽器主要在作的事情就是加載及初始化各項組件
大多數打包器(好比 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.
相關文章:《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">
的瀏覽器,必然支持下面的特性:
而不支持 <script type="module">
的老舊瀏覽器,會由於沒法識別這個標籤,而不去加載 ES2015+ 的代碼。另外老舊的瀏覽器一樣沒法識別 nomodule
熟悉,會自動忽略它,從而加載 ES5 標準的代碼。
簡單地概括爲下圖:
根據這篇文章,打包後的體積和運行效率都獲得了顯著提升
這個階段就很簡單了,主要是各類多媒體內容的加載
懶加載其實沒什麼好說的,目前也有一些比較成熟的組件了,本身實現一個也不是特別難:
固然你也能夠實現像 Medium 的那種加載體驗(好像知乎已是這樣了),即先加載一張低像素的模糊圖片,而後等真實圖片加載完畢以後,再替換掉。
實際上目前幾乎全部 lazyload 組件都不外乎如下兩種原理:
咱們在加載文本、圖片的時候,常常出現「閃屏」的狀況,好比圖片或者文字尚未加載完畢,此時頁面上對應的位置仍是徹底空着的,而後加載完畢,內容會忽然撐開頁面,致使「閃屏」的出現,形成很差的體驗。
爲了不這種忽然撐開的狀況,咱們要作的就是提早設置佔位元素,也就是 placeholder:
已經有一些現成的第三方組件能夠用了:
另外還能夠參考 Facebook 的這篇文章:《How the Facebook content placeholder works》
這篇文章裏,咱們一共提到了下面這些優化加載的點: