當聊到前端性能優(yōu)化時,我們會關注什么?
大廠技術 堅持周更 精選好文
關于這期分享內容
性能優(yōu)化一直是前端領域老生常談的問題,系統(tǒng)的性能以及穩(wěn)定性很大程度上決定著產品的用戶體驗以及產品所能達到的高度。而tob和toc系統(tǒng)又有著不同的業(yè)務場景,性能優(yōu)化也有著不用的著力點。本文從筆者的視角出發(fā),結合自己針對一個tob系統(tǒng)的性能優(yōu)化實踐去剖析一些大家可能共同關注的點,爭取可以以小見大。
關于團隊定位
我所在的團隊是一個涉及業(yè)務比較復雜的的教育前端團隊,而談及在線教育,始終繞不開在線講義,在線課件這一關,我們所負責的業(yè)務旨在提供完善的在線課件解決方案:
我們輸出的產品主要包括 編輯器 和 渲染器 兩部分。
編輯器除了提供基礎的課件編輯制作能力外,還提供了組裝各類教育資源的能力,這些教育資源包括互動題、cocos、pdf、ppt等。渲染器除了提供通用渲染器來支持基礎課件的渲染以外,還支持接入各類教育資源的渲染器,來支持教育資源的渲染。
關于數(shù)據(jù)結構,大致數(shù)據(jù)結構如下所示,類似ppt的數(shù)據(jù)結構,每一頁單頁課件是一個page,每頁課件上中的文字圖片音頻視頻都是一個節(jié)點,這些課件頁以及節(jié)點都是以數(shù)組的形式來維護。

{
pages: [
data: {
nodes: [
'text', 'image', 'video', 'staticQuestion'...
]
}
]
}
簡單了解業(yè)務之后我們才能結合具體的場景討論性能優(yōu)化過程中遇到的問題。
性能優(yōu)化歷程
3-4 雙月立項
我們的項目規(guī)劃一般按照雙月來制定目標,34雙月我們成立課件性能優(yōu)化專項,雙月目標是明顯提升用戶體驗。
針對不同問題的解決方案
下面我會從遇到的具體case入手,來聊一聊我們是如何解決這些問題的。
課件列表頁卡頓
原因分析
我們課件系統(tǒng)的數(shù)據(jù)依然采用了序列化數(shù)據(jù)存儲(未分頁),而我們打開編輯器時,會發(fā)請求拿到課件的所有內容,課件內容也會一股腦兒渲染在頁面上,這樣帶來的結果就是頁面的性能非常受課件體量的制約,隨著課件內容越來越多,課件頁面達到100頁以上時,系統(tǒng)的性能就已經到達了瓶頸,具體表現(xiàn)為點擊切換課件頁卡頓以及列表頁滾動卡頓。

我們在列表的vue組件的updated 生命周期中添加了一個 log 查看組件渲染次數(shù):
updated() {
// 查看該組件更新了多少次,勿刪
console.log("%c left viewer rerender", 'color: red;');
},
Vue 的 updated官網這樣解釋道:
由于數(shù)據(jù)更改導致的虛擬 DOM 重新渲染和打補丁。當這個鉤子被調用時,組件 DOM 已經更新,所以你現(xiàn)在可以執(zhí)行依賴于 DOM 的操作。然而在大多數(shù)情況下,你應該避免在此期間更改狀態(tài)。如果要相應狀態(tài)改變,通常最好使用 計算屬性 或 watcher 取而代之。
https://cn.vuejs.org/v2/api/#updated
于是我們發(fā)現(xiàn)點擊整個單個課件頁時,整個左側列表都重新渲染,而每個課件頁中的log也會執(zhí)行,而且會渲染三次。

我們初步判斷當點擊單頁時,組件執(zhí)行了多余的render,而在重新渲染之前虛擬dom的計算阻塞了單線程,導致ui假死。雖然Vue內部對虛擬dom的計算做了很多優(yōu)化,但是在這個案例中我們看到,課件體量大時,單線程依然會阻塞,我們通過performance可以進一步證明我們的猜想。

通過 perfermance可以看到,一次點擊事件的處理時間達到4.16s,這一楨的時間是4500ms,在這四秒多時間內,瀏覽器是沒有任何響應的,而通過觀察我們發(fā)現(xiàn)這段時間耗時的操作就是Vue的虛擬dom計算過程,在Bottom-up中也可以看到,耗時操作vue removeSub移除依賴的操作,還有虛擬dom patch node 的計算,這個過程是為了合并更新,這個計算堆積起來就非常耗時。

