之前这个项目没优化完,就投入到下个项目中去 /(ㄒoㄒ)/~~,不得已鸽了。。。
接上一篇:js前端对于大量数据的展示方式及处理
参考:js 大量数据优化,通用方法
进阶操作就是只渲染视口元素,以及把计算过程放在Web Worker多线程执行
本项目并无对数据作大量计算的需求,所以其他地方暂时用不到Web Worker。
我准备从以下两种方式来实现非视口元素不展示:
- 简单一些:一种是元素本身用占位框来代替,即本来是有内容的卡片,现在用无内容同等大小的空div来代替
- 复杂一些:元素本身也删掉,用padding来保证滚动距离。
因为我的展示方式本身就是比较复杂的,除了数据项还有时间点,在考虑滚动距离的时候也要把时间点的高度考虑进去,先从简单的来,我就先尝试把元素用占位div代替。
解决过程:
方式一:元素用占位div代替——监听事件触发
从实现图片懒加载的全局事件触发中得到启发,数据项替换也可以类似。
把数据项卡片提取为组件
// ResultItem.vue
<template>
<div class="result-item">
<div v-if="showItem">
...
</div>
</div>
</template>
<script>
export default {
name: 'ResultItem',
...
data() {
return {
showItem: true,
...
};
},
...
mounted() {
this.bus.$on('scroll', this.isShowItem); // 监听滚动事件
},
beforeDestroy() {
this.bus.$off('scroll', this.isShowItem);
},
methods: {
isShowItem() {
let node = this.$el;
if (node) {
let rect = node.getBoundingClientRect();
const yInView = rect.top < window.innerHeight && rect.bottom > 0;
const xInView = rect.left < window.innerWidth && rect.right > 0;
// 我直接就只渲染视口内的了,有需要也可以渲染视口加上下一点距离的。
if (yInView && xInView) {
this.showItem = true; // 在视口内则渲染内容
} else {
this.showItem = false; // 不在视口内则不渲染内容,但是 div.result-item(固定大小)保留
}
}
}
...
没想到差别还挺明显的。
卡片内容全部渲染和视口渲染的实际差别:
-
全部渲染:
-
视口渲染:
- 首先是作为用户的直观感受:
同样是不停滚动的操作,视口渲染比全部渲染顺滑很多;
全部渲染随着数据项的增多,渲染节点的也越多,相比之下会卡在底部加载数据然后渲染出来的时间点上,这时滚动轮可能会失效(即卡住啦)
视口渲染相比之下,则较为顺滑,卡住的时间感官上短很多。 - 看Performance性能测试:
第一点其实也可以从上图中的Frames
监测项中看出,视口渲染的Scroll
时间段明显比全部渲染规律很多,而全部渲染甚至会卡在把数据渲染出来的Animation
中;
其次就是 ( ̄▽ ̄)" 标红(渲染帧超时,感官上就是卡)相比之下,视口渲染比全部渲染少,全部渲染的红标都可以连成小毛毛虫了。
刚刚测试的时候忘了把视口渲染的图片懒加载监听关闭(功能重复了),如下图是取消图片懒加载监听的40s不停滚动性能监听:Scripting时间更少了,更顺滑了
方式二:元素用padding代替——scrollTop、scrollHeight、clientHeight
这个方法参考的就是:js 大量数据优化,通用方法
这篇文章讲实话(在下才疏学浅看得脑子比较混( ̄▽ ̄)")。
我理解下来,就是把数据分批次展示,在轮到下一个批次的数据时,就可以把上一个批次的数据换成提前计算好的padding了。这个批次中的数量可以不同,提前规定好即可。
这个理论,其实,就类似于实现图片轮播,同一时间页面上至多只存在两个图片(即两个批次的数据);
也类似于vue的transition过渡,除了本批次要展示的,上一个批次的数据只在过渡期间展示;
用在这里就是,如果scrollTop超过了提前计算好的值,就把数据换成padding。
突然发现其实好多实现的原理都差不多啊!!!
( ̄▽ ̄)" 古人诚不欺我, 举一隅不以三隅反,则不复也。
本项目一个是作了自适应,不同页面条件下展示的数据是不同的,计算好麻烦;还有一个问题是有层级展示数据的,既是计算高度要算进时间点的高度,又是没办法直接给滚动面板设置padding,会把时间轴也顶下来。
所以暂时不作代码演示。后续有时间把时间轴和数据分开展示,可以再试下。
本来是想这么写的, = = 画完图之后又觉得实现好像有迹可循,哎,别后续了,就现在写吧。
上面讲了那么多,可能你们也像我在看原作者的思路一样混,下面一步步拆开来走一遍。
梳理一下:
第一步,要先计算出每个批次的数据数量和高度(此方法不太适合自适应的滚动面板,每次重新计算出每个批次的数量后需要和前一次计算以及当前显示批次整合起来,比较费劲);
第二步,监听滚动,
(1)本批次的数据对应高度“触顶”时,上一批次数据添加进来,直至视口中不再有本批次数据删去对应高度的paddingTop;
(2)本批次的数据对应高度“触底”时,下一批次数据添加进来,直至视口中不再有本批次数据删去对应高度的paddingTop;
解决方案:
确实有了时间轴后设置padding比较麻烦,计算padding也比较麻烦,需要遍历,所以这个项目我选择第一种方案了。
以下是去除时间轴的实现结果:(数据还是放在this.list[listIndex].timeLines[timeIndex].snapDetailList
中,是因为不想改动原有的数据结构,但并不会出现时间轴了,只需将其看作数据的一个容器即可)
data() {
return {
panelLoading: false, // 面板加载状态
currentPage: 1, // 前端分批次摆放数据,此篇文章实为当前页(区分上篇文章实为下一页)
currentPageSize: 50, // 前端分批次摆放数据
currentLinePage: 1, // 无效,后端不处理
isLoading: false, // 控制滚动加载
panelLoading: false, // 初始加载
scrolling: false,
paddingTop: 0,
paddingBottom: 0,
worker: null,
reflect: [],
cardWidth: 216, // 每个卡片的宽度(包括margin)
cardHeight: 189, // 每个卡片的高度(包括margin)
scrollPanelWidth: window.innerWidth - 742, // 滚动面板的宽度,742是视口宽度除了滚动面板之外的固定宽度
scrollPanelHeight: window.innerHeight - 56, // 滚动面板的高度,56是视口高度除了滚动面板之外的固定高度
singleHeight: 0, // 每批次高度
lastHeight: 0 // 最后一排次高度,可能个数不足而其余批次不同
};
},
created() {
this.handleResize(); // 提取为方法,方便后续作resize监听(写代码时需要有意识地把不同功能的代码提取归类)
},
methods: {
/**
* @description: 计算每批次显示个数
*/
handleResize() {
// 本项目每个批次展示数据个数相同,仅最后一个批次可能展示不足,所以出最后一个数据,其余每个批次的数据高度相同
this.currentPageSize =
Math.floor(this.scrollPanelHeight / this.cardHeight) * // 计算视口至多每行个数
Math.floor(this.scrollPanelWidth / this.cardWidth) *// 计算视口至多每列个数(不包含显示不全的)
3; // 3 表示每页渲染三个视口高度的数据
this.singleHeight =
Math.floor(this.scrollPanelHeight / this.cardHeight) * 3 * this.cardHeight;
},
/**
* @description: 重新加载数据
*/
async getRecords(listIndex = 0, timeIndex = 0) {
this.panelLoading = true;
... // 一些初始化操作,包括把滚动面板滚至顶部
await this.getTimelineData(listIndex, timeIndex);
//加载第一个时间点的数据
this.panelLoading = false;
},
/**
* @description: 获取接口数据
*/
getTimelineData(listIndex, timeIndex) {
this.isLoading = true;
return suspectSnapRecords({
...
pageNo: 1,
pageSize: this.list[listIndex].timeLines[timeIndex].count, // 一次查询出该时间点的所有数据
startTime: new Date(this.list[listIndex].timeLines[timeIndex].time) // 此是为了防止滚动加载的数据因为数据库不断新增的数据导致返回数据重复,所以需要传一个首项唯一值给后端来判断此次查询从哪查起,比如这里就是时间值,因为还需要按时间排序。
})
.then((res) => {
if (res.data && res.data.list && res.data.list.length) {
this.$set(
this.list[listIndex].timeLines[timeIndex],
'snapDetailList',
res.data.list
); // 把数据放进储存变量中
this.reflect = []; // 开始计算reflect
const count = Math.ceil(res.data.list.length / this.currentPageSize); // 计算一共可分为几个批次
for (
let i = 0;
i < count;
i++
) {
this.reflect.push({
scrollTop:
this.singleHeight * // 每行高度
i, // i 表示前面已经过几个批次
startIndex: this.currentPageSize * i
});
}
this.lastHeight =
(res.data.list.length - this.reflect[this.reflect.length - 1].startIndex) / Math.floor(this.scrollPanelWidth /
this.cardWidth) * this.cardHeight;
this.$set(
this.showList[listIndex].timeLines[timeIndex],
'snapDetailList',
res.data.list.slice(
this.reflect[this.currentPage - 1].startIndex,
this.reflect[this.currentPage - 1].startIndex +
this.currentPageSize
)
); // 把当前批次的数据放入展示变量中
// 开始计算上下padding
this.paddingTop = 0;
this.paddingBottom =
this.reflect[this.reflect.length - 1].scrollTop - this.singleHeight + this.lastHeight;
}
})
.finally(() => {
this.$nextTick(() => {
this.isLoading = false;
});
});
},
/**
* @description: 页面滚动监听
*/
handleScroll({ scrollTop, percentY }) {
// 此处的scrollTop是组件返回的纵向滚动的已滚动距离,percentY则是已滚动百分比
this.scrolling = true;
if (!this.isLoading) {
if (this.timer) {
// 防抖机制,直至滚动停止才会运行定时器内部内容
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
requestAnimationFrame(async () => {
// this.paddingTop = scrollTop;
// 因为内部有触发重排重绘,所以把代码放在requestAnimationFrame中执行
await this.showNextData(scrollTop);
this.$nextTick(() => {
this.isLoading = false;
this.scrolling = false;
// let height = this.$refs.scrollView.scrollHeight;
// this.paddingBottom = height - scrollTop - window.innerHeight;
});
});
this.timer = null;
}, 500);
}
},
/**
* @description: 展示下一批次数据
*/
async showNextData(scrollTop) {
this.isLoading = true;
let res = []; // 最终展示结果暂存
let currentPage = this.reflect.findIndex((item, index) => {
return item.scrollTop > scrollTop; // 找到下一批次的序号就是当前页currentPage(比currentIndex大1)
});
if (currentPage < 0) currentPage = this.reflect.length; // 最后一个批次是没有下一批次的所以会返回-1,此处纠正为最后一个批次的currentPage
if (this.currentPage !== currentPage) {
// 当前批次改变
this.currentPage = currentPage;
res.push(
...this.list[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList.slice(
this.reflect[this.currentPage - 1].startIndex,
this.reflect[this.currentPage - 1].startIndex + this.currentPageSize
)
);
}
if (
currentPage < this.reflect.length &&
this.reflect[currentPage].scrollTop - scrollTop < this.scrollPanelHeight
) {
// 下一批次和当前批次都需显示在页面中
if (
this.showList[this.lastListIndex].timeLines[this.lastTimeIndex]
.snapDetailList.length <= this.currentPageSize ||
res.length
) {
// 未展示完全,需要把下一批次数据加进来;或页面改变后也是展示两个批次,则需要把对应新的当前批次的下一批次数据加进来
if (!res.length) {
// 当前页未变,需要把当前页的数据加进来
res.push(
...this.list[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList.slice(
this.reflect[this.currentPage - 1].startIndex,
this.reflect[this.currentPage - 1].startIndex +
this.currentPageSize
)
);
}
// 下一批次的数据加进来
res.push(
...this.list[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList.slice(
this.reflect[this.currentPage].startIndex,
this.reflect[this.currentPage].startIndex + this.currentPageSize
)
);
}
} else if (
this.showList[this.lastListIndex].timeLines[this.lastTimeIndex]
.snapDetailList.length > this.currentPageSize &&
res.length === 0
) {
// 不满足展示两个批次的条件,但是数据却有两个批次的数据,且当前页未变(否则已在前加入过该批次数据了)
res.push(
...this.list[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList.slice(
this.reflect[this.currentPage - 1].startIndex,
this.reflect[this.currentPage - 1].startIndex + this.currentPageSize
)
);
}
// 前端分批次展示数据,把计算出的数据推入展示变量中并计算padding
if (res.length) { // 若无改变则跳过
requestAnimationFrame(() => {
this.paddingTop = this.reflect[this.currentPage - 1].scrollTop;
this.paddingBottom =
this.reflect[this.reflect.length - 1].scrollTop +
this.lastHeight -
this.paddingTop -
(this.currentPage < this.reflect.length // 区分最后批次还是之前的批次
? this.singleHeight
: this.lastHeight) -
(res.length <= this.currentPageSize // 区分展示一个批次还是两个批次
? 0
: this.currentPage < this.reflect.length - 1 // 区分两个批次下,下一批次为最后批次还是别的批次
? this.singleHeight
: this.lastHeight);
this.showList[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList = res;
});
}
},
到此,已可以正常滚动面板展示对应数据,得益于vue的虚拟dom,重新赋值,性能损耗也不多。
此时可以明确感觉到,滚动和交互非常顺滑,那种不看控制台性能监测都知道比之前强太多的顺滑。
唯一的缺点就是会有短暂的空白,这个可以通过调节防抖的时间来减少空白的时间,不影响使用。
总结:
这种方式结果页面不渲染过多元素且没有多个监听器,效果是最优的,但是有点局限:(1)多层级的数据展示和计算实现起来比较困难;(2)自适应的数据面板其实还有实现希望,在resize的时候重新计算每页个数currentPageSize
和映射reflect
等,并且记录当前滚动高度scrollTop
,理论上就可以计算出当前页面所需显示的数据了。此处就不再实践了。
方式三:WebWorker优化计算(本项目中用不到)
本项目是单纯的展示数据,所以没有多余的计算过程。
就把方式二的计算当前展示数据的过程放入WebWorker中。
对于本项目这个数量级的数据没有太多优化,权当学习一下WebWorker的使用,对于万级别以上的数据或有奇效。
参考:web worker使用教程
vue-worker:在vue中方便使用web worker
使用vue中的webworker库
安装
npm i -S vue-worker
or
yarn add vue-worker
注册
// main.js
import Vue from 'vue'
import VueWorker from 'vue-worker'
import App from 'App.vue'
Vue.use(VueWorker)
new Vue({
el: '#app',
render: h => h(App)
})
然后就可以使用this.$worker
了
API
具体的API可以看参考 或 作者github库:https://github.com/israelss/vue-worker
这里就搬运一下web worker使用教程,使用run
、create
和销毁。
run
:直接新建worker, worker执行完任务后自动关闭worker线程
export default {
name: 'Index',
data() {
return {
values: [],
num: 100,
worker: null
};
},
destroyed() {
if(this.worker) this.worker = null; // 销毁
},
methods: {
btnClickHandle() {
this.num = this.num + 1;
this.worker = this.$worker
.run(this.getFactorial, [this.num])
.then((res) => {
this.values.push({ num: this.num, value: res });
})
.catch((e) => {
console.error('catch', e);
});
}
},
// 传递给worker的方法
getFactorial(n) {
const factorial = (num) => {
if (typeof num !== 'number') {
throw TypeError(num);
}
if (num <= 1) {
return 1;
}
if (num > 1) {
return num * factorial(num - 1);
}
};
return factorial(n);
}
};
create
:创建的worker会持久化运行
export default {
name: 'Index',
data() {
return {
message: '',
values: [],
num: 100,
defaultNum: 1,
worker: null
};
},
created() {
this.createWorker();
},
destroyed() {
this.worker = null; // 销毁
},
methods: {
handleClick() {
let randomNum = () => Math.floor(Math.random() * 100);
const data = Array.from(new Array(10), () => randomNum());
this.worker
.postMessage('pull-data', [data])
.then((res) => {
this.message = res;
})
.catch((e) => {
console.error(e);
});
},
createWorker() {
this.num = this.defaultNum;
this.worker = this.$worker.create([
{
message: 'pull-data',
func: (data) => {
if (Array.isArray(data)) {
return data.join('-');
}
return data;
}
},
{
message: 'run-task',
func: (id) => {
console.log('run-task', id);
}
}
]);
}
}
};
解决方案:
在worker的计算方法中,扩展运算符...
报错,原因暂无头绪,所以改为数组直接赋值。
created() {
this.handleResize();
// window.addEventListener('resize', this.handleResize);
this.worker = this.$worker.create([
// lulu
{
message: 'get-reflect',
func: (data) => {
let reflect = [];
const count = Math.ceil(data.listLen / data.currentPageSize); // 计算一共可分为几个批次
for (let i = 0; i < count; i++) {
reflect.push({
scrollTop: data.singleHeight * i, // 每行高度 // i 表示前面已经过几个批次
startIndex: data.currentPageSize * i
});
}
return reflect;
}
},
{
message: 'get-showdata',
func: (data) => {
let res = {
list: [],
currentPage: data.currentPage,
paddingTop: 0,
paddingBottom: 0
};
let currentPage = data.reflect.findIndex((item, index) => {
return item.scrollTop > data.scrollTop;
});
if (currentPage < 0) currentPage = data.reflect.length;
if (data.currentPage !== currentPage) {
res.currentPage = currentPage;
// 当前批次改变
res.list = data.saveList.slice(
data.reflect[res.currentPage - 1].startIndex,
data.reflect[res.currentPage - 1].startIndex +
data.currentPageSize
);
}
if (
currentPage < data.reflect.length &&
data.reflect[res.currentPage].scrollTop - data.scrollTop <
data.scrollPanelHeight
) {
// 下一批次和当前批次都显示在页面中
if (
data.showList.length <= data.currentPageSize ||
res.list.length
) {
res.list = data.saveList.slice(
data.reflect[res.currentPage - 1].startIndex,
data.reflect[res.currentPage].startIndex + data.currentPageSize
);
}
} else if (
data.showList.length > data.currentPageSize &&
res.list.length === 0
) {
// 不满足展示两个批次的条件,但是数据确实两个批次的数据,且当前页未变
res.list = data.saveList.slice(
data.reflect[res.currentPage - 1].startIndex,
data.reflect[res.currentPage - 1].startIndex +
data.currentPageSize
);
}
res.paddingTop = data.reflect[res.currentPage - 1].scrollTop;
res.paddingBottom =
data.reflect[data.reflect.length - 1].scrollTop +
data.lastHeight -
res.paddingTop -
(res.currentPage < data.reflect.length
? data.singleHeight
: data.lastHeight) -
(res.length <= data.currentPageSize
? 0
: res.currentPage < data.reflect.length - 1
? data.singleHeight
: data.lastHeight);
return res;
}
}
]);
},
beforeDestroy() {
if (this.worker) {
this.worker = null;
}
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
},
methods: {
// 另一块reflect计算的webworker做法和此处无异,就只展示计算页面展示数据这块的做法了
/**
* @description: 加载下一页数据
*/
async showNextData(scrollTop) {
// lulu
this.isLoading = true;
let params = {
reflect: this.reflect,
scrollTop,
currentPage: this.currentPage,
saveList: this.list[this.lastListIndex].timeLines[this.lastTimeIndex]
.snapDetailList,
currentPageSize: this.currentPageSize,
scrollPanelHeight: this.scrollPanelHeight,
showList: this.showList[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList,
lastHeight: this.lastHeight,
singleHeight: this.singleHeight
};
try {
let {
list: res,
currentPage,
paddingTop,
paddingBottom
} = await this.worker.postMessage('get-showdata', [params]); // worker
// 前端分批次展示的情况
this.currentPage = currentPage;
if (res.length) {
requestAnimationFrame(() => {
this.paddingTop = paddingTop;
this.paddingBottom = paddingBottom;
this.showList[this.lastListIndex].timeLines[
this.lastTimeIndex
].snapDetailList = res;
});
}
} catch (e) {
console.error(e);
}