背景
前段時間公司產品為了拉新活動,仿照Facebook的Creators頁面決定製作一套自己的HelpCenter頁面。
Facebook的Creators頁面其實大體上只有兩個功能點:
- 卡片移入和移出時的動畫效果
- 左側選單欄跟隨當前卡片變換的效果
這個頁面的重點在於每張卡片都需要獨立計算位置以及獨立進行動畫,因此對效能要求很高。
初版
第一個版本是在實習的時候寫的,因為要趕時間配合其他組同時上線,因此在整體上沒有考慮效能問題。
整體的思路就是將transform屬性和transform-origin屬性存於每一張卡片的state中,並給每一張卡片繫結一個scroll事件監聽,每次卡片移動都對在視口中的卡片進行位置檢測,如果該卡片的當前位置需要進行動畫的話,則直接修改state中的屬性重新給dom賦值即可。
左側選單欄的變化則是通過在每張卡片移動到螢幕對應位置的時候,根據卡片自帶的hash值修改選單欄state中的hash實現的。
初版完成了這個頁面最基礎的功能,使之可用,但是由於多個scroll事件的監聽(多達40+),以及每次計算所有卡片的位置,都造成了嚴重的效能問題,導致頁面滾動時的幀數經常掉到30幀左右,一旦遇到大一些的卡片甚至會產生卡幀的情況,整個頁面的執行時間有70%左右的時間在執行js,而facebook的頁面僅僅只有不到20%的時間在執行js。
因此優化的工作迫在眉睫。
減少計算工作量
第一次優化的目標是不影響正常使用者體驗的情況下,減少監聽的事件數量,以及減少對卡片位置的計算。
事件部分的優化思路是:刪除每一個卡片自身的scroll事件監聽,在父級元件用一個scroll事件監聽所有卡片位置,每次滾動的時候都遍歷計算所有卡片的位置資訊,不在動畫區域內的卡片直接跳過,在動畫區域內的卡片才進行動畫計算。但是優化之後的方法在效率上提升不大,因此猜測大部分時間都消耗在了計算上面。
因此對卡片位置的計算進行了優化。以前計算卡片位置的時候,都是重新根據getBoundingClientRect
方法獲取到的位置資訊,再根據當前視口的寬高資訊來計算結果,本身getBoundingClientRect
方法就比較耗時間,並且每次滾動都要遍歷每一張卡片,效率極低,雖然嘗試過通過截流了方式進行滾動,但是一旦經過截流處理,卡片的動畫就變的極為不流暢,因此並沒有採用截流的方式。
後來我將每一張卡片的初始位置儲存起來,每次滾動的時候不再需要重新計算每一張卡片的位置資訊,只需要根據滾動的距離更新儲存的資料,然後遍歷一個最大隻有40+的陣列進行位置判斷即可,這樣就節省了大量的在獲取位置部分浪費的時間。
經過兩步優化之後的卡片動畫比初版好了很多,幀數最多能達到50幀,執行時間也減少到40-50%左右,但是依然和facebook有很大的差距。
不使用setState
減少計算之後,下一個目標就是setState
了,setState
本身就有一個計算過程,並且會導致重新渲染,scroll時候大量的setState
是造成效能瓶頸的最大元凶。
react官方推薦的修改style的方式是:
- 修改className來修改style
- 通過setState修改內聯style
react本身不推薦直接修改dom的特性,以及常年寫通過state修改檢視的react寫法習慣讓我第一時間並沒有想到修改減少setState的使用的思路,後來看了一下呼叫棧消耗時間發現經過了計算優化之後,剩下的最多的部分就是setState了,於是決定做一下實驗,不使用setState,直接修改卡片的style試試。
我直接去掉了卡片元件中的state,直接在卡片初始化完成之後將例項傳給父級元件儲存起來,每次父級元件滾動的時候,經過計算後,直接修改dom例項的style,不再經過state修改。
import produce from 'immer';
/**
* @description 初始化所有卡片的高度,並將cardBody{DOM}元素掛載到cardList上
* @param {Object} item
* @param {Number} height
*/
initCardContainer(item, height, cardBody) {
this.setState(
produce(draft => {
const index = draft.cardList.findIndex(i => i.hash === item.hash);
if (index > -1) {
draft.cardList[index] = Object.assign({}, draft.cardList[index], {
height,
cardBody
});
}
})
);
}
/**
* @description 改變卡片的style
* @param {Object} item
* @returns {Object}: style
*/
changeCardState(item) {
// 卡片在視口之外的樣式
let style = defaultStyle;
const { bodyTop, bodyBottom } = item;
const scrollY = window.scrollY + document.body.scrollTop;
const scrollHeight = document.body.scrollHeight;
if (bodyBottom < scrollY + OFFSET && bodyBottom >= scrollY - OFFSET) {
// 卡片從上方進入/退出
const cal = 1 - (bodyBottom - scrollY) / OFFSET;
style = {
transformOrigin: '50% 100% 0',
rotate: ROTATE * cal,
translate: -TRANSLATEZ * cal
};
} else if (
bodyTop > scrollY + innerHeight - OFFSET &&
bodyTop <= scrollY + innerHeight + OFFSET
) {
// 卡片從下方進入/退出
const cal = 1 - (scrollY + innerHeight - bodyTop) / OFFSET;
style = {
transformOrigin: '50% 0% 0',
rotate: -ROTATE * cal,
translate: -TRANSLATEZ * cal
};
} else {
// 卡片在視口之外或螢幕中央,不需要動畫
style = {
transformOrigin: '50% 50% 0',
rotate: 0,
translate: 0
};
}
return style;
}
複製程式碼
果然,去掉setState之後,整體效能提升了一倍之多,js執行時間直接從50%降低到了15%-20%,頁面穩定50-70幀,滾動起來十分流暢,基本達到了facebook對應頁面的效能標準。
其他優化
- 配置優化
將卡片的所有資訊都配置成了config.json檔案的形式,頁面中直接讀取配置檔案進行渲染。
{
"title": "Header Demo",
"type": "header",
"children": [
{
"title": "Title One",
"content": "#### Content One"
},
{
"title": "Title Two",
"content": "<p>Hello World</p>"
}
]
},
複製程式碼
這樣以後修改的時候可以不需要修改元件程式碼,直接修改配置檔案的配置即可。
以前的卡片部分是通過讀取config配置檔案,然後經過遞迴進行渲染,優化之後在渲染卡片之前,先將整個配置檔案打平,然後直接對卡片陣列進行一次遍歷渲染即可,這樣在render時可以減少大量的遞迴計算時間。
import produce from 'immer';
/**
* @description 展平配置檔案的樹形結構
* @param {Array} list
* @returns {Array} arr
*/
const expandConfig = list => {
if (!(list instanceof Array)) {
return [];
}
const arr = [];
const recursion = list => {
if (list instanceof Array) {
list.forEach(i => {
// 防止修改cardList汙染menuList
arr.push(
produce(i, draft => {
draft.hash = draft.title
? util.getHash(draft.title)
: util.genRandomId();
return draft;
})
);
if (i.children && i.children instanceof Array) {
recursion(i.children);
}
});
}
};
recursion(list);
return arr;
};
複製程式碼
至此本次優化結束,基本達成了追平Facebook效能的目標。