排查到這里我將原因歸結為組件太多,不必要的更新太多,我們去查看了一下頁面節(jié)點數(shù)量

200頁的課件全部渲染,頁面節(jié)點已經到達了3w之多,而每次交互更新量巨大,瀏覽器重繪的壓力也比較大(雖然這個時間比js計算還是少很多)。
經過以上的排查,我們總結原因為:Vue的數(shù)據(jù)偵聽應該更新變化了的dom,但是我們點擊某個課件頁時,由于處理Vuex的數(shù)據(jù)流的方式不太合理,使得許多組件依賴了本不需要的數(shù)據(jù),導致Vue判斷組件需要重新渲染。其次我們的頁面結構過于復雜,沒有做動態(tài)渲染或者feed流類似的分片加載策略,浪費了很多資源。
解決方案
基于以上的原因分析,我們嘗試了比較多的方案。其中由于Vuex數(shù)據(jù)流不合理帶來的過多rerender,由于項目過于復雜,涉及到了互動題編輯器和模版編輯器,數(shù)據(jù)流的改動風險較大,而且收益不一定明顯。于是在3-4月的優(yōu)化中,我們沒有動現(xiàn)有的狀態(tài)管理,而是在當前基礎上,力求減少每次操作的計算量和渲染量,也就是在不合理的方案下去緩解用戶體驗問題。我們的思路聚焦在:課件列表需要動態(tài)加載,頁面節(jié)點越少,掛載的組件越簡單,Vue的計算越快,瀏覽器渲染的速度也越快。 于是我們做了以下嘗試:
IntersectionObserver
借鑒圖片懶加載的方式,我們通過瀏覽器的 IntersectionObserver 進行 dom 的監(jiān)聽實現(xiàn)課件頁的懶加載
// 在需要懶加載的節(jié)點上添加ref屬性為“containerNeedLazyLoad”
// 將控制是否進行加載的boolean變量命名為“elementNeedLoad”
export default {
data() {
return {
elementNeedLoad: false,
elementNeedVisible: false
};
},
mounted() {
const target = this.$refs.containerNeedLazyLoad;
const intersectionObserver = this.lazyLoadObserver(target);
intersectionObserver.observe(target);
},
methods: {
lazyLoadObserver(target) {
return new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.intersectionRatio > 0) {
if (this.elementNeedLoad === false) {
this.elementNeedLoad = true;
this.$nextTick(() => {
this.elementNeedVisible = true;
});
this.lazyLoadObserver().unobserve(target);
}
} else {
this.elementNeedLoad = false;
this.elementNeedVisible = false;
}
});
}, {
threshold: [0, 0.5, 1],
});
},
}
};


簡言之就是我們給200頁課件每一頁的dom容器元素都添加了一個監(jiān)聽器,當該課件進入視窗時渲染內部的元素,在課件出視窗時注銷元素,通過v-if指令來實現(xiàn)。該方案實現(xiàn)后,實時渲染的課件數(shù)量只有視窗內的7-8個,其他課件只渲染了容器組件,頁面節(jié)點也少了很多,當我們點擊切換課件時變得流暢了許多,那是因為未完全渲染的課件頁Vue組件都變得「簡單」了,vue的計算也會更快,點擊課件的時間可以在300ms內響應。極大優(yōu)化了體驗,于是我們滿心歡喜上線了該優(yōu)化。

動態(tài)加載
一天后,又有教研老師反饋頁面卡頓,列表頁經常滾動比之前更卡,于是我們再次排查。上面的方案留下了一個比較大的問題是,列表頁在滾動的過程當中,需要實時監(jiān)聽dom,我們不懷疑瀏覽器api的性能,但是v-if指令會在值變化時,執(zhí)行虛擬dom的計算并更新組件,而這個過程是在滾動的過程中實時進行的。

從上面的視頻中我們可以看到在滾動時很容易出現(xiàn)掉幀的情況,所以圖片懶加載的方案無法直接嫁接,我們需要更好的懶加載方案。
在競品調研過程中,我們比較關注google doc和騰訊文檔的方案。

Google doc采用了滾動懶加載的方式加載文檔,但是由于google的底層方案都是基于svg,方案無法復刻,但是動態(tài)加載的方式可以借鑒。

