# vue大数据展示优化
# IntersectionObserver 交叉观察器 虚拟列表
原理:Intersection Observer,不兼容IE浏览器。
IntersectionObserver接口 (从属于Intersection Observer API) 提供了一种异步观察目标元素与其祖先元素或顶级文档视窗(viewport)交叉状态的方法。祖先元素与视窗(viewport)被称为根(root)。
当一个IntersectionObserver对象被创建时,其被配置为监听根中一段给定比例的可见区域。一旦IntersectionObserver被创建,则无法更改其配置,所以一个给定的观察者对象只能用来监听可见区域的特定变化值;可以在同一个观察者对象中配置监听多个目标元素。
var io = new IntersectionObserver(callback, option);
IntersectionObserver是浏览器原生提供的构造函数,接受两个参数:callback是可见性变化时的回调函数,option是配置对象(该参数可选)
构造函数的返回值是一个观察器实例。实例的observe方法可以指定观察哪个 DOM 节点。
// 开始观察
io.observe(document.getElementById('example'));
// 停止观察
io.unobserve(element);
// 关闭观察器
io.disconnect();
上面代码中,observe的参数是一个 DOM 节点对象。如果要观察多个节点,就要多次调用这个方法。
io.observe(elementA);
io.observe(elementB);
callback 参数 目标元素的可见性变化时,就会调用观察器的回调函数callback。
callback一般会触发两次。一次是目标元素刚刚进入视口(开始可见),另一次是完全离开视口(开始不可见)。
var io = new IntersectionObserver(
entries => {
console.log(entries);
}
);
上面代码中,回调函数采用的是箭头函数的写法。callback函数的参数(entries)是一个数组,每个成员都是一个IntersectionObserverEntry对象。举例来说,如果同时有两个被观察的对象的可见性发生变化,entries数组就会有两个成员。
# IntersectionObserverEntry 对象
IntersectionObserverEntry对象提供目标元素的信息,一共有六个属性。
{
time: 3893.92,
rootBounds: ClientRect {
bottom: 920,
height: 1024,
left: 0,
right: 1024,
top: 0,
width: 920
},
boundingClientRect: ClientRect {
// ...
},
intersectionRect: ClientRect {
// ...
},
intersectionRatio: 0.54,
target: element
}
每个属性的含义如下。
- time:可见性发生变化的时间,是一个高精度时间戳,单位为毫秒
- target:被观察的目标元素,是一个 DOM 节点对象
- rootBounds:根元素的矩形区域的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回null
- boundingClientRect:目标元素的矩形区域的信息
- intersectionRect:目标元素与视口(或根元素)的交叉区域的信息
- intersectionRatio:目标元素的可见比例,即intersectionRect占boundingClientRect的比例,完全可见时为1,完全不可见时小于等于0
# 实例:懒加载和无限滚动
惰性加载(lazy load):有时,希望某些静态资源(比如图片),只有用户向下滚动,它们进入视口时才加载,这样可以节省带宽,提高网页性能。这就叫做"惰性加载"。
有了 IntersectionObserver API,实现起来就很容易了。
function query(selector) {
return Array.from(document.querySelectorAll(selector));
}
var observer = new IntersectionObserver(
function(changes) {
changes.forEach(function(change) {
var container = change.target;
var content = container.querySelector('template').content;
container.appendChild(content);
observer.unobserve(container);
});
}
);
query('.lazy-loaded').forEach(function (item) {
observer.observe(item);
});
上面代码中,只有目标区域可见时,才会将模板内容插入真实 DOM,从而引发静态资源的加载。
无限滚动(infinite scroll)的实现也很简单。
var intersectionObserver = new IntersectionObserver(
function (entries) {
// 如果不可见,就返回
if (entries[0].intersectionRatio <= 0) return;
loadItems(10);
console.log('Loaded new items');
});
// 开始观察
intersectionObserver.observe(
document.querySelector('.scrollerFooter')
);
无限滚动时,最好在页面底部有一个页尾栏(又称sentinels)。一旦页尾栏可见,就表示用户到达了页面底部,从而加载新的条目放在页尾栏前面。这样做的好处是,不需要再一次调用observe()方法,现有的IntersectionObserver可以保持使用。
# Option 对象
IntersectionObserver构造函数的第二个参数是一个配置对象。它可以设置以下属性。
- threshold 属性 threshold属性决定了什么时候触发回调函数。它是一个数组,每个成员都是一个门槛值,默认为[0],即交叉比例(intersectionRatio)达到0时触发回调函数。
new IntersectionObserver(
entries => {/* ... */},
{
threshold: [0, 0.25, 0.5, 0.75, 1]
}
);
用户可以自定义这个数组。比如,[0, 0.25, 0.5, 0.75, 1]就表示当目标元素 0%、25%、50%、75%、100% 可见时,会触发回调函数。
- root 属性,rootMargin 属性 很多时候,目标元素不仅会随着窗口滚动,还会在容器里面滚动(比如在iframe窗口里滚动)。容器内滚动也会影响目标元素的可见性,参见本文开始时的那张示意图。
IntersectionObserver API 支持容器内滚动。root属性指定目标元素所在的容器节点(即根元素)。注意,容器元素必须是目标元素的祖先节点。
var opts = {
root: document.querySelector('.container'),
rootMargin: "500px 0px"
};
var observer = new IntersectionObserver(
callback,
opts
);
上面代码中,除了root属性,还有rootMargin属性。后者定义根元素的margin,用来扩展或缩小rootBounds这个矩形的大小,从而影响intersectionRect交叉区域的大小。它使用CSS的定义方法,比如10px 20px 30px 40px,表示 top、right、bottom 和 left 四个方向的值。
这样设置以后,不管是窗口滚动或者容器内滚动,只要目标元素可见性变化,都会触发观察器。
# 注意点
IntersectionObserver API 是异步的,不随着目标元素的滚动同步触发。
规格写明,IntersectionObserver的实现,应该采用requestIdleCallback(),即只有线程空闲下来,才会执行观察器。这意味着,这个观察器的优先级非常低,只在其他任务执行完,浏览器有了空闲才会执行。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
<style type="text/css">
div {
height: 500px;
width: 30%;
margin-bottom: 50px;
}
#a {
background-color: red;
float: left;
}
#b {
background-color: black;
float: left;
}
#c {
background-color: blue;
clear: left;
}
</style>
</head>
<body>
<div id="a"></div>
<div id="b"></div>
<div id="c"></div>
</body>
</html>
<script type="text/javascript">
var io = new IntersectionObserver(
entries => {
entries.forEach(i => {
console.log('Time: ' + i.time);
console.log('Target: ' + i.target);
console.log('IntersectionRatio: ' + i.intersectionRatio);
console.log('rootBounds: ' + i.rootBounds);
console.log(i.boundingClientRect);
console.log(i.intersectionRect);
console.log('================');
});
},
{
/* Using default options. Details below */
}
);
// Start observing an element
io.observe(document.querySelector('#a'));
io.observe(document.querySelector('#b'));
</script>
# DOMContentLoaded
当初始的 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完全加载。
- 无限加载
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title></title>
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" name="viewport" />
<!--<script src='/js/flexible.js'></script>-->
</head>
<style>
html,
body {
height: 100%;
font-size:20px;
}
#loading{
text-align:center;
}
.unit{
height:120px;
border:1px solid black;
}
.container{
max-width:540px;
margin:0px auto;
}
</style>
<body>
<div class='container' id='app'>
<div id='loading'>
loading....
</div>
</div>
</body>
<script>
document.addEventListener('DOMContentLoaded', function() {
var sum=1;
var loadData=function(){
var fragment=document.createDocumentFragment();
for(var i=0;i<10;i++){
var div=document.createElement('div');
div.className='unit';
div.innerText='this is text'+sum;
fragment.appendChild(div);
sum++;
}
document.getElementById('app').insertBefore(fragment,document.getElementById('loading'));
}
var io = new IntersectionObserver(function(entries) {
if(entries[0].isIntersecting){
if(sum<100){
loadData();
}else{
document.getElementById('loading').innerText='到底了...';
io.unobserve(document.getElementById('loading')); // 停止观察
}
}
},{
root: null,
rootMargin : '0px 0px 50px 0px'
});
io.observe(document.getElementById('loading'));
})
</script>
</html>
- 虚拟列表
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
*{
margin:0;
padding:0;
}
.list-wrap{
position: relative;
overflow-y: scroll;
width: 200px;
margin: 100px auto;
box-sizing: border-box;
border: solid 1px red;
}
.list{
width: 100%;
position: absolute;
top: 0;
left: 0;
}
.list li{
width: calc(100% - 20px);
padding-left:20px;
border-bottom: solid 1px blue;
height: 29px;
line-height: 29px;
}
.scroll-bar{
background-color: red;
}
.list{
background: blue;
}
</style>
</head>
<body>
<ul id="app">
<div class="list-wrap" ref="listWrap" @scroll="scrollListener">
<div class="scroll-bar" ref="scrollBar"></div>
<ul class="list" ref="list">
<li v-for="val in showList">{{val}}</li>
</ul>
</div>
</ul>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.3/vue.js"></script>
<script>
new Vue({
el: '#app',
data(){
return {
list: [],//全部显示数据
itemHeight: 30,//每一列的高度
showNum: 10,//显示几条数据
start: 0,//滚动过程显示的开始索引
end: 10,//滚动过程显示的结束索引
timer: 0 //节流
}
},
computed: {
//显示的数组,用计算属性计算
showList(){
return this.list.slice(this.start, this.end);
}
},
mounted(){
//长列表
for (let i = 0; i < 50; i++) {
this.list.push('列表' + i)
}
//计算滚动容器高度
this.$refs.listWrap.style.height = this.itemHeight * this.showNum + 'px';
//计算总的数据需要的高度,构造滚动条高度
this.$refs.scrollBar.style.height = this.itemHeight * this.list.length + 'px';
},
methods: {
scrollListener(){
if(this.timer) return
this.timer = setTimeout( _ =>{
console.log("scroll")
//获取滚动高度
let scrollTop = this.$refs.listWrap.scrollTop;
//开始的数组索引
this.start = Math.floor(scrollTop / this.itemHeight);
//结束索引+1 解决第一个显示一部分 下面会空白一点
this.end = this.start + this.showNum + 1;
//绝对定位对相对定位的偏移量
// this.$refs.list.style.top = this.start * this.itemHeight + 'px';
this.$refs.list.style.transform = `translateY(${this.start * this.itemHeight}px)`; //GPU加速
this.timer = 0;
}, 50)
}
}
})
</script>
</body>
</html>
# vue-virtual-scroller
cnpm i --save vue-virtual-scroller
- 注意:虚拟列表使用需要设置高度,否则不会生效
<template>
<div id="longlist">
<!-- <div v-for='item in arr' :key='item.id'>
<span>{{item.val}}=1</span>
<span>{{item.val}}=2</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
</div> -->
<RecycleScroller
class="scroller"
:items="arr"
:item-size="32"
key-field="id"
v-slot="{ item }"
>
<div class="user">
<span>{{item.val}}=1</span>
<span>{{item.val}}=2</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
<span>{{item.val}}=3</span>
</div>
</RecycleScroller>
</div>
</template>
<script>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
export default{
data(){
return{
arr:[]
}
},
mounted(){
for(let i=0;i<600000;i++){
this.arr.push({
id:i,
val:`钉钉`+i
})
}
},
components:{
RecycleScroller
}
}
</script>
<style>
.scroller{
height: 100%;
}
#longlist{
height:500px;
background: blue !important;
}
</style>
# vue-virtual-scroll-list
cnpm install vue-virtual-scroll-list --save
<template>
<div id="longlist">
<virtual-list style="height: 360px; overflow-y: auto;"
:data-key="'id'"
:data-sources="arr"
:data-component="itemComponent"
/>
</div>
</template>
<script>
import Item from '../components/longlist/item.vue'
import VirtualList from 'vue-virtual-scroll-list'
export default{
data(){
return{
itemComponent: Item,
arr:[]
}
},
mounted(){
for(let i=0;i<1000000;i++){
this.arr.push({
id:i,
val:`钉钉`+i
})
}
},
components:{
VirtualList
}
}
</script>
<style>
.scroller{
height: 200px;
}
#longlist{
height:500px;
background: blue !important;
}
</style>
<template>
<div>
<span>{{ index }} - {{ source.val }}</span>
<span>{{ index }} - {{ source.val }}</span>
<span>{{ index }} - {{ source.val }}</span>
<span>{{ index }} - {{ source.val }}</span>
<span>{{ index }} - {{ source.val }}</span>
<span>{{ index }} - {{ source.val }}</span>
<span>{{ index }} - {{ source.val }}</span>
<span>{{ index }} - {{ source.val }}</span>
<span>{{ index }} - {{ source.val }}</span>
<span>{{ index }} - {{ source.val }}</span>
</div>
</template>
<script>
export default {
name: 'item-component',
props: {
index: { // index of current item
type: Number
},
source: { // here is: {uid: 'unique_1', text: 'abc'}
type: Object,
default () {
return {}
}
}
}
}
</script>
总结
- 上述两种虚拟列表,需要设置高度
- 本地测验vue-virtual-scroller更流畅
- 虚拟列表也是会有上限的,根据业务的复杂度不同,最大的渲染数值也是不同的
- vue-virtual-scroller需要引入css,vue-virtual-scroll-list则自身直接支持子组件嵌入列表