当页面数据过多,前端渲染大量的DOM时,会造成页面卡死问题,使用分页或则懒加载这些方案也无法解决,这些处理方法在页面加载到足够多的数据的时候,随着页面追加渲染的DOM越来越多,也会导致页面卡顿,甚至卡死。
这时候我们可以把两个方案中和一下,既然在有限的视窗中我们只能看到一部分的数据,那么我们就通过计算可视范围内的单元格,这样就保证了每一次拖动,我们渲染的 DOM 元素始终是可控的,不会像数据分页方案怕一次性渲染过多,也不会发生无限滚动方案中的老数据堆积现象。所以就有了虚拟滚动这一方案。

虚拟滚动

接下来我们用一张图来表示虚拟滚动的表现形式。
在这里插入图片描述
根据图中我们可以看到,无论我们如何滚动,我们可视区域的大小其实是不变的,那么要做到性能最大化就需要尽量少地渲染 DOM 元素,而这个最小值也就是可视范围内需要展示的内容,也就是图中的绿色区块,在可视区域之外的元素均可以不做渲染。

那么问题就来了,如何计算可视区域内需要渲染的元素,我们通过如下几步来实现虚拟滚动:

  • 每一行的高度需要相同,方便计算

  • 需要得知渲染的数据量(数组长度),可基于总量和每个元素的高度计算出容器整体的所需高度,这样就可以伪造一个真实的滚动条

  • 获取可视区域的高度

  • 在滚动事件触发后,滚动条的距顶距离也可以理解为这个数据量中的偏移量,再根据可视区域本身的高度,算出本次偏移的截止量,这样就得到了需要渲染的具体数据

  • 如果类似于渲染一个宽表,单行可横向拆分为多列,那么在X轴上同理实现一次,就可以横向的虚拟滚动

效果如图:
在这里插入图片描述
在这里插入图片描述

<template>
  <el-table
    :data="tableData"
    ref="tableRef"
    style="width: 900px"
    max-height="380"
    border
    stripe
    class="myTable"
  >
    <el-table-column
      prop="date"
      label="必要元素:"
      min-width="150"
      align="center"
      fixed="left"
    >
    </el-table-column>
    <el-table-column label="每一行高度必须相同">
      <el-table-column
        prop="name"
        label="class不能为【myTable】"
        min-width="180"
        align="center"
      >
      </el-table-column>
      <el-table-column label="ref不能为【tableRef】">
        <el-table-column
          prop="province"
          label="省份"
          min-width="150"
          align="center"
        >
        </el-table-column>
        <el-table-column
          prop="city"
          label="市区"
          min-width="150"
          align="center"
        >
        </el-table-column>
        <el-table-column
          prop="address"
          label="地址"
          min-width="150"
          align="center"
        >
        </el-table-column>
      </el-table-column>
    </el-table-column>
    <el-table-column label="操作" fixed="right" min-width="160" align="center">
      <template>
        <el-button size="mini">编辑</el-button>
        <el-button size="mini" type="danger">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