騰訊文檔則是采用了分頁加載,首屏渲染之后再加載其他的內容,但是由于我們在 url 上攜帶了課件 id需要定位到具體的課件頁,而且數(shù)據(jù)方面沒有分頁,因此該方案暫時不考慮。此外我們頁關注了某教研云的課件加載方案:
與預想中的一樣,某教研云也采用了動態(tài)加載的方式,可見這也算是長列表比較常見的優(yōu)化手段。
于是我們有了以下優(yōu)化思路:
默認每張課件不渲染任何節(jié)點,只渲染容器節(jié)點,dom結構會簡單很多 監(jiān)聽列表容器的滾動事件,計算當前視圖最中間的課件index,同時將上下各7張課件作為可渲染課件,添加滾動監(jiān)聽的防抖 添加可渲染課件index時通過 setTimeout 逐一添加實現(xiàn)流式加載 已經渲染的頁面在下一次滾動中不再重復渲染
import { on, off, mapState, mapMutations } from 'utils';
import debounce from 'lodash/debounce';
/**
* 總共加載當前課件的ppt上下若干頁課件,其他的通過滾動延遲加載
*/
const renderPagesBuffer = 7;
const renderPagesBoundary = 2 * renderPagesBuffer + 1;
const debounceTime = 400;
const progressiveTime = 150; // 漸進式渲染間隔時間
/**
* 持久化一下
*/
const bodyHeight = document.documentElement.clientHeight || document.body.clientHeight;
export default {
data() {
return {
additionalPages: [],
commonPages: [] // 前后兩次滾動所需要渲染的公共頁面
};
},
mounted() {
this.observeTarget = this.$refs.pprOverviewList;
on(this.observeTarget, 'scroll', () => {
this.handleClearTimer();
this.handleListScroll();
});
if (!this.renderAllPages) {
this.updateCurrentPagesInView(new Array(renderPagesBoundary).fill(1).map((_, i) => i));
} else {
/* 先手動觸發(fā)一次 */
const timer = setTimeout(() => {
this.handleClearTimer();
this.handleListScroll();
clearTimeout(timer);
}, debounceTime * 2);
}
},
beforeDestroy() {
off(this.observeTarget, 'scroll', this.handleListScroll);
},
computed: {
...mapState('editor', ['currentPagesInView']),
pagesLength() {
return this.pptDetail?.pages?.length || 0;
},
renderAllPages() {
return this.pagesLength > renderPagesBoundary;
}
},
watch: {
additionalPages(val) {
this.observerIndex = 1;
this.handleRenderNextPage();
}
},
methods: {
...mapMutations('editor', ['updateCurrentPagesInView']),
/**
* 增加滾動事件的防抖設置,防止頻繁更新
*/
handleListScroll: debounce(function() {
const { scrollTop, scrollHeight } = this.observeTarget;
const percent = (scrollTop + bodyHeight / 2) / scrollHeight;
// 找到當前滾動位置位于頁面中心的 ppt
const currentMiddlePage = Math.floor(this.pagesLength * percent);
const start = Math.max(currentMiddlePage - renderPagesBuffer, 0);
const end = Math.min(currentMiddlePage + renderPagesBuffer, this.pagesLength + 1);
// 已經渲染了的頁面集合(保證不重復渲染)
const commonPages = [];
// 滑動之后需要新渲染的頁面集合
const additionalPages = [];
for (let i = start; i < end; i++) {
if (this.currentPagesInView.includes(i)) {
commonPages.push(i);
} else {
additionalPages.push(i);
}
}
this.commonPages = commonPages;
this.additionalPages = additionalPages;
}, debounceTime),
handleRenderNextPage() {
const nextPages = this.additionalPages.slice(0, this.observerIndex);
this.updateCurrentPagesInView(
[...nextPages, ...this.commonPages]
);
this.observerIndex++;
if (this.observerIndex >= this.additionalPages.length) {
this.handleClearTimer();
} else {
this.observerTimer = setTimeout(() => {
this.animationTimer = requestAnimationFrame(this.handleRenderNextPage);
}, progressiveTime);
}
},
handleClearTimer() {
this.observerTimer && clearTimeout(this.observerTimer);
this.animationTimer && cancelAnimationFrame(this.animationTimer);
}
}
};
其中需要注意的時,滾動的監(jiān)聽添加了400ms的防抖,我們一直滾動會感受到非常流暢,而在停止?jié)L動開始渲染時,如果同時渲染計算得來的共15張課件,則在這些組件渲染完成之前頁面依然是卡死的狀態(tài),因此我們采用了setTimeout實現(xiàn)漸進式渲染,宏任務的好處就是讓我們可以在每一次事件循環(huán)中插入微任務,比如當前課件正在進行流式渲染,這時點擊了某張課件可以先切換再繼續(xù)渲染。
this.observerTimer = setTimeout(() => {
this.animationTimer = requestAnimationFrame(this.handleRenderNextPage);
}, progressiveTime);
另外當再次觸發(fā)滾動事件時,需要清除此前所有的定時器重新計算,而已經渲染了的頁面index我們存在 commonPages 數(shù)據(jù)中,在下一次計算時不進行清除。最終優(yōu)化的效果如下:

