前言
不积跬步无以至千里,从最基础常见的功能开始,一步一个脚印
任务拆解
结构样式:页面布局,加载动画
js功能:触发上拉加载/下拉刷新条件判定,加载动画显示和关闭,数据加载逻辑
业务场景:移动端(上拉加载,下拉刷新)和PC端(上拉加载)
任务实现
结构样式
HTML结构 | css样式 |
---|---|
HTML结构不过多赘述
css样式实现思路:正方形上下左侧border设置颜色,右侧border设置为透明,然后通过border-radius:50%讲正方形变成圆形,添加动画即可
代码如下
.box {
position: relative;
width: 50px;
height: 50px;
border-radius: 50%;
border:10px solid #ccc;
border-right-color: transparent;
animation:loadingAnimation 0.75s infinite ;
}
@keyframes loadingAnimation {
100%{
transform: rotate(360deg);
}
}
js功能实现逻辑
-
业务逻辑
上拉加载和下拉刷新可以拆解为如下步骤
下拉刷新(移动端)
1. touchstart事件,记录起始位置,并判断内容页是否是在顶部 2. touchmove事件,根据pageY与起始位置差值计算移动距离和方向,内容页随之移动(效果上可以优化,手移动到一定距离内容页不动),加载动画出现 3. touchend事件,请求数据并渲染,加载动画关闭,内容页回到顶部
上拉加载(移动端)
- touchstart事件,记录起始位置
- touchmove事件,根据pageY与起始位置差值计算移动距离和方向,内容页随之移动;判断内容页是否是在底部;到达底部,加载动画出现
- touchend事件,请求数据并渲染,加载动画关闭
初始 | 下拉 | 上拉 |
---|---|---|
-
触发条件判定
-
下拉刷新
内容页在可视窗口最顶端 内容页向下拉动
-
上拉加载
内容页滑动到可视窗口最底端
内容页向上拉动
-
后端代码和前端初始代码
再讲下拉刷新前,先把后端服务和前端起始页搭建起来
后端服务基于koa,数据通过mockjs模拟,代码如下
const Koa = require('koa');
const Router = require('koa-router');
const Mock = require('mockjs');
const Random = Mock.Random;
const koaBody = require('koa-body');
const app = new Koa();
const router = new Router();
app.use(koaBody())
function createUserInfo() {
Random.cname();
let name = Mock.mock('@cname');
Random.id()
let id = Mock.mock('@id')
let age = Mock.mock({
"age|1-100": 100
})
return {
name,
id,
...age
}
}
router.post('/data', ctx => {
let user = [];
for (let i = 0; i < 15; i++) {
temp = createUserInfo();
user.push(temp)
}
ctx.body = { 'user': [...user] };
})
app.use(router.routes())
app.listen(3000, () => {
console.log(`服务已经开启,地址http://localhost:3000/`)
})
前端部分基于vue,代码如下
因为前后端跨域,需要在vue.config.js进行代理配置
module.exports = {
devServer:{
proxy:{
'/api':{
target: "http://localhost:3000",
pathRewrite: { "^/api": "" }
}
}
}
}
Refresh
为上拉加载,下拉刷新组件;
@refreshEmit
为下拉刷新数据请求函数
@loadingEmit
为上拉加载数据请求函数
// App.vue
<template>
<div id="app">
<div class="box" style=""></div>
<refresh
ref="refresh"
@refreshEmit="refreshEmit"
@loadingEmit="loadingEmit"
>
<ul>
<li v-for="item in mydata" :key="item.id">{{ item.name }}</li>
</ul>
</refresh>
</div>
</template>
<script>
import axios from "axios";
import Refresh from "@/components/Refresh";
export default {
name: "App",
components: {
Refresh,
},
data() {
return {
mydata: [],
};
},
methods: {
async refreshEmit() {
return await this.getInitData();
},
async loadingEmit() {
return await this.getLoadingData();
},
async getInitData() {
let result = await axios({
baseURL: "/api",
url: "/data",
method: "post",
data: {
page: 1,
},
});
this.mydata.splice(0, this.mydata.length);
this.mydata.push(...result.data.user);
},
async getLoadingData() {
let result = await axios({
baseURL: "/api",
url: "/data",
method: "post",
data: {
page: 1,
},
});
this.mydata.push(...result.data.user);
},
},
async mounted() {
await this.getInitData();
},
};
</script>
下拉刷新功能实现
下拉刷新核心功能:下拉后显示加载动画,内容页一起跟随触摸移动,手指放开后数据更新,关闭加载动画,页面回到初始位置
细节:
- 内容页需在可视窗口最顶端下拉才能开启下拉刷新功能 ---> touchstart
- 下拉过程中内容页移动可用transform:translateY() ---> touchmove
- 数据更新通过emit派发给父组件,数据异步加载完成后,关闭加载动画和页面回到初始位置 ---> touchend
// Refresh.vue
<template>
<div
class="wrapper"
@touchstart="touchstartHandle"
@touchmove="touchmoveHandle"
@touchend="touchendHandle"
>
<div class="refresh-login" ref="refreshLogin">
<div class="circle-rotate refresh" ref="refresh" v-show="isShow.isRefresh"></div>
<slot></slot>
<div class="circle-rotate loading" v-show="isShow.isLoading"></div>
</div>
</div>
</template>
<script>
export default {
name: "Refresh",
data() {
return {
refreshLoginStatus: "normal",//组件当前状态:正常浏览模式normal,下拉刷新模式refresh,上拉加载模式loading
isShow: {//加载动画控制开关
isRefresh: false,
isLoading: false,
},
startPos: {//手指初始按压位置
pageY: 0,
},
dis: {//手移动距离
pageY: 0,
},
};
},
methods: {
touchstartHandle(e) {
//记录起始位置 和 组件距离window顶部的高度
this.startPos.pageY = e.touches[0].pageY;
//内容页在可视窗口最顶端或者在指定的位置(父级元素的顶部)
if (window.scrollY <= 0) {
this.refreshLoginStatus = "refresh";
}
},
touchmoveHandle(e) {
if (this.isShow.isRefresh) return;
let dis = e.touches[0].pageY - this.startPos.pageY;
if (this.refreshLoginStatus === "refresh" && dis > 0) {//下拉刷新成立条件
this.isShow.isRefresh = true;
//下拉到一定距离后,内容页不随touchmove移动
this.$refs.refreshLogin.style.transform = `translateY(${
dis < 100 ? dis : 100
}px)`;
}
},
async touchendHandle(e) {
//异步加载数据
await this.$emit("refreshEmit");
//松手后加载动画消失,并且内容页回到原位置
this.isShow.isRefresh = false;
this.$refs.refreshLogin.style.transform = `translateY(0px)`;
this.refreshLoginStatus = "normal";
},
},
};
</script>
<style>
.wrapper {
background-color: #fff;
}
.circle-rotate {
position: relative;
left: 50%;
width: 20px;
height: 20px;
border-radius: 50%;
border: 10px solid #ccc;
border-right-color: transparent;
animation: loadingAnimation 0.75s infinite;
}
@keyframes loadingAnimation {
0% {
transform: translateX(-50%) rotate(0deg);
}
100% {
transform: translateX(-50%) rotate(360deg);
}
}
.refresh-login {
transition: all .75s linear;
}
</style>
坑点
- touchend没加限定条件,只要松手就触发数据刷新
async touchendHandle(e) {
//加上限定条件,防止不在刷新状态,后面的代码执行
if (!this.isShow.isRefresh) return;
...
}
- 内容页在顶端,如果用户不想刷新网页,只是无意间下拉触碰就刷新页面导致想看的内容被刷新掉
async touchendHandle(e) {
//加上限定条件,防止不在刷新状态,后面的代码执行
if (!this.isShow.isRefresh) return;
//必须下拉一定距离,才进行异步加载数据
this.dis.pageY > 10 && await this.$emit("refreshEmit");
...
}
上拉加载功能实现
移动端
上拉加载核心功能:内容页底部到达可视窗口底部后,显示加载动画,手指放开后数据更新,关闭加载动画
细节:
- 内容页需到达可视窗口最底端上拉才能开启上拉加载功能 ---> touchmove
- 数据更新通过emit派发给父组件,数据异步加载完成后,关闭加载动画和页面回到初始位置 ---> touchend
//Refresh.vue
<template>
<div
class="wrapper"
@touchstart="touchstartHandle"
@touchmove="touchmoveHandle"
@touchend="touchendHandle"
>
<div class="refresh-login" ref="refreshLogin">
<div
class="circle-rotate refresh"
ref="refresh"
v-show="isShow.isRefresh"
></div>
<slot></slot>
<div class="circle-rotate loading" v-show="isShow.isLoading"></div>
</div>
</div>
</template>
<script>
export default {
name: "Refresh",
data() {
return {
refreshLoginStatus: "normal", //组件当前状态:正常浏览模式normal,下拉刷新模式refresh,上拉加载模式loading
isShow: {
//加载动画控制开关
isRefresh: false,
isLoading: false,
},
startPos: {
//手指初始按压位置
pageY: 0,
},
dis: {
//手移动距离
pageY: 0,
},
};
},
methods: {
touchstartHandle(e) {
//记录起始位置 和 组件距离window顶部的高度
this.startPos.pageY = e.touches[0].pageY;
//内容页在可视窗口最顶端或者在指定的位置(父级元素的顶部)
if (window.scrollY <= 0) {
this.refreshLoginStatus = "refresh";
} else {
this.refreshLoginStatus = "loading";
}
},
touchmoveHandle(e) {
let dis = e.touches[0].pageY - this.startPos.pageY;
this.dis.pageY = dis;
/* //触发下拉刷新 */
this.refreshLoginStatus === "refresh" && this.refreshMove(dis);
/* //触发上拉加载 */
if (this.isShow.isLoading) return;
this.refreshLoginStatus === "loading" && this.loadingMove(dis);
},
loadingMove(dis) {
// 计算内容页底部距离可视窗口顶部的距离
let disToTop = this.$refs.refreshLogin.getBoundingClientRect().bottom;
//计算可视窗口的高度
let clientHeight = document.documentElement.clientHeight;
if (disToTop <= clientHeight) {
if (this.refreshLoginStatus === "loading" && this.dis.pageY < 0) {
this.isShow.isLoading = true;
}
}
},
refreshMove(dis) {
if (this.isShow.isRefresh) return;
if (this.refreshLoginStatus === "refresh" && this.dis.pageY > 0) {
//下拉刷新成立条件
this.isShow.isRefresh = true;
//下拉到一定距离后,内容页不随touchmove移动
this.$refs.refreshLogin.style.transform = `translateY(${
dis < 100 ? dis : 100
}px)`;
}
},
async touchendHandle(e) {
this.refreshLoginStatus === "refresh" && this.refreshToucnend();
this.refreshLoginStatus === "loading" && this.loadingTouchend();
},
async refreshToucnend() {
//加上限定条件,防止不在刷新状态,后面的代码执行
if (!this.isShow.isRefresh) return;
//必须下拉一定距离,才进行异步加载数据
this.dis.pageY > 10 && (await this.$emit("refreshEmit"));
//松手后加载动画消失,并且内容页回到原位置
this.isShow.isRefresh = false;
this.$refs.refreshLogin.style.transform = `translateY(0px)`;
this.refreshLoginStatus = "normal";
},
async loadingTouchend() {
//加上限定条件,防止不在刷新状态,后面的代码执行
if (!this.isShow.isLoading) return;
await this.$emit("loadingEmit");
this.isShow.isLoading = false;
this.refreshLoginStatus = "normal";
},
},
};
</script>
<style>
.wrapper {
background-color: #fff;
overflow: hidden;
}
.circle-rotate {
position: relative;
left: 50%;
width: 20px;
height: 20px;
border-radius: 50%;
border: 10px solid #ccc;
border-right-color: transparent;
animation: loadingAnimation 0.75s infinite;
}
@keyframes loadingAnimation {
0% {
transform: translateX(-50%) rotate(0deg);
}
100% {
transform: translateX(-50%) rotate(360deg);
}
}
.refresh-login {
transition: all 0.75s linear;
}
</style>
坑点:
- touchmove触发loading那一瞬间,不加限定条件,会重复向后端发起请求(缺少节流)
touchmoveHandle(e) {
...
/* //触发上拉加载 */
if (this.isShow.isLoading) return;
this.refreshLoginStatus === "loading" && this.loadingMove(dis);
},
pc端
pc端原理和移动思路相同
只是监控的是鼠标滚轮事件
//app.vue
<template>
<refresher @loadingmore="loadingmore" ref="refresher">
<menu-card :menuList="menuList" />
</refresher>
</template>
<script>
export default {
name: "Home",
components: { MenuCard, Refresher },
data() {
return {
menuList: [],
isLoading: true,
page: 1,
};
},
async mounted() {
this.getMenu();
},
methods: {
async getMenu() {
const menuListsResult = await getMenuQuery({ page: this.page });
this.menuList.push(...menuListsResult.list);
},
async loadingmore() {
this.page++;
const menuListsResult = await getMenuQuery({ page: this.page });
this.menuList.push(...menuListsResult.list);
this.$refs.refresher.isLoading = false;
},
},
};
</script>
//Refresher
<template>
<div class="loading" ref="loading">
<slot></slot>
<div class="loading-spinner" v-show="isLoading">
<i class="el-icon-loading"></i>
</div>
</div>
</template>
<script>
export default {
name: "Refresher",
data() {
return {
isLoading: false,
};
},
mounted() {
const isReachBottomDebounce = this.debounce(this.isReachBottom);
window.addEventListener("scroll", isReachBottomDebounce);
},
destroyed() {//离开页面销毁scroll的监听
window.removeEventListener("scroll", this.isReachBottom);
},
methods: {
debounce(fun) {//防抖函数
let timer = null;
let startTime = new Date();
return function () {
timer && clearTimeout(timer);
timer = setTimeout(() => {
let nowTime = new Date();
let diff = nowTime - startTime;
startTime = nowTime;
fun.call(this);
}, 300);
};
},
isReachBottom() {//上拉加载逻辑
let timer2 = null;
const eleHeight = this.$refs.loading.getBoundingClientRect().height;
if (eleHeight === 0) return;//防止首次数据加载的时候重复请求数据
if (this.isLoading) return;
const eleBottomDis = this.$refs.loading.getBoundingClientRect().bottom;
const visibleWindowHeight = document.documentElement.clientHeight;
if (eleBottomDis + 19 < visibleWindowHeight) {
this.isLoading = true;
timer2 && clearTimeout(timer2);
this.$emit("loadingmore");
}
},
},
};
</script>
<style lang="scss">
$loadingHeight: 20px;
.loading {
margin-bottom: $loadingHeight;
position: relative;
.loading-spinner {
bottom: -$loadingHeight;
line-height: $loadingHeight;
height: $loadingHeight;
width: 100%;
text-align: center;
position: absolute;
}
}
</style>