导航


HTML

CSS

JavaScript

浏览器 & 网络

版本管理

框架

构建工具

TypeScript

性能优化

软实力

算法

UI、组件库

Node

冷门技能

来源:https://segmentfault.com/a/1190000039693056

一、前言

下拉刷新和上拉加载这两种交互方式通常出现在移动端中

本质上等同于PC网页中的分页,只是交互形式不同

开源社区也有很多优秀的解决方案,如iscroll、better-scroll、pulltorefresh.js库等等

这些第三方库使用起来非常便捷

我们通过原生的方式实现一次上拉加载,下拉刷新,有助于对第三方库有更好的理解与使用

二、实现原理

上拉加载及下拉刷新都依赖于用户交互

最重要的是要理解在什么场景,什么时机下触发交互动作

上拉加载

首先可以看一张图

Untitled

上拉加载的本质是页面触底,或者快要触底时的动作

判断页面触底我们需要先了解一下下面几个属性

综上我们得出一个触底公式:

scrollTop + clientHeight >= scrollHeight

⭐️ 简单实现

let clientHeight  = document.documentElement.clientHeight; //浏览器高度
let scrollHeight = document.body.scrollHeight;
let scrollTop = document.documentElement.scrollTop;

let distance = 50;  //距离视窗还用50的时候,开始触发;

if ((scrollTop + clientHeight) >= (scrollHeight - distance)) {
    console.log("开始加载数据");
}

下拉刷新

下拉刷新的本质是页面本身置于顶部时,用户下拉时需要触发的动作

关于下拉刷新的原生实现,主要分成三步:

举个例子:

Html结构如下:

<main>
    <p class="refreshText"></p>
    <ul id="refreshContainer">
        <li>111</li>
        <li>222</li>
        <li>333</li>
        <li>444</li>
        <li>555</li>
        ...
    </ul>
</main>

监听touchstart事件,记录初始的值

var _element = document.getElementById('refreshContainer'),
    _refreshText = document.querySelector('.refreshText'),
    _startPos = 0,  // 初始的值
    _transitionHeight = 0; // 移动的距离

_element.addEventListener('touchstart', function(e) {
    _startPos = e.touches[0].pageY; // 记录初始位置
    _element.style.position = 'relative';
    _element.style.transition = 'transform 0s';
}, false);

监听touchmove移动事件,记录滑动差值

_element.addEventListener('touchmove', function(e) {
    // e.touches[0].pageY 当前位置
    _transitionHeight = e.touches[0].pageY - _startPos; // 记录差值

    if (_transitionHeight > 0 && _transitionHeight < 60) {
        _refreshText.innerText = '下拉刷新';
        _element.style.transform = 'translateY('+_transitionHeight+'px)';

        if (_transitionHeight > 55) {
            _refreshText.innerText = '释放更新';
        }
    }
}, false);

最后,就是监听touchend离开的事件

_element.addEventListener('touchend', function(e) {
    _element.style.transition = 'transform 0.5s ease 1s';
    _element.style.transform = 'translateY(0px)';
    _refreshText.innerText = '更新中...';
    // todo...

}, false);

从上面可以看到,在下拉到松手的过程中,经历了三个阶段:

三、案例

在实际开发中,我们更多的是使用第三方库,下面以better-scroll进行举例:

HTML结构

<div id="position-wrapper">
    <div>
        <p class="refresh">下拉刷新</p>
        <div class="position-list">
            <!--列表内容-->
        </div>
        <p class="more">查看更多</p>
    </div>
</div>

实例化上拉下拉插件,通过use来注册插件

import BScroll from "@better-scroll/core";
import PullDown from "@better-scroll/pull-down";
import PullUp from '@better-scroll/pull-up';
BScroll.use(PullDown);
BScroll.use(PullUp);

实例化BetterScroll,并传入相关的参数

let pageNo = 1,pageSize = 10,dataList = [],isMore = true;
var scroll= new BScroll("#position-wrapper",{
    scrollY:true,//垂直方向滚动
    click:true,//默认会阻止浏览器的原生click事件,如果需要点击,这里要设为true
    pullUpLoad:true,//上拉加载更多
    pullDownRefresh:{
        threshold:50,//触发pullingDown事件的位置
        stop:0//下拉回弹后停留的位置
    }
});
//监听下拉刷新
scroll.on("pullingDown",pullingDownHandler);
//监测实时滚动
scroll.on("scroll",scrollHandler);
//上拉加载更多
scroll.on("pullingUp",pullingUpHandler);

