前端面經題記:長列表怎麼優化?

源碼地址demohtml

昨天上午接了一個電話面試,聊着聊着接說到了性能優化,而後面試官問到了長列表。其實以前作過的都只是簡單的分頁處理,但面試官問的確定不是這個咯,他關心的是虛擬列表,大概之前粗略看過這個效果的實現源碼,雖然我本身沒實現過但有一些本身的想法,因而blablabla......,可能礙於表達能力有限,也不曉得面試官理解我意思沒😂,因而簡單實現並記錄一下vue

當時體驗這個效果時特地打開performance面板分析了一下,感受不是很滿意。在網上找了一個實現,還原一下當時的場景,看圖:node

前半段是不斷經過滾輪滾動,後半段是快速拖拽滾動條,對於這種滾動相關的功能,我是那麼一丟丟強迫症的......,FPS表現很明顯,有紅色報警了,在看那個CPU圖表,有沒有想將它撫平的衝動???git

長列表優化,自己就是一次優化行爲(廢話),但優化功能的同時這個優化自己不能不考慮優化,通過昨天晚上的一番搗鼓,我最終達到了以下效果:github

一樣,前半段經過滾輪滾動,後半段快速拖拽滾動條。但實現後仍是有一缺陷的,待往後碰到這種需求時再去優化面試

  • 僅支持固定高度(而且要一致)的列表
  • 在滾動很是快的時候,會有閃爍,移動設備上尤其明顯,暫時沒想到什麼好的方法解決,這和scroll事件機制有關

源碼基於vue實現,這裏統一一下詞彙算法

  • Item表示長列表的每一個子項

思路

首先,得明確寫這個功能要達到什麼目的,或者說最終效果瀏覽器

  • 提高長列表頁面的性能
  • 在體驗上,用戶沒法感知你用了長列表
  • 讓這個功能組件化(暫不考慮)

由以上2點推測,我們有事情要作了性能優化

  1. 提高性能主要方向仍是減小長列表頁面的渲染節點數量,優化前是全量渲染,優化後最好只渲染用戶能看到的節點,或者說越少越好
  2. 優化後頁面有和普通長列表頁面同樣的滾動條反饋
  3. 優化後的滾動體驗要很是接近原生滾動體驗
  4. 上拉加載

暫時只能想到這幾點,下面,逐個實現它們。服務器

滑動窗口

爲何說是滑動窗口呢?在本地,咱們保存着一個超長的數據列表,但沒有必要將他們所有加入到視圖中,用戶只須要也只能看到當前視口範圍內顯示的數據,既然這樣,我們就能夠用一個容器存放當前用戶須要看到的數據,而後將這個容器中的數據展現給用戶,能夠將這個容器當作是一個小窗口,當用戶發出要查看更多數據的請求時,移動這個小窗口,而後更新視圖。

那麼這個窗口的跨度有多大呢?

  • 假如恰好是視口的高度,當向下移動窗口的時候,須要將窗口最上方的Item去掉,由於用戶不須要看到了,而後把下一個數據push到窗口最下方,那麼窗口移動很快的時候,更新的頻率也會很是快
  • 假如將窗口再放大一些,就能減少上面的更新頻率,至關於節流,這取決於窗口大小

如今,咱們將窗口放大些,原理簡單用圖理解一下

具體的作法就是,若是一頁展現10條數據,那麼實際上我會渲染20條,而且將這20條數據劃分爲2部分,當可視區移動到容器的邊緣時

  1. 若是可視區的上邊緣碰到容器的上邊緣,用前半部分Item填充後半部分Item,而後在原始數據中往前拿10條數據填充到前半部分,再將容器的位置上移10個Item高度
  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消耗

滾動事件

關於滾動行爲,有幾點須要明確,先看圖(瀏覽器渲染每一幀要作的事情),須要進一步瞭解的朋友能夠去查查相關資料

  1. 滾動不必定是連續的,好比快速拖動滾動條
  2. 滾動事件在每一幀繪製前執行,自帶節流效果,而且和每一幀是「同步」的,只須要保證回調邏輯足夠簡單快捷,儘可能不去觸發迴流操做,就能保證不會影響原有的平滑滾動的效果

滾動條

對滾動行爲的要求決定了得使用原生滾動,其實也很簡單,因爲還須要實現上拉加載功能,咱們在底部確定須要放一個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>
複製代碼

用不用key?

那麼對於容器內的Item,根據vdom diff算法的特性:

  1. 設置key的狀況下,其中一半在更新時只須要調換位置,另一半會被移除,而後會新增一半的DOM,若是我手動快速拖動滾動條,那可能全部DOM都要被刪除而後從新建立。
  2. 不設置key的狀況下,20個Item都不會被刪除,在這種狀況下快速拖動滾動條,就不須要從新建立DOM了,但每一個Item每次都會被就地複用,缺點就是本來能夠只進行移動的節點也被就地複用了

大概猜想沒什麼說服力,我寫完後,對比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請留言咯。

源碼地址demo

相關文章
相關標籤/搜索