【一步一个脚印】前端常见功能之上拉加载,下拉刷新

3,465 阅读4分钟

前言

不积跬步无以至千里,从最基础常见的功能开始,一步一个脚印

任务拆解

结构样式:页面布局,加载动画

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事件,请求数据并渲染,加载动画关闭,内容页回到顶部
    

    上拉加载(移动端)

    1. touchstart事件,记录起始位置
    2. touchmove事件,根据pageY与起始位置差值计算移动距离和方向,内容页随之移动;判断内容页是否是在底部;到达底部,加载动画出现
    3. touchend事件,请求数据并渲染,加载动画关闭
初始下拉上拉
  • 触发条件判定

    1. 下拉刷新

      内容页在可视窗口最顶端 内容页向下拉动

    2. 上拉加载

      内容页滑动到可视窗口最底端

      内容页向上拉动

后端代码和前端初始代码

再讲下拉刷新前,先把后端服务和前端起始页搭建起来

后端服务基于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>

下拉刷新功能实现

下拉刷新核心功能:下拉后显示加载动画,内容页一起跟随触摸移动,手指放开后数据更新,关闭加载动画,页面回到初始位置

细节:

  1. 内容页需在可视窗口最顶端下拉才能开启下拉刷新功能 ---> touchstart
  2. 下拉过程中内容页移动可用transform:translateY() ---> touchmove
  3. 数据更新通过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>

坑点

  1. touchend没加限定条件,只要松手就触发数据刷新
async touchendHandle(e) {
	//加上限定条件,防止不在刷新状态,后面的代码执行
      if (!this.isShow.isRefresh) return;
      ...
}      
  1. 内容页在顶端,如果用户不想刷新网页,只是无意间下拉触碰就刷新页面导致想看的内容被刷新掉
async touchendHandle(e) {
	//加上限定条件,防止不在刷新状态,后面的代码执行
      if (!this.isShow.isRefresh) return;
    //必须下拉一定距离,才进行异步加载数据
      this.dis.pageY > 10 && await this.$emit("refreshEmit");
      ...
} 

上拉加载功能实现

移动端

上拉加载核心功能:内容页底部到达可视窗口底部后,显示加载动画,手指放开后数据更新,关闭加载动画

细节:

  1. 内容页需到达可视窗口最底端上拉才能开启上拉加载功能 ---> touchmove
  2. 数据更新通过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>

坑点:

  1. 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>