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

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


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

除去花哨的展示方式(图表等),展示普通的大量列表数据有两种常用方式,分页和触底加载(滚动加载)。

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

分页

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

滚动加载中

问题背景:

数据量:1500左右;
数据描述形式:图片 + 部分文字描述;
卡顿出现在两个地方:

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


分析过程:

  1. 卡顿首先想到是渲染帧被延长了,用控制台的Performance查看,可以看出是重排重绘费时间:


    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, // 前端分批次摆放数据,currentPage实为下一页
    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多线程执行,进一步提升速度。
*这篇已经太长啦,进阶操作放下一章了。
下一章:js前端展示大数据量数据的进阶操作

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容