可以看到從用戶體驗上,已經解決了滾動的卡頓問題,同時也不會因為組件過多阻塞用戶的點擊事件。

通過性能監(jiān)控也能看到在滾動過程中幾乎沒有任何的計算,這也是滾動起來十分流暢的原因。
虛擬列表
我們前面也說到,這是在現(xiàn)有數(shù)據(jù)流不合理的情況下的無奈之舉,而要徹底解決需要更完美的方案。由于Vue框架沒有提供React的memo或者shouldComponentUpdate類似的鉤子函數(shù),讓開發(fā)者決定組件是否應該重新渲染,因此在更徹底的解決方案中,由組內另一位同學主導設計,我們嘗試用react虛擬列表進行重構。
長列表的終極優(yōu)化方案還是要走向虛擬列表,無論是百度貼吧中期的技術重構,還是今日頭條的feed流,都曾基于該方案做過探索。
虛擬列表是一種根據(jù)滾動容器元素的可視區(qū)域來渲染長列表數(shù)據(jù)中某一個部分數(shù)據(jù)的技術,具體在實現(xiàn)的時候,需要一個用于滾動監(jiān)聽的虛擬容器,和一個用作元素渲染的真實容器。
虛擬列表通過計算滾動視窗,每次只渲染部分元素,既減少了首屏壓力,長時間加載也不會有更多的性能負擔。虛擬列表中的元素都是絕對定位的,無論列表有多長,實際渲染的元素始終保持在可控范圍內。
前端領域各個社區(qū)也有了比較成熟的解決方案,react-virtualized、react-window 以及 vue-virtualize-list 等等,關于虛擬列表原理的敘述可以參考以下文章,這里限于篇幅不再贅述:
https://github.com/dwqs/blog/issues/70
import { SortableContainer } from 'react-sortable-hoc';
import { areEqual, VariableSizeList as List } from 'react-window';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
export const ReactVirtualList: React.FC<any> = (props) => {
const list = useRef(null);
const initialScrolled = useRef(false);
const [dragging, setDragging] = useState(-1);
const { pages, currentPageId, selectedPagesId } = props;
const pageGroups = buildPageGroups(pages);
useEffect(() => {
props.onReady({ scrollToTargetPage });
}, []);
useEffect(() => {
list.current.resetAfterIndex(0);
}, [pages]);
useEffect(() => {
// 進入頁面定位到選擇頁面
if (!initialScrolled.current && pages.length > 0) {
scrollToCurrentPage();
initialScrolled.current = true;
}
}, [pages]);
const scrollToCurrentPage = () => {
scrollToTargetPage(currentPageId, pages);
};
// 渲染列表的每一項
return <SomeThing />
}
同時由于最初我們縮略圖的元素渲染器采用了跟用戶操作區(qū)的同一個渲染組件,每個元素上都有很多事件監(jiān)聽,新版本我們也封裝了基于 react 的純 ui 元素渲染器 ,去掉了無用的事件監(jiān)聽,簡化了dom結構。

兩者結合就成了新版本的縮略圖列表,目前已經上線完成,從各項指標以及用戶體驗來看,提升還是非常大的,其中更明顯的拖拽排序,相較于此前的拖拽排序用戶體驗要好得多。

內存泄漏
同樣是上述課件,經過上述的優(yōu)化,頁面的節(jié)點數(shù)量已經少了很多,而且卡頓問題也得到了改善。但是當我們不斷滾動左側預覽圖時,一段時間之后還是會卡頓甚至卡死。此時要么是 cpu占用率過高導致頁面無法響應,要么出現(xiàn)了內存泄漏問題,經過排查發(fā)現(xiàn),雖然頁面中的節(jié)點在懶加載過程中會注銷,但是這些節(jié)點依然會被保存在內存當中,一直沒有釋放,甚至達到10w之多,內存占用也一直線性增長,我們需要針對這一些內存中的節(jié)點進行優(yōu)化,處理內存泄漏問題。