<script>
export default {
  data() {
    return {
      tableData: [], // 需要渲染的数据
      saveDATA: [], // 所有数据
      tableRef: null, // 设置了滚动的那个盒子
      tableWarp: null,
      fixLeft: null,
      fixRight: null,
      tableFixedLeft: null,
      tableFixedRight: null,
      scrollTop: 0,
      num: 0,
      start: 0,
      end: 42, // 3倍的pageList
      starts: 0, // 备份[保持与上一样]
      ends: 42, // 备份[保持与上一样]
      pageList: 14, // 一屏显示
      itemHeight: 41, // 每一行高度
      timeOut: 400 // 延迟
    }
  },
  watch: {
    num: function(newV) {
    // 因为初始化时已经添加了3屏的数据,所以只有当滚动到第3屏时才计算位移量
      if (newV > 1) {
        this.start = (newV - 1) * this.pageList
        this.end = (newV + 2) * this.pageList
        // 使用延时器会造成页面短暂白屏的问题,使用requestAnimationFrame则可以解决
        // setTimeout(() => {
        // // 计算偏移量
        //   this.tableWarp.style.transform = `translateY(${this.start *
        //     this.itemHeight}px)`
        //   if (this.fixLeft) {
        //     this.fixLeft.style.transform = `translateY(${this.start *
        //       this.itemHeight}px)`
        //   }
        //   if (this.fixRight) {
        //     this.fixRight.style.transform = `translateY(${this.start *
        //       this.itemHeight}px)`
        //   }
        //   this.tableData = this.saveDATA.slice(this.start, this.end)
        // }, this.timeOut)
        requestAnimationFrame(() => {
          // 计算偏移量
          this.tableWarp.style.transform = `translateY(${this.start *
            this.itemHeight}px)`
          if (this.fixLeft) {
            this.fixLeft.style.transform = `translateY(${this.start *
              this.itemHeight}px)`
          }
          if (this.fixRight) {
            this.fixRight.style.transform = `translateY(${this.start *
              this.itemHeight}px)`
          }
          this.tableData = this.saveDATA.slice(this.start, this.end)
        })
      } else {
        requestAnimationFrame(() => {
          this.tableData = this.saveDATA.slice(this.starts, this.ends)
          this.tableWarp.style.transform = `translateY(0px)`
          if (this.fixLeft) {
            this.fixLeft.style.transform = `translateY(0px)`
          }
          if (this.fixRight) {
            this.fixRight.style.transform = `translateY(0px)`
          }
        })
        // setTimeout(() => {
        //   this.tableData = this.saveDATA.slice(this.starts, this.ends)
        //   this.tableWarp.style.transform = `translateY(0px)`
        //   if (this.fixLeft) {
        //     this.fixLeft.style.transform = `translateY(0px)`
        //   }
        //   if (this.fixRight) {
        //     this.fixRight.style.transform = `translateY(0px)`
        //   }
        // }, this.timeOut)
      }
    }
  },
  created() {
    this.init()
  },
  mounted() {
    this.$nextTick(() => {
    // 设置了滚动的盒子
      this.tableRef = this.$refs.tableRef.bodyWrapper
      // 左侧固定列所在的盒子
      this.tableFixedLeft = document.querySelector(
        '.el-table .el-table__fixed .el-table__fixed-body-wrapper'
      )
      // 右侧固定列所在的盒子
      this.tableFixedRight = document.querySelector(
        '.el-table .el-table__fixed-right .el-table__fixed-body-wrapper'
      )
      /**
       * fixed-left | 主体 | fixed-right
       */
      // 主体改造
      // 创建内容盒子divWarpPar并且高度设置为所有数据所需要的总高度
      let divWarpPar = document.createElement('div')
      // 如果这里还没获取到saveDATA数据就渲染会导致内容盒子高度为0,可以通过监听saveDATA的长度后再设置一次高度
      divWarpPar.style.height = this.saveDATA.length * this.itemHeight + 'px'
      // 新创建的盒子divWarpChild
      let divWarpChild = document.createElement('div')
      divWarpChild.className = 'fix-warp'
      // 把tableRef的第一个子元素移动到新创建的盒子divWarpChild中
      divWarpChild.append(this.tableRef.children[0])
      // 把divWarpChild添加到divWarpPar中,最把divWarpPar添加到tableRef中
      divWarpPar.append(divWarpChild)
      this.tableRef.append(divWarpPar)

      // left改造
      let divLeftPar = document.createElement('div')
      divLeftPar.style.height = this.saveDATA.length * this.itemHeight + 'px'
      let divLeftChild = document.createElement('div')
      divLeftChild.className = 'fix-left'
      this.tableFixedLeft &&
        divLeftChild.append(this.tableFixedLeft.children[0])
      divLeftPar.append(divLeftChild)
      this.tableFixedLeft && this.tableFixedLeft.append(divLeftPar)

      // right改造
      let divRightPar = document.createElement('div')
      divRightPar.style.height = this.saveDATA.length * this.itemHeight + 'px'
      let divRightChild = document.createElement('div')
      divRightChild.className = 'fix-right'
      this.tableFixedRight &&
        divRightChild.append(this.tableFixedRight.children[0])
      divRightPar.append(divRightChild)
      this.tableFixedRight && this.tableFixedRight.append(divRightPar)

      // 被设置的transform元素
      this.tableWarp = document.querySelector(
        '.el-table .el-table__body-wrapper .fix-warp'
      )
      this.fixLeft = document.querySelector(
        '.el-table .el-table__fixed .el-table__fixed-body-wrapper .fix-left'
      )
      this.fixRight = document.querySelector(
        '.el-table .el-table__fixed-right .el-table__fixed-body-wrapper .fix-right'
      )

      this.tableRef.addEventListener('scroll', this.onScroll)
    })
  },
  methods: {
    init() {
      this.saveDATA = []
      for (let i = 0; i < 10000; i++) {
        this.saveDATA.push({
          date: i,
          name: '王小虎' + i,
          address: '1518',
          province: 'github:',
          city: 'divcssjs',
          zip: 'divcssjs' + i
        })
      }
      this.tableData = this.saveDATA.slice(this.start, this.end)
    },
    onScroll() {
      this.scrollTop = this.tableRef.scrollTop
      this.num = Math.floor(this.scrollTop / (this.itemHeight * this.pageList))
    }
  }
}
</script>
<style lang="less" scoped>
.myTable {
  /deep/ td {
    padding: 6px 0 !important;
  }
}

