關於 React 應用加載的優化,其實網上類似的文章已經有太多太多了,隨便一搜就是一堆,已經成為了一個老生常談的問題。
但隨着 React 16 和 Webpack 4.0 的發布,很多過去的優化手段其實都或多或少有些“過時”了,而正好最近一段時間,公司的新項目遷移到了 React 16 和 Webpack 4.0,做了很多這方面的優化,所以就寫一篇文章來總結一下。
零、基礎概念
我們先要明確一次頁面加載過程是怎樣的(這里我們暫時不討論服務器端渲染的情況)。
- 用戶打開頁面,這個時候頁面是完全空白的;
- 然后 html 和引用的 css 加載完畢,瀏覽器進行首次渲染,我們把首次渲染需要加載的資源體積稱為 “首屏體積”;
- 然后 react、react-dom、業務代碼加載完畢,應用第一次渲染,或者說首次內容渲染;
- 應用的代碼開始執行,拉取數據、進行動態import、響應事件等等,完畢后頁面進入可交互狀態;
- 接下來 lazyload 的圖片等多媒體內容開始逐漸加載完畢;
- 然后直到頁面的其它資源(如錯誤上報組件、打點上報組件等)加載完畢,整個頁面的加載就結束了。
所以接下來,我們就分別討論這些步驟中,有哪些值得優化的點。
一. 打開頁面 -> 首屏
寫過 React 或者任何 SPA 的你,一定知道目前幾乎所有流行的前端框架(React、Vue、Angular),它們的應用啟動方式都是極其類似的:
- html 中提供一個 root 節點
<div id="root"></div>
- 把應用掛載到這個節點上
ReactDOM.render(
<App/>,
document.getElementById('root')
);
這樣的模式,使用 webpack 打包之后,一般就是三個文件:
- 一個體積很小、除了提供個 root 節點以外的沒什么卵用的html(大概 1-4 KB)
- 一個體積很大的 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 插件,原理大致如下:
- 指定 dist 目錄和要渲染的路徑
- 插件在 dist 目錄中開啟一個靜態服務器,並且使用無頭瀏覽器(puppeteer)訪問對應的路徑,執行 JS,抓取對應路徑的 html。
- 把抓到的內容寫入 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 代碼,都可以大致划分成以下幾個大塊:
- 基礎框架,如 React、Vue 等,這些基礎框架的代碼是不變的,除非升級框架;
- Polyfill,對於使用了 ES2015+ 語法的項目來說,為了兼容性,polyfill 是必要的存在;
- 業務基礎庫,業務的一些通用的基礎代碼,不屬於框架,但大部分業務都會使用到;
- 業務代碼,特點是具體業務自身的邏輯代碼。
想要優化這個時間段的性能,也就是要優化上面四種資源的加載速度。
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
設置過期的時間長度(秒),在這個時間范圍內,瀏覽器請求都會直接讀緩存。當 expires
和 cache-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:
- How to inject webpack 4 splited chunks. · Issue #882
- allow to specify regexp as included or excluded chunks by mike1808 · Pull Request #881
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》
五、總結
這篇文章里,我們一共提到了下面這些優化加載的點:
- 在 HTML 內實現 Loading 態或者骨架屏;
- 去掉外聯 css;
- 緩存基礎框架;
- 使用動態 polyfill;
- 使用 SplitChunksPlugin 拆分公共代碼;
- 正確地使用 Webpack 4.0 的 Tree Shaking;
- 使用動態 import,切分頁面代碼,減小首屏 JS 體積;
- 編譯到 ES2015+,提高代碼運行效率,減小體積;
- 使用 lazyload 和 placeholder 提升加載體驗。