前端面经题记:长列表怎么优化?

源码地址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

相关文章
相关标签/搜索