/*滚动条样式*/

/deep/ .el-table__body-wrapper::-webkit-scrollbar {
  /*滚动条整体样式*/
  width: 6px;
  /*高宽分别对应横竖滚动条的尺寸*/
  height: 8px;
}

/deep/ .el-table__body-wrapper::-webkit-scrollbar-thumb {
  /*滚动条里面小方块*/
  border-radius: 2px;
  background: #666;
}

/deep/ .el-table__body-wrapper::-webkit-scrollbar-track {
  /*滚动条里面轨道*/
  background: #ccc;
}
</style>


使用虚拟列表做投屏功能
投屏页面 buletinFullscreen.vue

<!--
 * @Last Modified by:   Damon
 * @Last Modified time: 2023-2-14
 * @content: 订单看板投屏
-->
<template>
	<!-- 设置虚拟列表滚动条的盒子 -->
  <div id="buletinFullscreen">
    <div class="list-top">
      <p class="list-tit">订单看板</p>
      <ul class="list-lists">
        <li>已接单(数量): <span style="color: #008300">1</span></li>
        <li>生产中(数量): <span style="color: #D3831A">2</span></li>
        <li>出货中(数量): <span style="color: #128BFF">3</span></li>
        <li>出货完成(数量): <span style="color: #585858">4</span></li>
      </ul>
      <button
        class="cancel-btn"
        size="mini"
        @click="exitListFullScreen"
      >{{ $t('qxtp') }}</button>
    </div>
	<!-- 设置虚拟列表这个盒子的总高度,即所有数据显示所需要的高度,比如100*163,其中100为所有数据条数,163为每条列表展示的高度 -->
    <div class="list-div-content" id="buletinScrollBox" ref="contentBox">
			<!-- 真正展示内容的盒子,也是设置transform的盒子 -->
          <div id="buletinScrollContent" v-if="tableDataFullscreen && tableDataFullscreen.length>0">
		  <!-- 每一项 -->
            <div class="list-title-div" v-for="(item) in tableDataFullscreen" :key="item.id">
              {{ item.code }}
            </div>
          </div>
        </div>
  </div>