async function pullingDownHandler(){
    dataList=[];
    pageNo=1;
    isMore=true;
    $(".more").text("查看更多");
    await getlist();//请求数据
    scroll.finishPullDown();//每次下拉结束后,需要执行这个操作
    scroll.refresh();//当滚动区域的dom结构有变化时,需要执行这个操作
}
async function pullingUpHandler(){
    if(!isMore){
        $(".more").text("没有更多数据了");
        scroll.finishPullUp();//每次上拉结束后,需要执行这个操作
        return;
    }
    pageNo++;
    await this.getlist();//请求数据
    scroll.finishPullUp();//每次上拉结束后,需要执行这个操作
    scroll.refresh();//当滚动区域的dom结构有变化时,需要执行这个操作
}
function scrollHandler(){
    if(this.y>50) $('.refresh').text("松手开始加载");
    else $('.refresh').text("下拉刷新");
}
function getlist(){
    //返回的数据
    let result=....;
    dataList=dataList.concat(result);
    //判断是否已加载完
    if(result.length<pageSize) isMore=false;
    //将dataList渲染到html内容中
}

注意点:

使用better-scroll 实现下拉刷新、上拉加载时要注意以下几点:

小结

下拉刷新、上拉加载原理本身都很简单,真正复杂的是封装过程中,要考虑的兼容性、易用性、性能等诸多细节

四、完整 DEMO

Scroll.vue

<template>
  <div class="yo-scroll" :class="{ 'down': (state === 0), 'up': (state == 1), refresh: (state === 2), touch: touching }"
    @touchstart="touchStart($event)" @touchmove="touchMove($event)" @touchend="touchEnd($event)"
    @scroll="(onInfinite || infiniteLoading) ? onScroll($event) : undefined">
    <section class="inner" :style="{ transform: 'translate3d(0, ' + top + 'px, 0)' }">
      <header class="pull-refresh">
        <slot name="pull-refresh">
          <span class="down-tip">下拉更新</span>
          <span class="up-tip">松开更新</span>
          <span class="refresh-tip">更新中</span>
        </slot>
      </header>
      <slot></slot>
      <footer class="load-more" v-show="!isDone">
        <slot name="load-more">
          <span>加载中……</span>
        </slot>
      </footer>
    </section>
  </div>
</template>

<script>
export default {
  props: {
    offset: {
      type: Number,
      default: 40
    },
    enableInfinite: {
      type: Boolean,
      default: true
    },
    enableRefresh: {
      type: Boolean,
      default: true
    },
    onRefresh: {
      type: Function,
      default: undefined,
      required: false
    },
    onInfinite: {
      type: Function,
      default: undefined,
      require: false
    },
    isDone: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      top: 0,
      state: 0,
      startY: 0,
      touching: false,
      infiniteLoading: false
    }
  },
  methods: {
    touchStart(e) {
      this.startY = e.targetTouches[0].pageY
      this.startScroll = this.$el.scrollTop || 0
      this.touching = true
    },
    touchMove(e) {
      if (!this.enableRefresh || this.$el.scrollTop > 0 || !this.touching) {
        return
      }
      let diff = e.targetTouches[0].pageY - this.startY - this.startScroll
      if (diff > 0) e.preventDefault()
      this.top = Math.pow(diff, 0.8) + (this.state === 2 ? this.offset : 0)

      if (this.state === 2) { // in refreshing
        return
      }
      if (this.top >= this.offset) {
        this.state = 1
      } else {
        this.state = 0
      }
    },
    touchEnd(e) {
      if (!this.enableRefresh) return
      this.touching = false
      if (this.state === 2) { // in refreshing
        this.state = 2
        this.top = this.offset
        return
      }
      if (this.top >= this.offset) { // do refresh
        this.refresh()
      } else { // cancel refresh
        this.state = 0
        this.top = 0
      }
    },
    refresh() {
      this.state = 2
      this.top = this.offset
      this.onRefresh(this.refreshDone)
    },
    refreshDone() {
      this.state = 0
      this.top = 0
    },

    infinite() {
      this.infiniteLoading = true
      this.onInfinite(this.infiniteDone)
    },

    infiniteDone() {
      this.infiniteLoading = false
    },

    onScroll(e) {
      if (!this.enableInfinite || this.infiniteLoading) {
        return
      }
      let outerHeight = this.$el.clientHeight
      let innerHeight = this.$el.querySelector('.inner').clientHeight
      let scrollTop = this.$el.scrollTop
      let ptrHeight = this.onRefresh ? this.$el.querySelector('.pull-refresh').clientHeight : 0
      let infiniteHeight = this.$el.querySelector('.load-more').clientHeight
      let bottom = innerHeight - outerHeight - scrollTop - ptrHeight
      if (bottom < infiniteHeight) this.infinite()
    }
  }
}
</script>
<style>
.yo-scroll {
  position: absolute;
  top: 2.5rem;
  right: 0;
  bottom: 0;
  left: 0;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  background-color: #ddd
}