我們通過內存快照可以看到,標記為 Detached 也就是脫離文檔流的節(jié)點依然存儲在內存當中,其中 DIVElement 有兩萬多個,展開發(fā)現(xiàn)都是 element-render (我們課件元素渲染器)中的元素,比如帶有 render-wrap 或者 selection-zone 的這些類(都是我們項目中掛載在dom上的類名)。所以判斷這個組件存在內存泄露問題。

排查的過程中,一度懷疑是vue 的 v-if 造成的,在 vue的官網有關于 v-if 內存泄漏的相關內容。
https://cn.vuejs.org/v2/cookbook/avoiding-memory-leaks.html
我嘗試了通過手動掛載組件執(zhí)行$mount,在課件滑到視圖之外時手動注銷,依然沒有作用,甚至嘗試實現(xiàn)自定義指令 v-clean-node,在懶加載過程中動態(tài)注銷節(jié)點。但事實證明,節(jié)點是已經從dom結構中注銷了的,只是對應的dom片段依然保存在內存當中,而且筆者也沒有找到可以手動釋放內存的方法,至少在瀏覽器環(huán)境還沒有辦法辦到。這里走了很多彎路,而思考的方向或許也能成為一些反面案例,不過性能優(yōu)化這條路需要做的也是勇敢嘗試,敢于試錯,最終總能找到一個相對較優(yōu)的解決辦法。
我們換個思考的方向,既然不能手動釋放內存,就去迎合v8內存管理的原理,代碼怎樣寫才能保證內存被正常釋放。我們想到的就是一直被引用的變量,其次就是未被注銷的事件監(jiān)聽。
接下來的排查采用了打斷點和注釋代碼的笨辦法,逐漸縮小排查范圍,最終鎖定在了渲染富文本所使用的 render 組件。

這個組件用到了兄弟團隊提供的render 庫,而在 node_modules 是編譯后的 es5代碼,可讀性還不錯,于是我通過斷點在在源碼中進行調試,手動追蹤調用棧。

在最終渲染的方法中,排查找到了這樣一行代碼:

這里每個富文本渲染都會添加一個body resize的事件監(jiān)聽,而筆者并沒有找到相關unobserve的邏輯。類似這種事件監(jiān)聽的代碼如果沒有取消監(jiān)聽,很容易造成內存泄漏,注釋這一行代碼之后重啟項目,系統(tǒng)的內存可以正常回收了,最終確定是由這個sdk導致了內存泄漏,后續(xù)兄弟團隊的同學也協(xié)助解決了這一問題,重新發(fā)了一個正式包。


通過測試發(fā)現(xiàn)內存已經可以正常釋放。
這里的內存泄漏可以用以下示意圖概括:

未被釋放的事件監(jiān)聽會導致對應的組件在卸載時并未被釋放,因此我們的內存中會有這些 vue組件。
日常項目開發(fā)的過程中,內存優(yōu)化需要持續(xù)關注,我們課件編輯器的內存占用大概100M左右,需要在后續(xù)的開發(fā)過程中持續(xù)優(yōu)化
點擊預覽按鈕之后頁面操作卡頓
這是一個非常有趣的案例,課件的預覽是在當前頁面打開一個彈窗嵌入預覽頁面的iframe,每次點擊預覽之后回到編輯器總會出現(xiàn)或多或少的卡頓現(xiàn)象。
這是因為預覽頁和編輯器的域名相同,因此打開iframe時共享了同一進程。
iframe 作為升級版的 frame,一般來說都會被認為和上層的 parent 容器處在同一個進程中,他們會擁有父容器的一個子上下文 BrowserContext。在這種情況下,iframe 當中的 js 運行時便會阻塞外部的 js 運行,特別是當如果 iframe 中的代碼質量不高而導致性能問題時,外層運行的容器會受到相當大的影響。這顯然是我們不愿意看到的,因為 webview 中的內容僅僅會作為 IDE 拓展機制的一部分,我們不希望看到我們的外部 UI 和程序被 iframe 阻塞從而導致性能表現(xiàn)不佳。