</template>
<script>
export default {
  components: {},
  props: {},
  data () {
    return {
      tableDataFullscreen: [], // 需要显示的数据
      scrollDomBox: null, // 设置了滚动的那个盒子
      noScroll: true, // 是否没有滚动条
      isBottom: false,
      setIntervalTimer: null,
      setTimeoutTimer: null,
      setTimeoutTimer1: null,

      allTableDataFullscreen: [], // 所有数据
      tableWarp: null,
      scrollTop: 0,
      num: 0,
      start: 0,
      end: 90, // 3倍的pageList
      starts: 0, // 备份[保持与上一样]
      ends: 90, // 备份[保持与上一样]
      pageList: 30, // 需要显示的数据的条数
	  clientList: 10, // 可视区域能显示的条数
      itemHeight: 110, // 每一行高度
    }
  },
  watch: {
    isBottom: function() {
      // 触底和触顶后重新请求一次数据
      setTimeout(() => {
        this.clearIntervalFn()
        this.getListData(true)
      },5000)
    },
    num: function(newV) {
    // 因为初始化时已经添加了3屏的数据,所以只有当滚动到第3屏时才计算位移量
	console.log(newV,'newV');
      if (newV > 1) {
        this.start = (newV - 1) * this.pageList
        this.end = (newV + 2) * this.pageList
        requestAnimationFrame(() => {
          // 计算偏移量
          this.tableWarp.style.transform = `translateY(${this.start *
            this.itemHeight}px)`
          this.tableDataFullscreen = this.allTableDataFullscreen.slice(this.start, this.end)
        })
      } else {
        requestAnimationFrame(() => {
          this.tableDataFullscreen = this.allTableDataFullscreen.slice(this.starts, this.ends)
          this.tableWarp.style.transform = `translateY(0px)`
        })
      }
    }
  },
  created() {},
  mounted() {
    this.getListData()
  },
  destroyed() {
    // 清除定时器和延时器
    this.clearIntervalFn()
    this.clearTimeoutFn(this.setTimeoutTimer)
    this.clearTimeoutFn(this.setTimeoutTimer1)
    // 取消监听全屏事件
    this.removeScreenFn()
  },
  
  methods: {
	  // 计算当前屏数
    onScroll() {
      this.scrollTop = this.scrollDomBox.scrollTop
      this.num = Math.floor(this.scrollTop / (this.itemHeight * this.pageList))
    },
    clearIntervalFn() {
      clearInterval(this.setIntervalTimer)
    },
    clearTimeoutFn(timer) {
      clearTimeout(timer)
    },
    getListData(isFalg) {
		if (!isFalg) {
			this.listenerScreenFn()
		}
		let arr = []
		// 模拟异步请求
		setTimeout(() => {
			for (var i = 0; i < 10000; i++) {
				let obj = {
					code: '列表' + i
				}
				arr.push(obj)
			}
			this.allTableDataFullscreen = arr;
			// 首屏的时候要多加上一屏的数据才能无缝衔接
			const count = this.pageList + this.clientList;
			this.tableDataFullscreen = this.allTableDataFullscreen.slice(0,count)
			this.$nextTick(() => {
				// 设置了滚动的盒子
				this.scrollDomBox = document.getElementById("buletinFullscreen");
				// 高度设置为所有数据所需要的总高度
				let divWarpPar = document.getElementById('buletinScrollBox')
				// 如果这里还没获取到数据就渲染会导致内容盒子高度为0,可以通过监听长度后再设置一次高度
				// 头部高度为80
				if (divWarpPar) {
					divWarpPar.style.height = this.allTableDataFullscreen.length * this.itemHeight + 80 + 'px'
				}
				console.log(this.allTableDataFullscreen.length * this.itemHeight,'kkk');
				
				
				// 被设置的transform元素
				this.tableWarp = document.getElementById('buletinScrollContent')
				
				this.scrollDomBox && this.scrollDomBox.addEventListener('scroll', this.onScroll)
				let scrollBox = document.getElementById("buletinFullscreen");
				// 电脑屏幕的刷新率为60左右
				this.setIntervalTimer = setInterval(() => {
				  this.autoScroll(scrollBox)
				  this.onScroll()
				},60)
				
			})
		},2000)
    },
    // 自动滚动
    autoScroll(scrollDom) {
      if ((scrollDom.scrollTop + scrollDom.clientHeight < scrollDom.scrollHeight && !this.isBottom) || !scrollDom.scrollTop) {
        scrollDom.scrollTop += 1
        this.isBottom = false
      } else {
		  // 触底则向上滚动
        scrollDom.scrollTop -= 1
        this.isBottom = true
      }
      if (!scrollDom.scrollTop && this.noScroll) {
        this.noScroll = false
        // 没有滚动条时触发
        this.setTimeoutTimer1 = setTimeout(() => {
          this.clearIntervalFn()
          this.getListData()
        },30000)
      }
    },
    /**
     * @description 全屏
	 * requestFullscreen方法必须由用户主动交互触发,否则会报错
     */
    listFullScreen() {
      const element = document.documentElement
      if (element.requestFullscreen) {
        element.requestFullscreen()
      } else if (element.mozRequestFullScreen) {
        element.mozRequestFullScreen()
      } else if (element.msRequestFullscreen) {
        element.msRequestFullscreen()
      } else if (element.webkitRequestFullscreen) {
        element.webkitRequestFullscreen()
      }
    },

    /**
     * @description 退出全屏
     */
    exitListFullScreen() {
      if (document.fullscreenElement) {
        document.exitFullscreen()
      } else if (document.mozCancelFullScreen) {
        document.mozCancelFullScreen()
      } else if (document.msExitFullscreen) {
        document.msExitFullscreen()
      } else if (document.webkitExitFullscreen) {
        document.webkitExitFullscreen()
      }
    },
    changeFullscreen() {
      console.log('监听全屏事件');
      const fullscreenEle = document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement
      console.log(fullscreenEle,'fullscreenEle');
      let flag = false
        if (fullscreenEle) {
          //浏览器进入全屏
          flag = true
        } else {
          flag = false
        }
        this.$emit('changeShowFlag',flag)
    },
        /**
     * @description 监听全屏事件
     */
    listenerScreenFn() {
      this.listFullScreen()
      if (document.exitFullscreen) {
        document.addEventListener('fullscreenchange', this.changeFullscreen)
      } else if (document.mozCancelFullScreen) {
        document.addEventListener('mozfullscreenchange', this.changeFullscreen)
      } else if (document.msExitFullscreen) {
        document.addEventListener('MSFullscreenChange', this.changeFullscreen)
      } else if (document.webkitCancelFullScreen) {
        document.addEventListener('webkitfullscreenchange', this.changeFullscreen)
      }
    },
    /**
    * @description 移除全屏事件
    */
    removeScreenFn() {
      if (document.exitFullscreen) {
        document.removeEventListener('fullscreenchange', this.changeFullscreen)
      } else if (document.mozCancelFullScreen) {
        document.removeEventListener('mozfullscreenchange', this.changeFullscreen)
      } else if (document.msExitFullscreen) {
        document.removeEventListener('MSFullscreenChange', this.changeFullscreen)
      } else if (document.webkitCancelFullScreen) {
        document.removeEventListener('webkitfullscreenchange', this.changeFullscreen)
      }
    },
  }
}
</script>
<style lang="less" scoped>
#buletinFullscreen {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  overflow-y: auto;
  z-index: 1002;
  .list-top{
    position: fixed;
    z-index: 1003;
    width:100%;
    transition: top .3s ease;
    border-bottom:1px solid #ebeef2 ;
    margin-left: 0px;
    padding: 0 15px;
    height: 80px;
    display: flex;
    align-items: center;
    color: #333333;
    .cancel-btn {
      position: absolute;
      top: 25px;
      right: 15px;
    }
  }
  .list-tit{
    margin-right: 30px;
    margin-left: 10px;
    font-weight: 500;
    font-size: 24px;
  }
  .list-lists{
    display: flex;
    margin-top: 0px;
    li{
      margin-right: 30px;
    }
  }
  .list-div-content{
    padding: 95px 15px 15px;
    background-color: #ebeef2;
    min-height: 100%;
    overflow: hidden;
    .list-title-div{
		height: 100px;
      width: 100%;
      display: flex;
      flex-direction: column;
      justify-content: center;
      padding: 10px 15px;
      background: #ffffff;
      margin-bottom: 10px;
      position: relative;
	  overflow: hidden;
    }
  }
}
</style>

引入投屏页

<template>
	<div>
		<button
		  size="mini"
		  @click="handlerClick"
		>投屏</button>
		<!-- 投屏页面 必须使用v-if -->
		<buletin-fullscreen v-if="isShowFullScreen" @changeShowFlag="handlerClick"></buletin-fullscreen>
	</div>
</template>
<script>
import buletinFullscreen from '~/components/bulletin/buletinFullscreen.vue';

export default {
  components: {
	  buletinFullscreen
  },
  data() {
    return {
      isShowFullScreen:false,// 是否全屏
    };
  },
  mounted() {
    window.addEventListener("keydown", this.keyDownFuns)// 监听按键事件
  },
  methods: {
    // 监听F11键盘事件
    keyDownFuns(event) {
      if (event.keyCode === 122) {
        event.preventDefault();
        this.handlerClick(true)
      }
    },
	handlerClick(flag) {
		this.isShowFullScreen= flag
	}

  }
};
</script>

<style lang="less" scoped>
	
</style>

element表格组件实现虚拟滚动,解决卡顿问题
「前端进阶」高性能渲染十万条数据(时间分片)

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