.yo-scroll .inner {
  position: absolute;
  top: -2rem;
  width: 100%;
  transition-duration: 300ms;
}

.yo-scroll .pull-refresh {
  position: relative;
  left: 0;
  top: 0;
  width: 100%;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
}

.yo-scroll.touch .inner {
  transition-duration: 0ms;
}

.yo-scroll.down .down-tip {
  display: block;
}

.yo-scroll.up .up-tip {
  display: block;
}

.yo-scroll.refresh .refresh-tip {
  display: block;
}

.yo-scroll .down-tip,
.yo-scroll .refresh-tip,
.yo-scroll .up-tip {
  display: none;
}

.yo-scroll .load-more {
  height: 3rem;
  display: flex;
  align-items: center;
  justify-content: center;
}
</style>

mock.js

export default class MockDataService {
  constructor(totalItems = 1000) {
      this.totalItems = totalItems;
      this.data = this.generateData(totalItems);
  }

  // 生成模拟数据
  generateData(totalItems) {
      const data = [];
      for (let i = 1; i <= totalItems; i++) {
          data.push({
              id: i,
              name: `Item ${i}`,
              description: `Description for item ${i}`
          });
      }
      return data;
  }

  // 根据 pageIndex 和 pageSize 分页获取数据
  getPaginatedData(pageIndex = 1, pageSize = 10) {
      const startIndex = (pageIndex - 1) * pageSize;
      const endIndex = startIndex + pageSize;

      const paginatedData = this.data.slice(startIndex, endIndex);

      return {
          pageIndex,
          pageSize,
          totalItems: this.totalItems,
          totalPages: Math.ceil(this.totalItems / pageSize),
          data: paginatedData
      };
  }
}

App.vue

<template>
 <div>
    <v-scroll :on-refresh="onRefresh" :on-infinite="onInfinite" :isDone="pagination.isDone">
      <div class="card" v-for="item of list" :key="item.id">
        <div class="title">{{ item.name }}</div>
      </div>
    </v-scroll>
 </div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import VScroll from './Scroll.vue';
import MockDataService from "./mock.js";
 
// 实例化 MockDataService 并生成 1000 条数据
const mockService = new MockDataService(42);
const list = ref([]);
const pagination = reactive({
  pageIndex: 1,
  pageSize: 20,
  isDone: false,
});
const loadMore = async () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const data = mockService.getPaginatedData(pagination.pageIndex, pagination.pageSize);
      resolve(data);
    }, 500);
  });
}
const refresh = async () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      pagination.pageIndex = 1;
      const data = mockService.getPaginatedData(pagination.pageIndex, pagination.pageSize);
      resolve(data);
    }, 500);
  });
}
const loadData = async () => {
  pagination.isDone = false;
  const data = await refresh();
  list.value = data.data;
  if (pagination.pageIndex === data.totalPages) {
    pagination.isDone = true;
  }
}
onMounted(async () => {
  loadData();
});

const onRefresh = async (done) => {
  await loadData();
  done();
}

const onInfinite = async (done) => {
  if (pagination.isDone) {
    done();
    return;
  }
  pagination.pageIndex++;
  const data = await loadMore();
  list.value.push(...data.data);
  if (pagination.pageIndex === data.totalPages) {
    pagination.isDone = true;
  }
  done();
}
</script>

<style>
* { padding: 0; margin: 0; list-style: none; }
.container { width: 100vw; height: 100vh; background: lightblue; overflow-y: auto; }
.card { height: 200px; width: 100%; border: 1px solid; box-sizing: border-box; }
</style>