iframe 線程
幸運的是,Chrom 在 67 版本之后默認開啟了 Site Isolation。基于它的描述,如果 iframe 中的域名和當前父域名不同(也就是大家熟悉的跨域情況),那么這個 iframe 中的渲染內容就會被放在兩個不同的渲染進程中。而我們只需要將 IDE 主應用的頁面掛在域名A下,而同時將 iframe 的的頁面掛在域名B下,那么這個 iframe 的進程就和主進程分開了。在這種模型下,iframe 和主進程僅僅能通過 postMessage 和 message 事件進行數(shù)據(jù)通訊。但是在上面的模型中,仍然有一點需要注意。基于 Site Isolation 的特性,同一個頁面中如果有多個,擁有同一個域名的多個 iframe 之間是共享進程的,因此他們仍然會互相影響性能。如果某個業(yè)務場景需要一個更為獨立的 iframe 進程,它必須和其他 iframe 擁有不同的域名。
我們在項目中分別嵌入了百度首頁和我們課件渲染頁面,發(fā)現(xiàn)一級域名相同時iframe和當前頁面總是會共享進程id,無論嵌入頁面的性能如何,對當前頁面都會有或多或少的影響。因此我們有了以下解決方案:
預覽頁面部署到新的域名上,兩者不共享進程 通過a標簽打開新的頁面進行預覽,需要注意的是a標簽需要加上 rel="noopener"屬性,切斷進程聯(lián)系
<a
v-if="showOpenEntry"
class="intro-step3 preview-wrap"
rel="noopener"
target="__blank"
:disabled="!showEditArea"
:style="{ color: !showEditArea ? 'lightgray' : '#515a6e' }"
:href="pageShareUrl"
>
<lego-icon type="preview" size="16" />
</a>
目前的優(yōu)化中采用了第二種跳轉的方式作為臨時方案。
添加動畫時間過長
有這樣一個場景是老師需要給多個元素同時添加動畫,但是頁面需要幾秒鐘響應,對于用戶來說就是出現(xiàn)了卡頓。

我們依然通過performance排查,確定了動畫表單的渲染阻塞了ui的更新,同樣涉及到長列表,但此處沒有課件列表復雜,而且課件頁都渲染了一個固定高度的容器,此處每個動畫表單高度都不一樣,因此我們采用另一種懶加載的渲染方式:
export default {
data() {
return {
// 當前渲染動畫數(shù)量
nextRenderQuantity: 0
};
},
computed: {
animationLength() {
return this.animationConfigsUnderActiveTab.length;
},
renderAnimationList() {
// 從原數(shù)組中切割
return this.animationConfigsUnderActiveTab.slice(0, this.nextRenderQuantity);
}
},
watch: {
animationLength: {
handler() {
this.handleRenderProgressive();
},
immediate: true,
}
},
beforeDestroy() {
this.timer && cancelAnimationFrame(this.timer);
},
methods: {
/**
* 動畫表單的漸進式渲染,每一幀多渲染一個
*/
handleRenderProgressive() {
this.timer && cancelAnimationFrame(this.timer);
if (this.nextRenderQuantity < this.animationConfigsUnderActiveTab.length) {
this.nextRenderQuantity += 1;
this.timer = requestAnimationFrame(this.handleRenderProgressive);
}
},
}
};
通過 requestAnimationFrame 每一幀添加一個動畫,也就是40個元素同時添加動畫,需要40x16 = 640ms渲染完表單,而在這個時間之前,頁面已經及時作出了響應,老師在使用的時候就不會覺得卡頓了。



優(yōu)化前后的響應時間從2.35s => 370ms,優(yōu)化效果比較顯著。
總結:不要讓你的js邏輯阻塞了ui的渲染。
其他的優(yōu)化
其他的一些常見的項目優(yōu)化就不在此贅述了,無論什么樣的業(yè)務場景,tob還是toc系統(tǒng),大家可能都曾在以下優(yōu)化方向上摸爬滾打過。
路由懶加載 靜態(tài)資源緩存 打包體積優(yōu)化 較大第三方庫借助cdn 編碼優(yōu)化:長數(shù)組遍歷善用 for 循環(huán)等 可能的預編譯優(yōu)化
深入框架,尋找性能優(yōu)化方向
Vue 的懶人哲學 vs React 暴力美學
在性能優(yōu)化的路上越走越偏,我也深深感受到前端工具帶來的便利和過分依賴前端框架所帶來的所謂的 side effect。vue 和 react 作為如今最火的兩個框架,各自有著其獨特的魅力,而性能優(yōu)化的同時我們始終繞不開框架的底層原理。
Vue 的懶人哲學:
曾經的一次分享中我們提到了vue所謂的懶人哲學 ,也就是說 vue 框架內部為你做了太多優(yōu)化工作。


