昨天上午接了一個電話面試,聊着聊着接說到了性能優化,而後面試官問到了長列表。其實以前作過的都只是簡單的分頁處理,但面試官問的確定不是這個咯,他關心的是虛擬列表,大概之前粗略看過這個效果的實現源碼,雖然我本身沒實現過但有一些本身的想法,因而blablabla......,可能礙於表達能力有限,也不曉得面試官理解我意思沒😂,因而簡單實現並記錄一下vue
當時體驗這個效果時特地打開performance面板分析了一下,感受不是很滿意。在網上找了一個實現,還原一下當時的場景,看圖:node
前半段是不斷經過滾輪滾動,後半段是快速拖拽滾動條,對於這種滾動相關的功能,我是那麼一丟丟強迫症的......,FPS表現很明顯,有紅色報警了,在看那個CPU圖表,有沒有想將它撫平的衝動???git
長列表優化,自己就是一次優化行爲(廢話),但優化功能的同時這個優化自己不能不考慮優化,通過昨天晚上的一番搗鼓,我最終達到了以下效果:github
一樣,前半段經過滾輪滾動,後半段快速拖拽滾動條。但實現後仍是有一缺陷的,待往後碰到這種需求時再去優化面試
源碼基於vue實現,這裏統一一下詞彙算法
Item
表示長列表的每一個子項首先,得明確寫這個功能要達到什麼目的,或者說最終效果瀏覽器
由以上2點推測,我們有事情要作了性能優化
暫時只能想到這幾點,下面,逐個實現它們。服務器
爲何說是滑動窗口呢?在本地,咱們保存着一個超長的數據列表,但沒有必要將他們所有加入到視圖中,用戶只須要也只能看到當前視口範圍內顯示的數據,既然這樣,我們就能夠用一個容器存放當前用戶須要看到的數據,而後將這個容器中的數據展現給用戶,能夠將這個容器當作是一個小窗口,當用戶發出要查看更多數據的請求時,移動這個小窗口,而後更新視圖。
那麼這個窗口的跨度有多大呢?
如今,咱們將窗口放大些,原理簡單用圖理解一下
具體的作法就是,若是一頁展現10條數據,那麼實際上我會渲染20條,而且將這20條數據劃分爲2部分,當可視區移動到容器的邊緣時
容器的DOM結構像這樣
<div ref="fragment" class="fragment" :style="{ transform: `translate3d(0, ${translateY}px, 0)` }">
<template v-for="item in currentViewList">
<div :key="item.key">
<!-- item content -->
</div>
</template>
</div>
複製代碼
// 原始數據
const sourceList = [/* ... */]
// 狀態1
const currentViewList = [...sourceList.slice(20, 30), ...sourceList.slice(30, 40)]
// 狀態1 向下
currentViewList = [...sourceList.slice(30, 40), ...sourceList.slice(40, 50)]
// 狀態1 向上
currentViewList = [...sourceList.slice(10, 20), ...sourceList.slice(20, 30)]
複製代碼
這裏使用translate平移,由於這能夠減小沒必要要的layout,在這個實現中,移動容器是一個很是頻繁的操做,因此很是有必要考慮layout消耗
關於滾動行爲,有幾點須要明確,先看圖(瀏覽器渲染每一幀要作的事情),須要進一步瞭解的朋友能夠去查查相關資料
對滾動行爲的要求決定了得使用原生滾動,其實也很簡單,因爲還須要實現上拉加載功能,咱們在底部確定須要放一個loading,這樣的話,就能夠給loading設置一個paddingTop值,大小爲Item的高度乘以列表長度 ,這樣一來滾動條就是真實的滾動條了
<div ref="fragment" class="fragment" :style="{ transform: `translate3d(0, ${translateY}px, 0)` }">
<!---->
</div>
<div class="footer" :style="{ paddingTop: `${loadingTop}px` }">
<div class="footer-loading">Loading......</div>
</div>
複製代碼
那麼對於容器內的Item,根據vdom diff算法的特性:
大概猜想沒什麼說服力,我寫完後,對比2種狀況進行了屢次測試,發現2者差距其實不是很大(多是我電腦緣由😂),綜合幾回測試,不使用key時狀況看起來稍微好些
不使用key
使用key
實際上我這幾年沒有碰到過這種需求,這裏我就選擇不使用key渲染
這裏的方式有不少種,能夠在滾動事件中經過getBoundingClientRect
獲取到容器相對視口的位置後計算。這裏有的朋友可能會有疑問,getBoundingClientRect
方法不是會觸發迴流嗎?你在滾動事件中頻繁調用這個方法,那對性能不是很是不利嗎?來看2個小例子:
// 例1
setInterval(() => {
console.log(document.body.offsetHeight)
}, 100)
// 例2
let height = 1000
setInterval(() => {
document.body.style.height = `${height++}px`
console.log(document.body.offsetHeight)
}, 100)
複製代碼
顯然這裏的例1不會致使迴流,但例2就會了,緣由是由於你在當前幀更新了layout相關的屬性,同時設置後又進行了一次查詢,這就致使瀏覽器必須進行layout獲得正確的值後返回給你。因此,關於咱們日常所說的那些致使layout的屬性,不是用了就會layout,而是看你如何用。
那麼臨界點的邏輯大概是這樣的:
const innerHeight = window.innerHeight
const { top, bottom } = fragment.getBoundingClientRect()
if (bottom <= innerHeight) {
// 到達最後一個Item,向下
}
if (top >= 0) {
// 到達第一個Item,向上
}
複製代碼
注意在頁面滾動時,這裏並不會頻繁觸發向上或者向下的邏輯。以向下爲例,當觸發向下的邏輯後,當即將容器的translateY
值更新(至關於下移10個Item高度)向下平移,同時更新Item,下一幀渲染後容器下邊緣已經回到可視區下方了,而後繼續向下滾動一段距離後纔會再次觸發,這其實就像一個懶加載,只不過這是同步的。
只有在向下滾動時,纔有必要執行向下的邏輯,向上滾動同理。爲了處理不一樣方向的邏輯,須要算出當前的滾動方向,這個直接保存上一次的值就能搞定了
let oldTop = 0
const scrollCallback = () => {
const scrollTop = getScrollTop(scroller)
if (scrollTop > oldTop) {
// 向下
} else {
// 向上
}
oldTop = scrollTop
}
複製代碼
結合前面的代碼,咱們先綁定一下滾動事件
const innerHeight = window.innerHeight
// 滾動容器
const scroller = window
// Item容器
const fragment = this.$refs.fragment
let oldTop = 0
const scrollCallback = () => {
const scrollTop = getScrollTop(scroller)
const { top, bottom } = fragment.getBoundingClientRect()
if (scrollTop > oldTop) {
// 向下
if (bottom <= innerHeight) {
// 到達最後一個Item
this.down(scrollTop, bottom) // 待實現
}
} else {
// 向上
if (top >= 0) {
// 到達第一個Item
this.up(scrollTop, top) // 待實現
}
}
oldTop = scrollTop
}
scroller.addEventListener('scroll', scrollCallback)
複製代碼
處理滾動條時,我們已經添加了loading標籤,這裏只須要在滾動事件中判斷這個loading元素是否出如今可視區,一旦出現就觸發加載邏輯。這裏有一個邊界狀況要考慮,一旦觸發了加載邏輯,不出意外在拿到響應數據時是要更新原始數據的,若是此時,我停留在底部,須要自動將新的數據渲染出來;若是我在沒有拿到數據前,向上滾動了,那麼拿到響應後就不須要將新的數據更新到視圖了。
const loadCallback = () => {
if (this.finished) {
// 沒有數據了
return
}
const { y } = loadGuard.getBoundingClientRect()
if (y <= innerHeight) {
if (this.loading) {
// 不能重複加載
return
}
this.loading = true
// 執行異步請求
}
}
複製代碼
首先,須要作一些相關的邊界處理,好比currentViewList
中的數據量不知足向下滾動等。主要仍是要注意一點:滾動不必定是連續的
down (scrollTop, y) {
const { size, currentViewList } = this
const currentLength = currentViewList.length
if (currentLength < size) {
// 數據不足以滾動
return
}
const { sourceList } = this
if (currentLength === size) {
// 單獨處理第二頁
this.currentViewList.push(...sourceList.slice(size, size * 2))
return
}
const length = sourceList.length
const lastKey = currentViewList[currentLength - 1].key
// 已是當前最後一頁了,但可能正在加載新的數據
if (lastKey >= length - 1) {
return
}
let startPoint
const { pageHeight } = this
if (y < 0) {
// 直接拖動滾動條,致使容器底部邊緣直接出如今可視區上方,這種狀況經過列表高度算出當前位置
const page = (scrollTop - scrollTop % pageHeight) / pageHeight + (scrollTop % pageHeight === 0 ? 0 : 1) - 1
startPoint = Math.min(page * size, length - size * 2)
} else {
// 連續的向下滾動
startPoint = currentViewList[size].key
}
this.currentViewList = sourceList.slice(startPoint, startPoint + size * 2)
}
複製代碼
向上滾動的處理和向下滾動相似,這裏就直接貼代碼了。
up (scrollTop, y) {
const { size, currentViewList } = this
const currentLength = currentViewList.length
if (currentLength < size) {
return
}
const firstKey = currentViewList[0].key
if (firstKey === 0) {
return
}
let startPoint
const { sourceList, innerHeight, pageHeight } = this
if (y > innerHeight) {
const page = (scrollTop - scrollTop % pageHeight) / pageHeight + (scrollTop % pageHeight === 0 ? 0 : 1) - 1
startPoint = Math.max(page * size, 0)
} else {
startPoint = currentViewList[0].key - size
}
this.currentViewList = sourceList.slice(startPoint, startPoint + size * 2)
},
複製代碼
到此,這些功能差很少已經實現,仔細想一想,若是不用任何庫或者框架直接用原生操做DOM的方式實現的話,應該能達到更好的性能,由於能夠更直接的移動和複用DOM,同時少了一層vnode等減小內層消耗,但卻喪失了更好的可維護性,若是能將這個功能單獨做爲一個插件開發,卻是能夠考慮。若是數據在本地服務器中,彷佛能夠拋棄這個sourceList
,這樣的話頁面就會內存爆減,帶來的結果就是白屏時間稍長。寫的比較快,略顯粗糙,也可能還有BUG,若是有啥BUG請留言咯。