背景:系统中有一个添加品牌的搜索框,当搜索类目不做限制的时候,全部的品牌列表会有1W多个,这时候在框架的加持下,操作速度感人。可以在codesandbox.io/s/pure-vue-…中体验一下,甚至不用打开控制台看console输出,就可以感受到载入长列表和重置之间切换时,页面停止响应的时间。
问题产生原因
DOM节点数量过多,浏览器渲染吃力
(图片引用自zhuanlan.zhihu.com/p/26022258)
其实不光是初次渲染时间长,如果有大量节点出现,那么在滚动的时候,也能明显感受到不流畅的滚动现象。
可选方案
懒加载
通过懒加载的方式,在出现长列表的时候,第一次并不完全渲染所有的DOM节点,即可解决一部分场景下的问题。
优点:实现简单
缺点:
- 想要定位到某一个位置的数据会非常困难
- 如果一直加载到底,那么最终还是会出现大量的DOM节点,导致滚动不流畅
虚拟渲染
懒加载无法满足真正的长列表展示,那么如果真正要解决此类问题该怎么办?还有一种思路就是:列表局部渲染,又被称为虚拟列表.
当前比较知名的一些第三方库有vue-virtual-scroller、react-tiny-virtual-list、react-virtualized。它们都可以利用局部加载解决列表过长的问题的,vue-virtual-scroller、react-tiny-virtual-list一类的方案只支持虚拟列表,而react-virtualized这种大而全的库则是支持表格、集合、列表等多种情况下的局部加载方案。
单纯列表虚拟渲染
我们先看下vue-virtual-scroller、react-tiny-virtual-list这种纯虚拟列表的解决方案。它们的实现原理是利用视差和错觉制作一份出一份“虚拟”列表,一个虚拟列表由三部分组成:
- 视窗口
- 虚拟数据列表(数据展示)
- 滚动占位区块(底部滚动区)
虚拟列表侧面图示意:
正面图:
滚动一段距离后:
最终要实现的效果:由滚动占位区块产生滚动条,随着滚动条的移动,在可视窗口展示虚拟数据列表
react-virtualized的二维虚拟渲染
react-virtualized的实现方案和我们上面探讨的不太一样,因为表格是二维的,而列表是一维的(可以认为列表是一种特殊的表格),react-virtualized就是在二维的基础上构建的一套虚拟数据渲染工具。
示意图如下:
蓝色的部分被称为Cell,上面白色线分隔的区块叫做Section。
基本原理:在列表的上方打上一层方格(Section),下面的每个元素(Cell)都能落到某个方格上(Section)。滚动的时候,随着Cell的动态增加,Section也会被动态的创建,将每一个Cell都注册到对应的Section下。根据当前滚动到的Section,可以得到当前Section下包含的Cell,从此将Cell渲染出来。
/*
0 1 2 3 4 5
┏━━━┯━━━┯━━━┓
0┃0 0┊1 3┊6 6┃
1┃0 0┊2 3┊6 6┃
┠┈┈┈┼┈┈┈┼┈┈┈┨
2┃4 4┊4 3┊7 8┃
3┃4 4┊4 5┊9 9┃
┗━━━┷━━━┷━━━┛
Sections to Cells map:
0.0 [0]
1.0 [1, 2, 3]
2.0 [6]
0.1 [4]
1.1 [3, 4, 5]
2.1 [7, 8, 9]
*/
实现方案
由于我们的目的是处理前端超长列表,而react-virtualized的实现方案是基于二维表格的,其List组件也是继承自Grid组件,如果要做列表方案,必须先实现二维的Grid方案。只处理长列表的情况下,实现一个单纯的虚拟列表渲染方案比二维的Grid方案要更合适一些。
基本结构
首先我们按照虚拟列表示意图来规划出若干个元素。.virtual-scroller乃整个滚动列表组件,在最外层监测其滚动事件。在内部我们需放置一个.phantom来撑开容器,使滚动条出现,并且该元素的高度 = 数据总数 * 列表项高度。接着我们在.phantom的上一层,再画出一个ul列表,它被用来动态加载数据,而它的位置和数据将由计算得出。
<template>
<div id="app">
<input type="text" v-model.number="dataLength">条
<div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
<div class="phantom" :style="{height: this.dataLength * itemHeight + 'px'}">
<ul :style="{'margin-top': `${scrollTop}px`}">
<li v-for="item in visibleList" :key="item.brandId" :style="{height: `${itemHeight}px`, 'line-height': `${itemHeight}px`}">
<div>
<div>{{item.name}}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
itemHeight: 60,
visibleCount: 10,
dataLength: 100,
startIndex: 0,
endIndex: 10,
scrollTop: 0
};
},
computed: {
dataList() {
const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
brandId: i + 1,
name: `第${i + 1}项`,
height: this.itemHeight
}));
return newDataList;
},
visibleList() {
return this.dataList.slice(this.startIndex, this.endIndex);
}
},
watch: {
dataList() {
console.time('rerender');
setTimeout(() => {
console.timeEnd('rerender');
}, 0)
}
},
methods: {
onScroll(e) {
}
}
};
</script>
<style lang="stylus" scoped>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
.virtual-scroller {
border: solid 1px #eee;
margin-top: 10px;
height 600px
overflow auto
}
.phantom {
overflow hidden
}
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
}
}
</style>
Make it scroll
上一例中,onScroll函数并没有填写,也就是说虚拟列表中的数据及位置并不会随着我们滚动而更新。这一步,补全onScroll函数。
<template>
<div id="app">
<input type="text" v-model.number="dataLength">条
<div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
<div class="phantom" :style="{height: this.dataLength * itemHeight + 'px'}">
<ul :style="{'margin-top': `${scrollTop}px`}">
<li v-for="item in visibleList" :key="item.brandId" :style="{height: `${itemHeight}px`, 'line-height': `${itemHeight}px`}">
<div>
<div>{{item.name}}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
itemHeight: 60,
visibleCount: 10,
dataLength: 100,
startIndex: 0,
endIndex: 10,
scrollTop: 0
};
},
computed: {
dataList() {
const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
brandId: i + 1,
name: `第${i + 1}项`,
height: this.itemHeight
}));
return newDataList;
},
visibleList() {
return this.dataList.slice(this.startIndex, this.endIndex);
}
},
watch: {
dataList() {
console.time('rerender');
setTimeout(() => {
console.timeEnd('rerender');
}, 0)
}
},
methods: {
onScroll(e) {
const scrollTop = e.target.scrollTop;
this.scrollTop = scrollTop;
console.log('scrollTop', scrollTop);
this.startIndex = Math.floor(scrollTop / this.itemHeight);
this.endIndex = this.startIndex + 10;
}
}
};
</script>
<style lang="stylus" scoped>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
.virtual-scroller {
border: solid 1px #eee;
margin-top: 10px;
height 600px
overflow auto
}
.phantom {
overflow hidden
}
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
}
}
</style>
解决滚动不连贯的问题
上一例中我们滚动时,会发现一定要滚动一段距离后,虚拟列表中的内容才会突然更新一下,而不是循序渐进的过程。
这是因为startIndex由scrollTop/itemHeight计算出来,只能是item高度的倍数,假设scrollTop值在1倍和2倍之间的时候,虚拟列表内的startIndex并不会更新,也不会产生滚动现象
那么如何解决呢?其实我们利用ul元素自身的滚动来“欺骗眼睛”,原理如下图所示:
只需将我们的onScroll函数调整一下。ul的margin-top由计算得出,而不是直接使用e.target.scrollTop。
onScroll(e) {
const scrollTop = e.target.scrollTop;
this.startIndex = Math.floor(scrollTop / this.itemHeight);
this.endIndex = this.startIndex + 10;
this.scrollTop = this.startIndex * this.itemHeight;
}
减少reflow
由于我们每滚动一次,就需要改变一次margin-top,可能会频发引发reflow,那么我们可以考虑降低margin-top改变的频率
onScroll(e) {
const scrollTop = e.target.scrollTop;
const startIndex = Math.floor(scrollTop / this.itemHeight);
let endIndex = startIndex + 10;
if (endIndex > this.dataList.length) {
endIndex = this.dataList.length;
}
// 当前滚动高度
const currentScrollTop = startIndex * this.itemHeight;
// 如果往下滚了可视区域的一部分,或者往上滚任意距离
if (currentScrollTop - this.scrollTop > this.itemHeight * (this.visibleCount - 1) || currentScrollTop - this.scrollTop < 0) {
this.scrollTop = currentScrollTop;
this.startIndex = startIndex;
this.endIndex = endIndex;
}
}
列表项高度不固定,但可在渲染前获得高度的
上面处理的基本是写死高度的情况,如果是由数据中获取高度的,需要如下改写。
<template>
<div id="app">
<input type="text" v-model.number="dataLength">条{{this.scrollBarHeight}}
<div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
<div class="phantom" :style="{height: this.scrollBarHeight + 'px'}">
<ul :style="{'margin-top': `${scrollTop}px`}">
<li v-for="item in visibleList" :key="item.brandId" :style="{height: `${item.height}px`, 'line-height': `${item.height}px`}">
<div>
<div>{{item.name}}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
visibleCount: 10,
dataLength: 2000,
startIndex: 0,
endIndex: 10,
scrollTop: 0,
bufferItemCount: 4,
dataList: []
};
},
computed: {
visibleList() {
return this.dataList.slice(this.startIndex, this.endIndex + this.bufferItemCount);
},
scrollBarHeight() {
return this.dataList.reduce((pre, current)=> {
console.log(pre, current)
return pre + current.height;
}, 0);
}
},
watch: {
dataList() {
console.time('rerender');
setTimeout(() => {
console.timeEnd('rerender');
}, 0)
}
},
mounted() {
this.dataList = this.getDataList();
},
methods: {
getDataList() {
const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
brandId: i + 1,
name: `第${i + 1}项`,
height: Math.floor(Math.max(Math.random() * 10, 5)) * 10
}));
return newDataList;
},
getScrollTop(startIndex) {
return this.dataList.slice(0, startIndex).reduce((pre, current) => {
return pre + current.height;
}, 0)
},
getStartIndex(scrollTop) {
let index = 0;
let heightAccumulate = 0;
for (let i = 0; i < this.dataList.length; i++) {
if (heightAccumulate > scrollTop) {
index = i - 1;
return index;
}
if (heightAccumulate === scrollTop) {
index = i;
return i
}
heightAccumulate += this.dataList[i].height;
}
return index;
},
onScroll(e) {
const scrollTop = e.target.scrollTop;
this.startIndex = this.getStartIndex(scrollTop);
this.endIndex = this.startIndex + 10;
this.scrollTop = this.getScrollTop(this.startIndex);
}
}
};
</script>
<style lang="stylus" scoped>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
.virtual-scroller {
border: solid 1px #eee;
margin-top: 10px;
height 600px
overflow auto
}
.phantom {
overflow hidden
}
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
}
}
</style>
缓存每个元素的scrollTop
上一个例子中,我们每次getScrollTop都需要重新计算一次,比较浪费性能。可以在一开始的时候加上缓存,这样每次调用时直接从map中取,耗时较小。
generatePositionCache() {
const allHeight = this.dataList.reduce((pre, current, i) => {
const heightSum = pre + current.height;
this.positionCache[i] = pre;
return heightSum;
}, 0)
this.scrollBarHeight = allHeight
}
二分查找减少startIndex的查找时间
另外,还可以利用二分查找来降低getStartIndex的时间
getStartIndex(scrollTop) {
// 在itemTopCache中找到一个左侧小于scrollTop但右侧大于scrollTop的位置
// 复杂度O(n)
// for (let i = 0; i < this.itemTopCache.length; i++) {
// if (this.itemTopCache[i] > scrollTop) {
// return i - 1;
// }
// }
// 复杂度O(logn)
let arr = this.itemTopCache;
let index = -1;
let left = 0,
right = arr.length - 1,
mid = Math.floor((left + right) / 2);
let circleTimes = 0;
while (right - left > 1) {
// console.log('index: ', left, right);
// console.log('height: ', arr[left], arr[right]);
circleTimes++;
// console.log('circleTimes:', circleTimes)
// 目标数在左侧
if (scrollTop < arr[mid]) {
right = mid;
mid = Math.floor((left + right) / 2);
} else if (scrollTop > arr[mid]) {
// 目标数在右侧
left = mid;
mid = Math.floor((left + right) / 2);
} else {
index = mid;
return index;
}
}
index = left;
return index;
}
解决CSS索引问题
正常的列表结构是从第0个元素开始的,我们在CSS中通过选择器2n可以选中偶数行的列表。但虚拟列表不同,我们每次计算出来的startIndex都不同,startIndex为奇数时,2n便表现异常,所以我们需要保证startIndex为一个偶数。解决方法也很简单,如果发现是奇数,则取上一位,确保startIndex一定是偶数。
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
&:nth-child(2n) {
background: #fff;
}
}
}
...
// onScroll中加入
// 如果是奇数开始,就取其前一位偶数
if (startIndex % 2 !== 0) {
this.startIndex = startIndex - 1;
} else {
this.startIndex = startIndex;
}
渲染后才可确定高度的
有种情况是每个列表项中包含的文字数量不同,导致渲染后撑开的高度不一样。那么我们就可以在组件mounted后更新一次列表项的高度。
Item.vue
<template>
<li :key="item.brandId" :style="{height: `${item.height}px`, 'line-height': `${item.height}px`}" ref="node">
<div>
<div>{{item.name}}</div>
</div>
</li>
</template>
<script>
export default {
props: {
item: {
default() {
return {}
},
type: Object
},
index: Number
},
data() {
return {
}
},
mounted() {
this.$emit('update-height', {height: this.$refs.node.getBoundingClientRect().height, index: this.index})
}
}
</script>
Item组件加载时会更新高度,但是整个列表初始化时是没有高度的怎么办?我们需要引入一个估算值:estimatedItemHeight,它代表每个Item的预估高度,每当Item有更新时,则替换掉预估值,同时更新列表的整体高度。
App.vue
<template>
<div id="app">
<input type="text" v-model.number="dataLength">条 Height:{{scrollBarHeight}}
<div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
<div class="phantom" :style="{height: scrollBarHeight + 'px'}">
<ul :style="{'transform': `translate3d(0,${scrollTop}px,0)`}">
<Item v-for="item in visibleList" :item="item" :index="item.index" :key="item.brandId" @update-height="updateItemHeight"/>
</ul>
</div>
</div>
</div>
</template>
<script>
import Item from './components/Item.vue';
export default {
name: "App",
components: {
Item
},
data() {
return {
estimatedItemHeight: 30,
visibleCount: 10,
dataLength: 200,
startIndex: 0,
endIndex: 10,
scrollTop: 0,
scrollBarHeight: 0,
bufferItemCount: 4,
dataList: [],
itemHeightCache: [],
itemTopCache: []
};
},
computed: {
visibleList() {
return this.dataList.slice(this.startIndex, this.endIndex + this.bufferItemCount);
}
},
watch: {
dataList() {
console.time('rerender');
setTimeout(() => {
console.timeEnd('rerender');
}, 0)
}
},
created() {
this.dataList = this.getDataList();
this.generateEstimatedItemData();
},
mounted() {
},
methods: {
generateEstimatedItemData() {
const estimatedTotalHeight = this.dataList.reduce((pre, current, index)=> {
this.itemHeightCache[index] = this.estimatedItemHeight;
const currentHeight = this.estimatedItemHeight;
this.itemTopCache[index] = index === 0 ? 0 : this.itemTopCache[index - 1] + this.estimatedItemHeight;
return pre + currentHeight
}, 0);
this.scrollBarHeight = estimatedTotalHeight;
},
updateItemHeight({index, height}) {
this.itemHeightCache[index] = height;
this.scrollBarHeight = this.itemHeightCache.reduce((pre, current) => {
return pre + current;
}, 0)
let newItemTopCache = [0];
for (let i = 1, l = this.itemHeightCache.length; i < l; i++) {
newItemTopCache[i] = this.itemTopCache[i - 1] + this.itemHeightCache[i - 1]
};
this.itemTopCache = newItemTopCache;
},
getDataList() {
const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
index: i,
brandId: i + 1,
name: `第${i + 1}项`,
height: Math.floor(Math.max(Math.random() * 10, 5)) * 10
// height: 50
}));
return newDataList;
},
getStartIndex(scrollTop) {
// 在heightAccumulateCache中找到一个左侧小于scrollTop但右侧大于scrollTop的位置
// 复杂度O(n)
// for (let i = 0; i < this.itemTopCache.length; i++) {
// if (this.itemTopCache[i] > scrollTop) {
// return i - 1;
// }
// }
// 复杂度O(logn)
let arr = this.itemTopCache;
let index = -1;
let left = 0,
right = arr.length - 1,
mid = Math.floor((left + right) / 2);
let circleTimes = 0;
while (right - left > 1) {
circleTimes++;
// 目标数在左侧
if (scrollTop < arr[mid]) {
right = mid;
mid = Math.floor((left + right) / 2);
} else if (scrollTop > arr[mid]) {
// 目标数在右侧
left = mid;
mid = Math.floor((left + right) / 2);
} else {
index = mid;
return index;
}
}
index = left;
return index;
},
onScroll(e) {
const scrollTop = e.target.scrollTop;
console.log('scrollTop', scrollTop);
let startIndex = this.getStartIndex(scrollTop);
// 如果是奇数开始,就取其前一位偶数
if (startIndex % 2 !== 0) {
this.startIndex = startIndex - 1;
} else {
this.startIndex = startIndex;
}
this.endIndex = this.startIndex + this.visibleCount;
this.scrollTop = this.itemTopCache[this.startIndex] || 0;
}
}
};
</script>
<style lang="stylus" scoped>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
.virtual-scroller {
border: solid 1px #eee;
margin-top: 10px;
height 600px
overflow auto
}
.phantom {
overflow hidden
}
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
&:nth-child(2n) {
background: #fff;
}
}
}
</style>
假如列表项中包含了img标签,并会被img自动撑开,那么我们可以利用img的onload事件来通知列表更新高度。react-virtualized中也有img配合CellMeasure组件使用的例子。那么如果遇到更复杂的高度变化场景该怎么办?
ResizeObserver
ResizeObserver 接口可以监听到 Element 的内容区域或 SVGElement的边界框改变,可以处理复杂的高度变化场景。
但ResizeObserver 兼容性较为一般: caniuse.com/#feat=resiz…
虽然兼容性不太好,但在某些后台系统中,还是可以尝试使用的。
ResizeObserve使用例子
主要调整点就是在list item中增加observe和unobserve方法。
Item.vue
<template>
<li :key="item.brandId" :style="{height: `${item.height}px`, 'line-height': `${item.height}px`}" ref="node">
<div>
<div>{{item.name}}</div>
</div>
</li>
</template>
<script>
export default {
props: {
item: {
default() {
return {}
},
type: Object
},
index: Number
},
data() {
return {}
},
mounted() {
this.observe();
},
methods: {
observe() {
this.resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
console.log(this.index, entry.contentRect.height)
this.$emit('update-height', {height: entry.contentRect.height, index: this.index})
});
this.resizeObserver.observe(this.$refs.node);
},
unobserve() {
this.resizeObserver.unobserve(this.$refs.node);
}
},
beforeDestroy() {
this.unobserve();
}
}
</script>
使用resize dectect库监测高度
对于高度变化场景且兼容性要求较高的,我们可以使用它的polyfill:ResizeObserver Polyfill,支持到IE8以上。另外应注意到它的一些限制:
- Notifications are delivered ~20ms after actual changes happen.
- Changes caused by dynamic pseudo-classes, e.g.
:hover
and:focus
, are not tracked. As a workaround you could add a short transition which would trigger thetransitionend
event when an element receives one of the former classes (example). - Delayed transitions will receive only one notification with the latest dimensions of an element.
如果在没有原生ResizeObserver的情况下想实现:hover及:focus后的size更新观察,那么就要使用element-resize-detector、javascript-detect-element-resize(react-virtualized使用)这一类的第三方库了,当然它们也有一些限制,可以在observation-strategy中详细查阅到。
总结
解决了上述的一系列问题,我们才算实现了一个较为基础的虚拟列表。一些兼容性问题的修复和性能的优化,需要根据实际情况来看。在生产环境中,建议直接使用成熟的第三方库,在兼容性和性能方面有保证。如果时间充裕,可以造个轮子理解下思路,这样在使用第三方组件时也会更加得心应手。
参考文章:
yuque.antfin-inc.com/abraham.cj/…
developer.mozilla.org/zh-CN/docs/…