我們知道Vue會對組件中需要追蹤的狀態(tài),將其轉化為getter和setter進行數(shù)據(jù)代理,構建視圖和數(shù)據(jù)層的依賴,這就是ViewModel 這一層。而正是由于vue精確到原子級別的數(shù)據(jù)偵聽使得其對數(shù)據(jù)十分敏感,任何數(shù)據(jù)的改變,vue都能知道這個數(shù)據(jù)所綁定的視圖,在下一次dom diff時,他能精確知道哪些dom該渲染,哪些保持不動。而vue的這個原理也是他升級Vue3時進行更高效的預編譯優(yōu)化的前提條件,感興趣的同學可以跟我探討下曾經的分享,這其中也聊到了 Vue。
https://zhuanlan.zhihu.com/p/158880026
但是最大問題在于,vue 更新視圖恰好不多不少的前提是,你的數(shù)據(jù)流十分干凈,沒有多余的數(shù)據(jù)更新,否則「敏感」的vue會以為更多的組件需要重新渲染,這也是目前我們課件編輯器的問題所在,項目體量越來越大,幾乎沒有幾個開發(fā)者可以保證自己所維護的狀態(tài)管理干凈透明,而一旦有不合理的數(shù)據(jù)更新,組件的重新渲染是無法從中攔截的,因此用 Vue 可以讓你「懶」一點,也需要你寫代碼時「小心」一點。而對比react,兩者底層設計的不同導致在遇到此類問題時,我們可能需要不一樣的思考方向。
現(xiàn)在在知乎上還能翻到一些尤大關于 react 性能問題的理解。

