使用 Express + Mockjs 模拟新闻类列表API接口
基于 Vue-Cli 脚手架准备基础案例
PS:企业级应用开发,在 Vue WebPack 中灵活配置,可以让我们的项目更加灵活可靠
基础功能实现
使用 Axios 引入模拟数据
//引入axios,并加到原型链中
import axios from "axios";
Vue.prototype.$axios = axios;
export default {
data() {
return {
// 数据请求状态判断
isRequestStatus: true,
// 提示显示信息
msg: "小二正在努力,请耐心等待...",
};
},
created() {
// 分批发送请求时,先请求一部分数据保证数据显示
this.getAllListData(100);
if (!!request && request.length > 0) {
this.allDataList = [...request];
this.isRequestStatus = false;
}
},
methods: {
// 发送请求获取新的请求模拟数据,这个是跨域请求的网络mock数据
getAllListData(num) {
this.isRequestStatus = true;
this.msg = "小二正在努力,请耐心等待...";
this.$axios
.get("http://localhost:4000/data?num=" + num)
.then(res => {
this.isRequestStatus = false;
this.allListData = res.data.list;
})
.catch(() => {
this.msg = "亲,网络请求出错啦!赶快检查吧...";
});
}
}
};
计算滚动容器最大列表容积
根据滚动容器 DOM 元素高度 this.$refs.scrollContainer.innerHeight
和单条数据的固定高度 oneHeight
,计算当前滚动容器最大列表容积数量 containSize
。
注意:
当屏幕
Resize
改变窗口,或者orientationchange
手机横竖屏切换时,滚动容器最大容积数需要动态计算
// 给滚动容器加一个 ref 属性,用来获取当前滚动容器的 DOM 节点
<div class="scroll-container" ref="scrollContainer">
// 首先在data中声明两个属性
data() {
return {
// 列表单条条数据 CSS 高度,这个数值需要固定,根据 CSS 值手动填写,如此才能准确的计算容积
oneHeight: 100,
// 当前页面可以容纳的列表最大数量
containSize: 0
}
}
mounted() {
// 根据显示区域高度,计算可以容纳最大列表数量
this.myresize();
window.onresize = this.myresize;
window.orientationchange = this.myresize;
},
methods: {
// 监听窗口变化动态计算容器最大容积数
myresize: () => {
console.log(this.$refs.scrollContainer.offsetHeight);
// 容积数量可能只截取了单条数据的一部分,所以要进位加一
this.containSize =
Math.ceil(this.$refs.scrollContainer.offsetHeight / this.oneHeight) + 1;
}
}
思考:
现在我将
className
为scroll-container
或者news-box
的高度 100% 设置取消会是什么结果?为什么会这样?
- 当前 ref 节点 DOM 元素
scrollContainer
的高度竟然成了 84,这是什么原因呢?- 原来,这个时候异步请求的新闻列表数据填充虽然改变了 DOM 结构,但是 DOM 计算是异步的,所以导致了这个时候计算的容器高度,仅仅是底部 msg 信息提示 DOM 节点的高度,如何解决?
- 使用
this.nextTick()
解决数据变化后 DOM 更新后的节点信息获取,我们发现变成了 10000 ,这是因为填充了100条高度为100的列表数据导致。
注意:
我们在学习虚拟滚动前,务必要非常清醒的去理解,盒元素高度继承的原理,以及子元素高度溢出滚动的原理。如果滚动容器 CSS 高度使用百分比限制的话,要注意其父元素的高度需要指定,父元素的父元素一样需要指定高度,依次类推,只有这样,才会有当前滚动容器内部的滚动效果。
监听滚动事件动态截取数据
监听用户滚动、滑动事件,根据滚动位置,动态计算当前可视区域起始数据的索引位置 startIndex
,再根据 containSize
,计算结束数据的索引位置 endIndex
,最后根据startIndex
与 endIndex
截取长列表所有数据 allDataList
中需显示的数据列表 showDataList
。
// 给滚动容器添加滚动事件 @scroll="handleScroll"
// 使用passive修饰符,确保默认滚动行为有效
<div class="scroll-container" ref="scrollContainer" @scroll.passive="handleScroll">
// 显示容器最大容积截取的数组数据
<div v-for="(item, index) in showDataList" :key="index">
data() {
return {
// 可视元素开始索引
startIndex:0
}
}
computed: {
// 根据 starIndex 和屏幕容积 containSize 计算 endIndex
endIndex() {
let endIndex = this.startIndex + this.containSize;
// 判断截取到最后元素是否存在,如果不存在则只取最后一位
if (!this.allDataList[endIndex]) {
endIndex = this.allDataList.length - 1;
}
return endIndex;
},
// 根据容器最大容积数,截取显示,实际需要渲染列表,这里也通过计算属性动态依赖计算
showDataList() {
// 根据 starIndex 和 endIndex,截取 allDataList 对应需要显示部分 showDataList
return this.allDataList.slice(this.startIndex, this.endIndex);
}
},
methods: {
// 监听容器滚动事件
handleScroll() {
this.startIndex = ~~(
this.$refs.scrollContainer.scrollTop / this.oneHeight
);
}
}
// 给容器添加 Y 轴可滚动 CSS 属性
.scroll-container {
overflow-y: auto;
}
注意:
- 务必给 scroll-container 添加 overflow-y: auto; 的 CSS 属性,Vue 才能监听滚动触发事件;
- 使用 Computed 计算属性,可以依赖性的计算创建对应属性;
- 数组操作 splice 会改变原始数组,slice 不会改变原始数组
使用计算属性动态设置上下空白占位
思考:
我们设置了根据容器滚动位移动态截取
ShowDataList
数据,现在我们滚动一下发现滚动 2 条列表数据后,就无法滚动了,这个原因是什么呢?在容器滚动过程中,因为动态移除、添加数据节点丢失,进而强制清除了顶部列表元素 DOM 节点,导致滚动条定位向上移位一个列表元素高度,进而出现了死循环
根据 startIndex
和 endIndex
的位置,使用计算属性,动态的计算并设置,上下空白填充的高度样式 blankFillStyle
,使用 padding 或者 margin 进行空白占位都是可以的
<!-- 添加上下空白占位 -->
<div :style="blankFillStyle">
<!-- 循环遍历元素 -->
</div>
computed: {
// blankFillStyle 依赖 计算上下空白占位高度样式
blankFillStyle() {
return {
paddingTop: this.startIndex * 100 + "px",
paddingBottom: (this.allDataList.length - this.endIndex) * 100 + "px"
};
}
}
注意:
- 填充样式必须以盒子包裹的方式包裹所有节点;
- 使用计算属性来自动依赖输出
blankFillStyle
对象;- 在Vue中可以使用对象直接操作 Style 样式,但是要注意「 驼峰式 」的命名规则。
思考:
如果填充样式,使用上下分散占位方式,没有包裹内部列表节点会出现什么现象?为什么?需要怎样处理呢?
@mousewheel.passive="handleScroll"
@touchmove.passive="handleScroll"
完整功能实现
下拉置底自动请求加载数据
async created() {
// 分批发送请求时,先请求一部分数据保证数据显示
let request = await this.getAllListData(100);
if (!!request && request.length > 0) {
this.allDataList = [...request];
this.isRequestStatus = false;
}
},
methods: {
// 发送请求获取新的请求模拟数据,这个是跨域请求的网络mock数据
getAllListData(num) {
this.isRequestStatus = true;
this.msg = "小二正在努力,请耐心等待...";
return this.$axios
.get("http://localhost:4000/data?num=" + num)
.then(res => {
this.isRequestStatus = false;
return res.data.list;
})
.catch(() => {
this.msg = "亲,网络请求出错啦!赶快检查吧...";
return false;
});
},
// 监听容器滚动事件
handleScroll() {
// 获取当前容器在scoll事件中距离顶部的位移 scrollTop 计算可视元素开始索引
let CurrentStartIndex = ~~(
this.$refs.scrollContainer.scrollTop / this.oneHeight
);
// 如果当前可视元素开始索引和记录的 startIndex 开始索引发生变化,才需要更改 showDataList
if (CurrentStartIndex === this.startIndex) return;
// 当前可视元素索引发生变化后,更新记录的 startIndex 值
this.startIndex = CurrentStartIndex;
// PS:因为计算属性依赖关系,startIndex 发生变化,endIndex 会自动触发计算属性的操作
// 同理,根据计算属性依赖关系,showDataList 也会自动触发返回新的值
// 如果下拉到了底部,并且上一次请求已经完成,则触发新的数据更新
// 使用 this.loadingTag 状态进行节流,防止非必要触发
if ( this.containSize + currentIndex > this.listData.length - 1
&& !this.loadingTag ) {
// 请求新的20条新闻数据,如果没有请求到数据则直接return
let newListData = await this.getAllListData(20);
if (!!newListData && newListData.length === 0) return;
// 使用拓展运算符将请求的最新数据写进所有数据的列表
this.listData = [...this.listData, ...newListData];
}
}
}
滚动事件节流定时器优化
思考:
监听滚动事件触发对应函数方法的频率是极高的,该如何做好页面节流优化呢?
// 在data中声明一个属性scrollState用来记录滚动状态
// 监听滚动(滑动)事件,
handelScroll() {
// 只有scrollState值为true的时候才会具体执行
if (this.scrollState) {
this.scrollState = false;
this.setDataStartIndex();
var mytimer = setTimeout(() => {
this.scrollState = true;
window.clearTimeout(mytimer);
},60);
}
},
async setDataStartIndex() {
// 获取当前容器在scoll事件中距离顶部的位移 scrollTop 计算可视元素开始索引
let CurrentStartIndex = ~~(
this.$refs.scrollContainer.scrollTop / this.oneHeight
);
// 如果当前可视元素开始索引和记录的 startIndex 开始索引发生变化,才需要更改 showDataList
if (CurrentStartIndex === this.startIndex) return;
// 当前可视元素索引发生变化后,更新记录的 startIndex 值
this.startIndex = CurrentStartIndex;
// PS:因为计算属性依赖关系,startIndex 发生变化,endIndex 会自动触发计算属性的操作
// 同理,根据计算属性依赖关系,showDataList 也会自动触发返回新的值
// 如果下拉到了底部,并且上一次请求已经完成,则触发新的数据更新
// 使用 this.loadingTag 状态进行节流,防止非必要触发
if ( this.containSize + currentIndex > this.listData.length - 1
&& !this.loadingTag ) {
// 请求新的20条新闻数据,如果没有请求到数据则直接return
let newListData = await this.getAllListData(20);
if (!!newListData && newListData.length === 0) return;
// 使用拓展运算符将请求的最新数据写进所有数据的列表
this.listData = [...this.listData, ...newListData];
}
}
滚动事件节流请求动画帧优化
//兼容低版本浏览器
let requestAnimationFrame =
window.requestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.msRequestAnimationFrame;
//浏览器防抖优化:根据浏览器FPS采用递归方法,队列调用requestAnimationFrame方法实现优化
let fps = 30;
let interval = 1000 / fps;
let then = Date.now();
requestAnimationFrame(() => {
let now = Date.now();
let delta = now - then;
then = now;
this.setDataStartIndex();
if (delta >= interval) {
requestAnimationFrame(arguments.callee);
}
});
设置上下滚动缓冲消除快速滚动白屏
computed: {
// 用来保存实际要渲染到页面的数据
showListData() {
// 设置起始、结尾位置索引
let startIndex = 0;
let endIndex = 0;
// 如果当前滚动的位置还没有完成一屏,则从第1条开始,截取到当前索引位置+屏幕容积+向下缓冲屏幕容积
if (this.currentIndex <= this.containSize) {
startIndex = 0;
} else {
startIndex = this.currentIndex - this.containSize;
}
endIndex = this.currentIndex + this.containSize * 2;
// 判断截取到最后元素是否存在,如果不存在则只取最后一位
if (!this.listData[endIndex]) {
endIndex = this.listData.length - 1;
}
return this.listData.slice(startIndex, endIndex);
},
// 用来动态的计算上下空白padding的占位样式
wrapperStyle() {
let paddingTop = "0px";
let paddingBottom = "0px";
// 判断当前滚动位置
if (this.currentIndex > this.containSize) {
// 当当前滚动位置大于屏幕容积后才填充空白
paddingTop =
(this.currentIndex - this.containSize) * this.oneHeight + "px";
}
let endIndex = this.currentIndex + this.containSize * 2;
// 判断截取到最后元素是否存在,只有最后一个元素存在则填充下空白
if (!!this.listData[endIndex]) {
paddingBottom =
(this.listData.length - endIndex) * this.oneHeight + "px";
}
return {
paddingTop,
paddingBottom
};
}
}
路由切换定位列表滚动位置
思考:
当我们滚动了一段列表后,点击一条新闻查看新闻详情,然后再返回列表页面,发现列表回到顶部了,这个体验很不好,该如何解决?
// 在 app.vue 文件的路由出口添加 keepAlive
<keep-alive>
<router-view />
</keep-alive>
// 在 index.vue 文件中记录相关信息
data(){
return {
// 在data中声明一个属性,用来保存路由切换后的偏移定位
scrollTop: 0
}
},
methods:{
async setDataStartIndex() {
// 根据滚动事件,获取当前容器在scoll事件中距离顶部的位移
this.scrollTop = this.$refs.scrollContainer.scrollTop;
// 根据 scrollTop 计算可视元素开始索引
let CurrentStartIndex = ~~( this.scrollTop / this.oneHeight );
...
}
},
activated() {
//在keep-alive路由模式下,切换路由时确保能够返回用户之前所在位置
this.$nextTick(() => {
this.$refs.scrollContainer.scrollTop = this.scrollTop;
});
},
插件封装调用
剥离代码构建插件文件并直接调用
- 在 src 文件夹下,创建 plugins 文件夹,用来保存我们的自定义插件,并创建插件 VirtualScroll.vue 文件
<template>
<div class="scroll-container" ref="scrollContainer" @scroll.passive="handleScroll">
<!-- 滚动容器内部数据 -->
</div>
</template>
<script>
export default {
// 对应实现虚拟滚动的脚本文件
};
</script>
<style lang="scss" scoped>
.scroll-container {
/* 对应实现虚拟滚动的 css 文件*/
}
</style>
- 新建 index.js 文件输出插件
import VirtualScroll from './VirtualScroll.vue';
const plugin = {
install(Vue) {
Vue.component("VirtualScroll", VirtualScroll);
}
}
export default plugin;
- 在 main.js 文件中,给 Vue 添加全局插件属性
//引入定制化虚拟滚动插件并注册到Vue全局实例上,这里需要注意先后顺序,我们的定制化插件中会用到iView中的组件
import VirtualScroll from "./plugins";
Vue.use(VirtualScroll);
- 在 index.vue 文件中调用插件
<template>
<div class="news-box">
<virtual-scroll />
</div>
</template>
调用插件并传递 Props 参数
调用插件的时候,需要抽离关键定制化的参数信息,向子组件进行通信使用
<template>
<div class="news-box">
<virtual-scroll
:msg ="msg"
:oneHeight ="oneHeight"
:requestUrl ="requestUrl"
:oneRequestDataLength ="oneRequestDataLength"
/>
</div>
</template>
<script>
export default {
data() {
return {
// 请求数据提示信息
msg: "小二正在努力,请耐心等待...",
// 记录单条数据的高度
oneHeight: 100,
// 数据请求的 Url
requestUrl: "http://localhost:4000/data?num=",
// 单次请求数据的条数
oneRequestDataLength: 20,
};
},
};
</script>
在组件中使用 props 接收父组件传递过来的参数
props: {
// 请求数据提示信息
msg: {
default: () => "小二正在努力,请耐心等待...",
type: String,
},
// 记录单条数据的高度
oneHeight: {
default: () => 100,
type: Number,
},
// 数据请求的 Url
requestUrl: {
default: () => "http://localhost:4000/data?num=",
type: String,
},
// 单次请求数据的条数
oneRequestDataLength: {
default: () => 20,
type: Number,
},
}
修改关键传递过来的参数信息
let newList = await this.getNewsList(this.oneRequestDataLength);
// 使用父组件传递过来的请求地址 requestUrl 来替代写死的地址
return this.$axios.get(this.requestUrl + num)
// 追加请求新的数据
let newList = await this.getNewsList(this.oneRequestDataLength);
使用作用域插槽传递单条元素结构
将 VirtualScroll.vue 组件内部的单条元素的 html 结构、css 样式、data 数据,使用作用域插槽传递出去
<div v-for="(item, index) in showDataList" :key="index">
<slot :thisItem="item"></slot>
</div>
在 index.vue 接收子组件中传递过来的单条元素内容,结构、数据、样式
<virtual-scroll v-slot:default="oneItem">
<router-link class="one-new" to="/article">
<!-- 新闻左侧标题、评论、来源部分 -->
<div class="new-left">
<h3>{{ oneItem.thisItem.title }}</h3>
<div>
<p>
<img src="../assets/icons/msg.png" alt="评" />
<span>{{ oneItem.thisItem.reads }}</span>
<span>{{ oneItem.thisItem.from }}</span>
</p>
<h4>{{ oneItem.thisItem.date }}</h4>
</div>
</div>
<!-- 新闻右侧图片部分 -->
<div class="new-right">
<img :src="imgsList[oneItem.thisItem.image]" alt="PIC" />
</div>
</router-link>
</virtual-scroll>
<style lang="scss" scoped>
.news-box {
width: 100%;
max-width: 800px;
height: 100%;
.one-new {
text-decoration: none;
display: block;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
border-bottom: 1px solid #ddd;
padding: 14px 10px 5px;
.new-left {
height: 80px;
position: relative;
h3 {
padding: 0;
margin: 0;
font-size: 16px;
text-align: justify;
color: #555;
}
div {
position: absolute;
width: 100%;
bottom: 10px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
p {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
img {
height: 16px;
}
span {
font-size: 12px;
color: #555;
margin-left: 3px;
margin-right: 3px;
}
}
h4 {
font-size: 12px;
color: #888;
}
}
}
.new-right {
margin-left: 10px;
img {
height: 68px;
}
}
}
}
</style>
技术场景拓展简介
- 使用上下空白占位的方式实现虚拟滚动
- 横屏滑动实现虚拟滚动
- 浮动模型实现虚拟滚动
- 微信小程序长列表虚拟滚动实现