乐趣区

关于大数据:js前端对于大量数据的展示方式及处理

最近临时脱离了演示我的项目,开始了公司内比拟常见的以表单和列表为主的我的项目。
干一个,爱一个了。从开始的感觉本人都做了炫酷的演示我的项目了,这对我来说就是个小意思,缓缓也开始踩坑,有了些经验总结可谈。


现下不得不说是个数据的时代,有数据就必然有前端来展现。
芜杂的数据通过数据分析(未碰到的点,不讲请搜),提炼出业务相干的数据维度,而前端所做的就是把这些一个个数据通过不同维度(key-value)的形容来展现到页面上。

除去花哨的展现形式(图表等),展现一般的大量列表数据有两种罕用形式,分页和触底加载(滚动加载)。

分页是一种比拟经典的展现形式,碰到的问题比拟少,最多是因为一页展现的数据量大些的时候能够用图片懒加载,来减速一些(不过根本一页也不太会超过 200 个,不然就失去了分页的意义了)。

而最近在实现滚动加载时,呈现了卡顿的状况。


问题背景:

数据量:1500 左右;
数据形容模式:图片 + 局部文字描述;
卡顿呈现在两个中央:

  1. 滚动卡顿,往往是动一下滚轮,就要卡个 2 -3s
  2. 单个数据卡片事件响应卡顿:鼠标浮动,本应 0.5s 向下延展,然而延展之前也会卡个 1 -2s;鼠标点击,本应弹出图片大图,然而弹出前也要卡个 1 -2s

剖析过程:

  1. 卡顿首先想到是渲染帧被缩短了,用控制台的 Performance 查看,能够看出是重排重绘费时间:

    如图,Recalculate Style 占比远远大于其余,一瞬间要渲染太多的卡片节点,重排重绘的量太大,所以造成了次要的卡顿。
    因而,须要缩小霎时的渲染量。

  2. 渲染的数据项与图片渲染无关,于是会想到图片资源的加载和渲染,看控制台的 Network 的 Img 申请中,有大量的 pending 项(pending 项参考下图所示)。

    图片在不停地加载而后渲染,影响了页面的失常运行,因而能够作懒加载优化。

解决过程:

首先针对最次要的缩小霎时渲染量,逐渐由简入繁尝试:

1. 主动触发的延时渲染

由定时器来操作,setTimeoutsetInterval 都能够,留神及时跳出循环即可。
我应用了 setTimeout 来作为第一次尝试(上面代码为后续补的手写,大略意思如此)

应用定时器来分页获取数据,而后 push 进展现的列表数据中:

data() {
  return {
    count: -1,
    params: {
      ... // 申请参数
      pageNo: 0,
      pageSize: 20
    },
    timer:null,
    list: []}
},
beforeDestroy() {if (this.timer) {clearTimeout(this.timer)
    this.timer = null
  }
},
methods: {getListData() {
    this.count = -1
    this.params = {
      ... // 申请参数
      pageNo: 0,
      pageSize: 20
    }
    this.timer = setTimeout(this.getListDataInterval, 1000)
  },
  getListDataInterval() {
    params.pageNo++
    if (params.pageNo === 1) {this.list.length = 0}
    api(params) // 申请接口
      .then(res => {if (res.data) {
          this.count = res.data.count
          this.list.push(...res.data.list)
        }
      })
      .finally(() => {if (count >= 0 && this.list.length < count) {this.timer = setTimeout(this.getListDataInterval, 1000)
        }
      })
  }
  ...
}

后果:首屏渲染速度变快了,不过滚动和事件响应还是略卡顿。
起因剖析:滚动的时候还是有局部数据在渲染和加载,其次图片资源的加载渲染量未变(暂未作图片懒加载)。

2. 改为 滚动触发加载(滚动触发下的“分页”形容的是数据分批次)

滚动触发,益处在于只会在触底的状况下影响用户一段时间,不会在开始时始终影响用户,而且触底也是由用户操作概率产生的,绝对比下,体验性减少。
此处有两种做法:

  • 滚动触发“分页”申请数据,
    毛病:除了第一次,之后每次滚动触发展现数据会比下一种消耗多一个申请的工夫
  • 一次性获取所有数据存在内存中,滚动触发“分页”展现数据。
    毛病:第一次一次性获取所有数据的工夫,比上一种消耗多一点工夫

上述两种做法,可视数据的具体数量决定(据共事所尝试,两三万个数据的获取工夫在 1s 以上,不过这个也看数据结构的复杂程度和后端查数据的形式),决定前能够调后端接口试一下工夫。

例:联合我本次我的项目的理论状况,不须要一次性获取所有的数据,能够一次性获取一个工夫点的数据,而每个工夫点的数据不会超过 3600 个,这就属于一个比拟小的量,尝试下来一次性获取的工夫根本不超过 500ms,于是我抉择第二种

先一次性获取所有数据,由前端管制滚动到间隔底部的肯定间隔,push 一定量的数据到展现列表数据中:

data() {
  return {
    timer: null,
    list: [], // 存储数据的列表
    showList: [], // html 中展现的列表
    isLoading: false, // 管制滚动加载
    currentPage: 1, // 前端分批次摆放数据
    currentPageSize: 50, // 前端分批次摆放数据
    lastListIndex: 0, // 记录以后获取到的最新数据地位
    lastTimeIndex: 0, // 记录以后获取到的最新数据地位
  }
},
created() { // 优化点:可做可不做,其中的数值都是依照卡片的宽高间接写入的,因为不是通用组件,所以从简。this.currentPageSize = Math.round((((window.innerHeight / 190) * (window.innerWidth - 278 - 254)) / 220) * 3
  ) // (((window.innerHeight / 卡片高度和竖向间距) * (window.innerWidth - 列表内容距视口左右的总间隔 - 卡片宽度和横向间距)) / 卡片宽度) * 3
// * 3 代表我心愿每次加载至多能多出三个视口高度的数据;列表内容距视口左右的总间隔:是因为我是两边固定宽度,两头适应展现内容的构造
},
beforeDestroy() {if (this.timer) {clearTimeout(this.timer)
    this.timer = null
  }
},
methods: {
  /**
   * @description: 获取工夫点的数据
   */
  getTimelineData(listIndex, timeIndex) {
    if (// this.list 的第一、二层是时间轴 this.list[listIdex].timeLines[timeIndex],在获取工夫点数据之前获取了
      this.list &&
      this.list[listIndex] &&
      this.list[listIndex].timeLines &&
      this.list[listIndex].timeLines[timeIndex] &&
      this.showList &&
      this.showList[listIndex] &&
      this.showList[listIndex].timeLines &&
      this.showList[listIndex].timeLines[timeIndex]
    ) {
      this.isLoading = true
      // 把以后工夫点变成展现状态
      if (!this.showList[listIndex].active) {this.handleTimeClick(listIndex, this.showList[listIndex])
      }
      if (!this.showList[listIndex].timeLines[timeIndex].active)
        this.handleTimeClick(
          listIndex,
          this.showList[listIndex].timeLines[timeIndex]
        )
      if (!this.list[listIndex].timeLines[timeIndex].snapDetailList) {this.currentPage = 1}
      if (!this.list[listIndex].timeLines[timeIndex].snapDetailList // 第一次加载工夫点数据,前面的或条件可省略
      ) {
        
        return suspectSnapRecords({...})
          .then(res => {if (res.data && res.data.list && res.data.list.length) {let show = []
              res.data.list.forEach((item, index) => {show[index] = {}
                if (index < 50) {show[index].show = true
                } else {show[index].show = true
                }
              })
              this.$set(this.list[listIndex].timeLines[timeIndex],
                'snapDetailList',
                res.data.list
              )
              this.$set(this.showList[listIndex].timeLines[timeIndex],
                'snapDetailList',
                res.data.list.slice(0, this.currentPageSize)
              )
              this.$set(this.showList[listIndex].timeLines[timeIndex],
                'showList',
                show
              )
              this.currentPage++
              this.lastListIndex = listIndex
              this.lastTimeIndex = timeIndex
            }
          })
          .finally(() => {this.$nextTick(() => {this.isLoading = false})
          })
      } else { // 此处是工夫点被手动敞开,手动敞开会把 showList 中的数据清空,然而曾经加载过数据的状况
        if (this.showList[listIndex].timeLines[timeIndex].snapDetailList
            .length === 0
        ) {
          this.currentPage = 1
          this.lastListIndex = listIndex
          this.lastTimeIndex = timeIndex
        }
        this.showList[listIndex].timeLines[timeIndex].snapDetailList.push(...this.list[listIndex].timeLines[timeIndex].snapDetailList.slice((this.currentPage - 1) * this.currentPageSize,
            this.currentPage * this.currentPageSize
          )
        )
        this.currentPage++
        this.$nextTick(() => {this.isLoading = false})
        return
      }
    } else {return}
  },
  /**
   * @description: 页面滚动监听,用的是公司外部的框架,就不展现 html 了,不同框架原理都是一样的,只是须要写的代码多与少的区别,如 ElementUI 的 InfiniteScroll,能够间接设置触发加载的间隔阈值
   */
  handleScroll({scrollTop, percentY}) { // 此处的 scrollTop 是组件返回的纵向滚动的已滚动间隔,percentY 则是已滚动百分比
      this.bus.$emit('scroll') // 触发全局的滚动监听,用于图片的懒加载
      this.scrolling = true
      if (this.timer) { // 防抖机制,直至滚动进行才会运行定时器外部内容
        clearTimeout(this.timer)
      }
      this.timer = setTimeout(() => {requestAnimationFrame(async () => {
          // 因为外部有触发重排重绘,所以把代码放在 requestAnimationFrame 中执行
          let height = window.innerHeight
          if (
            percentY > 0.7 && // 保障最开始的时候不要疯狂加载,已滚动 70% 再加载
            Math.round(scrollTop / percentY) - scrollTop < height * 2 && // 保障数据量大后滚动页面长的时候不要疯狂加载,在触底小于两倍视口高度的时候才加载
            !this.isLoading // 保险,不同时运行上面代码,以防运行工夫大于定时工夫
          ) {
            this.isLoading = true
            let len = this.list[this.lastListIndex].timeLines[this.lastTimeIndex].snapDetailList.length // list 为一次性获取所有数据存在内存中
            if ((this.currentPage - 1) * this.currentPageSize < len) { // 前端分批次展现的状况
              this.showList[this.lastListIndex].timeLines[this.lastTimeIndex].snapDetailList.push(...this.list[this.lastListIndex].timeLines[this.lastTimeIndex].snapDetailList.slice((this.currentPage - 1) * this.currentPageSize,
                  this.currentPage * this.currentPageSize
                )
              )
              this.currentPage++
            } else if (this.list[this.lastListIndex].timeLines.length >
              this.lastTimeIndex + 1
            ) { // 前端分批次展现完上一波数据,该月份时间轴高低一个工夫点存在的状况
              await this.getTimelineData(
                this.lastListIndex,
                this.lastTimeIndex + 1
              )
            } else if (this.list.length > this.lastTimeIndex + 1) { // 前端分批次展现完上一波数据,该月份时间轴高低一个工夫点不存在,下一个月份存在的状况
              await this.getTimelineData(this.lastListIndex + 1, 0)
            }
          }
          this.$nextTick(() => {
            this.isLoading = false
            this.scrolling = false
          })
        })
      }, 500)
    },

后果:首屏渲染和事件响应都变快了,只是滑动到底部的时候有些许卡顿。
起因剖析:滑动到底部的卡顿,也是因为一瞬间渲染一堆数据,尽管比一次性展现所有的速度快很多,然而还是存在相比一次性展现不那么重大的重排和重绘,以及图片不停加载渲染的状况。

3. 滚动触发 + 图片懒加载

图片懒加载能够解决每次渲染数据的时候因为图片按加载程序不停渲染产生的卡顿。
滚动触发应用点 2 的代码。
提取通用的图片组件,通过滚动事件的全局触发,来管制每个数据项图片的加载:
如上,点 2 中曾经在 handleScroll 中设置了 this.bus.$emit('scroll') // 触发全局的滚动监听,用于图片的懒加载

// main.js
Vue.prototype.bus = new Vue()
...

以下的在 template 中写 js 不要学噢

// components/DefaultImage.vue
<template>
  <div class="default-image" ref="image">
    <img src="@/assets/images/image_empty.png" v-if="imageLoading" />
    <img
      class="image"
      v-if="showSrc"
      v-show="!imageLoading && !imageError"
      :src="showSrc"
      @load="imageLoading = false"
      @error="
        imageLoading = false
        imageError = true
      "
    />
    <img src="@/assets/images/image_error.png" v-if="imageError" />
  </div>
</template>
<script>
export default {
  name: 'DefaultImage',
  props: {
    src: String, // 图片源
    lazy: Boolean // 懒加载
  },
  data() {
    return {
      imageLoading: true,
      imageError: false,
      showSrc: '', // 渲染的 src
      timer: null
    }
  },
  mounted() {if (this.lazy) {this.$nextTick(() => {this.isShowImage()
      })
      this.bus.$on('scroll', this.handleScroll)
    } else {this.showSrc = this.src}
  },
  beforeDestroy() {if (this.lazy) {this.bus.$off('scroll', this.handleScroll)
    }
    if (this.timer) {clearTimeout(this.timer)
      this.timer = null
    }
  },
  methods: {handleScroll() {if (this.timer) {clearTimeout(this.timer)
      }
      this.timer = setTimeout(this.isShowImage, 300)
    },
    isShowImage() {
      let image = this.$refs.image
      if (image) {let rect = image.getBoundingClientRect()
        const yInView = rect.top < window.innerHeight && rect.bottom > 0
        const xInView = rect.left < window.innerWidth && rect.right > 0
        if (yInView && xInView) {
          this.showSrc = this.src
          this.bus.$off('scroll', this.handleScroll)
        }
      }
    }
  }
}
</script>

后果:在点 2 首屏展现快的根底上,事件交互更快了,触发展现数据也快了。
起因剖析:防抖的图片懒加载之后,只在用户滚动进行时,加载视口内的图片,就没有后续一直的加载渲染图片,也就不会因为不停渲染图片而影响事件交互和根底的无图卡片渲染。

以上一顿操作之后曾经合乎本我的项目的需要了。
不过我钻研了一下进阶操作 ????
还能够 只渲染视口元素,非视口用 padding 代替 ,以及 把计算过程放在 Web Worker 多线程执行 ,进一步晋升速度。
待我钻研一下操作补上

退出移动版