尤大說的比較通俗易懂,而且也確實直指react框架的要害,其中所提到的 react 把大量優(yōu)化責任丟給開發(fā)者,相信大家都有所感受。
React 的暴力美學:
與Vue不同的是,React對數(shù)據(jù)天然不敏感,框架不關心你更新了多少數(shù)據(jù),乃至更新了多少臟數(shù)據(jù),數(shù)據(jù)與dom結構也沒有vue那種依賴關系,你只需要通過 setState 告訴我我應該渲染哪些組件即可。與 Vue 相比,react的處理方式既優(yōu)雅又暴力,而且從開發(fā)者的角度來審視,react的這種設計真的減少了太多的心理負擔,而作為初接入react的開發(fā)者來說,你不會因為多更新了數(shù)據(jù)導致過多的rerender 而抓耳撓腮,你要做的就是借助框架本身去消除這些副作用,眾所周知react正好提供了這些能力:
Reat.memo PureComponent shouldComponentUpdate
你需要的,都能借助框架或者第三方工具做到。
談到這里,不妨具體說說框架如何避免不必要的渲染問題:
以 react context 為例,如果我們在項目中所有的狀態(tài)管理都放在一個 context 中,那么在使用時總會引起不必要的渲染。而在開發(fā)過程中如何避免,不同開發(fā)者都有不同的心得。
const AppContext = React.createContext(null);
const App = () => {
const [count, setCount] = useState(0); // 經常變的
const [name, setName] = useState('Mike'); // 不經常變的
return (
<AppContext.Provider value={{
count,
setCount,
name,
setName
}}>
<ComponentA />
<ComponentB />
</AppContext.Provider>
)
}
const ComponentA = () => {
console.log('這是組件A');
const context = useContext(Context);
return (
<div>{context.name}</div>
)
}
const ComponentB = () => {
console.log('這是組件B')
const context = useContext(Context);
return (
<div>
{context.count}
<button
onClick={() => {
context.setCount((c) => c + 1)
}}
>
SetCount
</button>
</div>
)
}
在這個 demo中,我們在頂層注入了 Context 做狀態(tài)管理,同時有一個經常改變的狀態(tài)count 和一個不經常改變的狀態(tài) name,組件A和組件B分別引用了 name 和 count 這兩個狀態(tài)。
我們在點擊 SetCount 時,調用了全局上下文 中的方法,同時觀測到A B兩個組件都會重新渲染,而實際上我們的組件 A只用到了 name 這個狀態(tài),是不應該重新渲染的。這里的數(shù)據(jù)流其實非常「干凈」,沒有多余的引用,如果是 Vue,它會追蹤依賴而避免組件 A 的渲染,react 卻沒有做到。而作為 react 開發(fā)者,如果放任這種不必要的 rerender不管,那正如尤大所說, react 應用的性能確實會遇到瓶頸,好在 react 給了開發(fā)者足夠的發(fā)揮空間,大多開發(fā)者遇到此類場景,反手就是一個 context 拆分優(yōu)雅解決:
const AppContext = React.createContext(null);
const AppContext2 = React.createContext(null);
const App = () => {
const [count, setCount] = useState(0);
const [name, setName] = useState('Mike');
return (
<AppContext.Provider value={{
name,
setName
}}>
<AppContext2.Provider value={{
count,
setCount
}}>
<ComponentA />
<ComponentB />
</AppContext2.Provider>
</AppContext.Provider>
)
}
const ComponentA = () => {
console.log('這是組件A');
const context = useContext(Context);
return (
<div>{context.name}</div>
)
}
const ComponentB = () => {
console.log('這是組件B')
const context = useContext(Context);
return (
<div>
{context.count}
<button
onClick={() => {
context.setCount((c) => c + 1)
}}
>
SetCount
</button>
</div>
)
}
這里我們將兩個狀態(tài)拆分進不同的 context 中,此時再調用 setCount 方法,就不會影響到組件 A 重新渲染了。這也是我們實際項目開發(fā)中最常見的解決方案。但是項目體量越來越大時,這種模塊的拆分會變得很繁瑣,類似 Vuex 模塊的拆分一樣,我們開發(fā)一個新功能,也總是不愿意在 vuex 中新開辟一個模塊,寫更多的文件以及 getters mutations。所以在這個例子中我們也可以通過 useMemo 來解決:
const ComponentA = () => {
console.log('這是組件A');
const context = useContext(Context);
const memoResult = useMemo(
() => {
<div>{context.name}</div>
},
[context.name]
)
return memoResult;
}
我們在組件 A 中,組件內容用 useMemo 包裹,將其制造為一個緩存對象,這里的 useMemo 不去緩存 state,因為我們調用了頂層方法 setCount 引起 state immutable 更新 -> 進而 name 更新(引用地址變化),在頂層組件中緩存 state 其實并沒有什么用,所以在這個案例中 useMemo 只能用來緩存組件。
當然,我們不能每個組件都通過 useMemo 來處理,很多時候只是平添開銷。因此 react 團隊所提出的 context selectors 才是解決類似案例的最佳選擇:
https://github.com/reactjs/rfcs/pull/119
通過 selector 機制,開發(fā)者在使用 Contexts 時,可以選擇需要相應的 Context data,從而規(guī)避掉「不關心」的 Context data 變化所觸發(fā)的渲染。這跟我們手動拆分 context 所實現(xiàn)的功能如出一轍,總的來說優(yōu)化思路還是比較一致的。
react 更多案例可以參考:
https://codesandbox.io/s/react-codesandbox-forked-s9x6e?file=/src/Demo1/index.js
聊到這里我們發(fā)現(xiàn),當使用框架作為開發(fā)工具來解決問題時,如果產生了副作用,react開發(fā)者有很多方式可以抵消這個副作用,而相對來說 vue 以及vue生態(tài)圈所能提供的解決方案就比較少,正如前面我們遇到的那些bad case一樣,我們可能需要從一些比較偏的角度去思考才能解決這類問題。
題外話(個人觀點):
個人認為 Vue 做大型項目有著天然的弊端,由于遞歸實現(xiàn)了精確數(shù)據(jù)偵聽,使得其產生了過多的訂閱對象,而正如前面所說,一旦數(shù)據(jù)流不合理,多余的更新不可逆,而過多的偵聽對象對系統(tǒng)內存也是一個考驗。令一點就是筆者的自我感受,Vue 項目開發(fā)在組件化不如 React 來得清澈透明,單文件組件大了之后,可讀性也比較差,而react 有社區(qū)加持,有 Redux 和 Saga 進行狀態(tài)管理,上手曲線雖然略高,但是代碼規(guī)范度極高,狀態(tài)管理效果極好,適合團隊開發(fā)。反觀 vue, 做小型項目卻有著天然優(yōu)勢(為什么?),因此每個項目在前期都要著重分析業(yè)務場景,做好項目規(guī)劃和技術選型。個人觀點,希望各位同學指出不足,理性討論。
以上是筆者在我們課件編輯器的項目中一些優(yōu)化實踐,不同場景有不同的解決方案,希望大家也可以留言給到一些建議和幫助,讓我們課件團隊可以打磨出更好的產品。
關于性能優(yōu)化的建議
面向用戶,了解用戶真正的痛點
縮小產研團隊和用戶之間對產品理解的gap。
發(fā)現(xiàn)問題,問題就解決了一半
發(fā)現(xiàn)問題,也需要發(fā)現(xiàn)問題的根源,性能問題的背后,往往是編碼的不合理以及工具的不合理應用。
歸納總結,觸類旁通。
相似的問題千篇一律,有趣的方案各有各的特色,每次性能優(yōu)化之后,歸納總結總能帶來更多的收獲。
