阅读 968

易忽略的滚动加载或下拉刷新时导致的数据问题

描述

一个非常非常常见的需求,实现在移动端中的一个列表,实现滚动到底部加载更多,其中列表的每一项有删除操作。

是的,就是这么一个常见的问题,有什么好说的?一般场景下,特别是对数据要求不高的情况,其实确实没啥好说的,假设你对列表的数据展示要求十分高,准确性要高,特别是涉及财务相关的单据列表这种场景时,我们不能出现重复数据或遗漏数据,这是基础的也是重要的。

说到这种移动端滚动加载更多,大家应该第一反应前后端合作应该就是采用分页的方式吧,例如第一次请求的时候加载第一页的多少条数据,每次加载更多就翻页加载。相信很多人都是这么做的,当然遇到问题也正是从这开始,来看下有什么需要注意的细节问题

问题

  1. 采用分页滚动加载,当一进入某个列表页时,分页获取的数据是基于当时进入那刻的数据库数据进行分页的,但是进入页面后,中途会有新加入的数据到数据中,即中途会新增单据,数据库的分页数据会发生变化,跟之前进入页面时的分页情况不一样了。这样的话,当我们在页面上进行滚动加载获取下页数据时,因为时实时查询数据库,就会在获取下页数据的时候可能会参杂了新旧数据,当新增量很多时,更甚者下页数据全是新增的数据。但是由于我们是要求某个排序规则展示数据的,就可能出现本因排在前头的数据,却在分页加载时被请求出来了,排在后面了,而旧数据部分就是可能已经被展示出了,重复展示了。
  2. 除了中途可能加入数据,也会存在数据删除掉的情况,同理,分页加载的数据是实时的,但是由于删除了部门数据,导致进入页面那刻的数据库分页情况跟实时的不一样了,就会存在可能部分数据查不出来了。
  3. 当新增和删除混合在一起出现时,这时候数据库的分页变化就变得更加难以预测了。

所以目前需要针对此问题,进行一个讨论,看如何解决

举些最简单的例子:

中途加入数据的情况:

假设按紧急程度降序排序下,进入页面时的数据库只有11条数据,10条数据为1页,此时页面展示了10条数据,10条数据的紧急程度会不一样,理应滚动加载的时候获取第二页的1条数据。但是假设中途加入了20条新数据,20条数据紧急程度都是最高的,此时数据库的分页情况就发生变化了,那么滚动加载第二页数据的时候,就会获取到这20条数据,但是明显这20条数据不应该排在后面。

删除的情况:

进入页面时的数据库只有20条数据,10条数据为1页,此时页面展示了10条数据,理应滚动加载的时候获取第二页的20条数据。假设中途删除或界面操作删除了5条数据,那么数据库的分页情况就变成第一页5条数据被删除了,但是第二页的前5条就补到了第一页中,而第二页变成只有5条了,那么在滚动加载查询第二页时,只会加载出5条,所以有5条数据没了。

加入和删除混合的情况就变得更复杂了。

解决方案:

变量解释

pageIndex: 当前所在页码

pageSize: 一页展示多少条数据

total: 全部数据总数

wrap: 列表所在的滚动容器DOM对象

target: 删除那一项的DOM对象

deleteNumber: 删除的个数

复制代码

应对中途加入数据

前端在第一次访问页面的时候,以当前时间作为时间戳传给后端,后端的数据以这个时间戳作为分界点对数据进行划分,之后在页面上进行数据的滚动加载更多的时候,都是以这个时间戳传递,这样的话,中途加入的新数据就不会出现在接下来的分页中,即不会有新数据打乱分页情况,相当于维持在旧数据的分页处理上。

如果中途有数据加入,那么用户只有自己主动刷新页面才会重新获取最新的数据,此时会更新传递的时间戳。一般是要提供一个下拉刷新的功能。做的更加好的话,还可以考虑消息推送,来新数据了或者改变删改了数据,可以提示用户,让其刷新。

应对删除的情况

这里会有两种方式进行处理,我称之为“分页法”和“标记法”,主要是因为前者获取数据,是依赖将数据分页,根据页数来获取的。而后者,其实没有分页的概念,只是标记某个数据的位置,然后从其位置开始获取指定条数的数据。

“分页法”适用场景比较广泛,“标记法”适合满足某种特定场景下。不论是哪个方法,都会遇到这么一个问题:

假设当前页面显示了10条数据,出现了滚动条,那么用户删除了好几条数据后,页面显示内容变少了,自然会出现没有滚动条或空白的情况,但是其实,它后面还是有好几页数据的,而让用户触发请求更多数据的滚动条没有了,就会陷入一个有数据却不能请求更多数据的窘境。因此,我们想要针对这种情况,做一个“补”数据的操作,把后面的数据(有的话)给补上来,让界面能维持可滚动状态,进而能让用户滚动加载更多数据。

所以我们要首先知道,何时补数据,再说补什么数据,怎么补数据

“补”数据时机

在上述应对中途加入新数据的基础上,在不必担心新加入数据污染的情况下,删除数据就变得好处理点了。最坏的处理方式是每删除一条数据,那么就发 pageSize = 1, pageIndex = 删前当前展示的总数(pageIndex * pageSize)的请求得到这条数据就是要补的数据了,因为如果还有下页的数据,那么删了之后,就自然会补到当前pageIndex * pageSize的这个位置上。

但是上述这种方式,就会造成请求的频繁发送,不论对服务器压力,还是用户体验来讲,都是不友好的,因为实际上在数据较多的情况下,删了一条,用户也不需要立即就要补数据,说定用户都不会再滚动下去查看更多数据,这时候还特意发个请求去补数据,显得很没必要且浪费资源。

我这边想出来的一个较为友好的方式:

每删除一个

  1. 判断当前所展示的数据是否已经是全部数据了,没有的话,就不用补数据了
  2. 计算当前列表所在的滚动容器是否还会出现滚动条,没有出现的话,就要开始补数据,不然没有滚动条触发滚动事件发起加载更多的请求

先判断第一个条件,接着才判断第二个。

这样的话,就不用频繁发请求,只有在需要的时候才进行。

上面两个条件换成公式表达:

1)判断是否还有数据需要展示

if (pageIndex * pageSize - deleteCount >= total) {
    console.log('没有更多的数据需要展示了')
}
复制代码

意思就是,当前页面原本所能展示的数据量是pageIndex * pageSize个数据,但是由于进行了删除操作,所以当前展示的数据量就是pageIndex * pageSize - deleteCount个,而total值是会变化,我们每删一个total就会减一,如果我们每删一个不会马上发请求获取最新的total值,那么请记住需要前端针对这个变量进行递减处理,如果方便的话,还是建议后端能够在删除接口的返回结果里告知剩余total数,但是要记住这个total值是不能把中途新加入的数据计算在内,即上述应对中途加入数据的方案的旧数据的基础上。

2)判断补数据的时机

一般可能会想,删除了之后就判断当前页面是否还出现滚动条,不出现就去补数据。其实这样做是可行的,但是假设补数据的请求很慢,删除了一个后页面不出现滚动条了,底下有一块空白,请求响应了后就突然把这块空白补了,就显得很突兀,所以我为了让用户体验更好,把这个判断行为提前一点,我们不要根据删除后的事实去才去判断,而是要提前判断,预测删除后的列表如果再删除一个就没有滚动条的话,就去补数据。当然,如果一开始列表删除一个就出现空白了,这种情况是无可避免的,因为我们不能不做删除操作就开始预测。

这样的话,用户删除了一个之后还是有滚动条,页面还没有空白,此时预测再删掉一个的话,就会不出现滚动条了,即会有空白,那么现在就提前发补齐数据的请求就能显得没那么突兀了,跟滚动加载更多的行为保持一致。当然,这么做的缺点就是,有可能用户就不会往下再查看,此时补数据就显得无意义,所以自己考虑用哪个把。我这里就以预测的方案说明:

if (wrap.clientHeight + (target的平均高度的一半+target的一个平均高度) > wrap.scrollHeight - target.offsetHeight) {
    console.log('再删一个就要没滚动条啦!赶紧补数据')
}
复制代码

补数据

我们知道了何时补数据了,那么我们要清楚,我们要补什么数据?答案很显然,就是补排在当前界面所展示的数据后面排序着的数据了。

分页法

怎么获取后面的数据,按照原本的设计,就是通过把数据进行分页,然后通过分页来分批获取数据的,而这种方式,很显然就会产生我们这里要解决的问题,

举例,删除了第一页的3条数据,那么原本属于第二页的头三条数据(ABC),在删除之后就会被分配到第一页中去了,变成了第一页的后三条数据(ABC),因此,假设用户继续往下浏览,前端发获取第二页数据的请求,返回的数据很显然没有被“顶”到第一页的那三条数据(ABC)。

因此,要避免上述问题的发生,我们就要在用户触发发下一页数据之前,就要补那三条数据(ABC),结合上节说的补数据时机,这里要触发补数据的时机变成两个场景

  1. 删除数据后,判断是否还有数据以及是否会引起滚动条消失,再决定是否补数据
  2. 发下一页数据之前(一般表现为滚动加载更多),先补一次数据

怎么知道要补哪些数据呢?从上面的例子可以知道,删了多少条,就会有多少条数据被分配到上面的页中去了(注意,有可能是上几页的哦,如果用户是先滚动加载获取了好多页数据后才删除,如果删除条数过多,就会有超过几页数据被替补上去),我们只要再一次获取当前用户所在分页上的数据,然后删了多少条,就取后面多少条数据即可,这时候这些后几条数据就是被“顶”上来的数据。

来个图来理解下 (画的丑别介意)

image

当前界面展示了两页数据(蓝色框部分),假设删了1、2、3,那么此刻数据库变成“删后”的情况,这时要补的数据是9、10、11,那么就重新获取一下等同于删前界面所展示的数据量一样的数据量,把这部分数据视为1页数据,pageSize即删前数据量,即发pageSize: 8, pageIndex: 1的请求,这时候返回的数据正是“删后”的第一页和第二页的数据情况,因为删了3条数据,所以对返回的数据取后三条数据作为要补的数据

所以,最终转化为代码 (大概意思意思)

function fillLackData () {
    const currentAmount = pageSize * pageNumber // 当前界面原本展示的数据条数
    // 请求的入参
    const config = {
        timeStamp: +new Date(), // 在应对中途加入数据的基础上
        pageSize: currentAmount,
        pageIndex: 1
    }
    const res = await request() // 请求数据
    // list为当前列表数据的数组
    // res.list是后端返回的数据列表
    // 这里做拼接,就是在补数据了,对返回的数据取跟删除数一样的倒数几个数据
    // 可能有人会疑惑为啥不直接使用slice(-deleteNumber),在有充足数据的情况下是没问题的,
    // 但是假设返回的数据不够,小于currentAmount时,明显这样取负值来截取数据,是不对的
    list = list.concat(res.list.slice(currentAmount - deleteNumber))
    deleteNumber = 0
}
复制代码

上面就是补数据的主要函数,那么就结合时机来正确调用就ok了

大概意思意思

/**
 * 移除单条数据,看需不需要补数据
 * @param {Number}} index - target的index
 */
function removeItem(index, card) {
    list.splice(index, 1)
    deleteNumber++
    total--
    // 如果未全部数据加载完 以及 预测删除后的列表(如果再删除一个)没有滚动条
    // 210这里是例子,意思是上面说的target的平均高度的一半+target的一个平均高度
    if ((pageIndex * pageSize < total + deleteNumber) && (wrap.clientHeight + 210 > wrap.scrollHeight - target.offsetHeight)) {
        fillLackData()
    }
}

/**
 * 列表滚动加载更多
 */
function handleScroll() {
    // 滑动到底部
    // +5是预防可能有偏差值,遇到过移动端有偏差值导致已经滚动到底部了,但是相加不等于scrollHeight
    if (wrap.scrollTop + wrap.clientHeight + 5 >= wrap.scrollHeight) {
        if (pageNumber * pageSize >= total + deleteNumber) {
            return
        }
        // 如果尚未补齐数据就要先补齐数据
        if (deleteNumber) {
            fillLackData()
            return
        }
        pageNumber++
        loadList() // 加载下一页数据
    }
},
复制代码

我这里是假设用户在删除了数据之后,没达到补数据的条件,那么用户直接滚动加载更多,是先进行补数据的,即这次的“滚动加载更多”行为是加载出了补的数据,用户要再触发一次滚动加载,才会真正地获取下一页数据,如果你觉得不合理,那么可以稍微改造下,先补完后主动发一次请求下页数据。或者根据需要补齐的数据量来判断,多需要补齐的话,就仅仅进行补齐操作,如果少数据可以进行完补齐就直接加载下页数据。

当然这种方式的补数据是有弊端的,就是假设用户加载了好多页的数据了,只是删除一条数据,然后再触发加载更多,这时候就获取当前所有页的数据就为了取最后一条数据,这不划算。

标记法

之所以会导致删除数据的分页混乱问题,本质就是,分页的划分问题,如果不对数据进行分页这种手段来获取更多数据,那么自然而然就不会有这种问题了。

举个例子说明,假设有一段数据,是按照时间排序的,首先获取了10条数据,每条数据都会有对应的时间戳(毕竟是按时间排序,肯定每条数据都会有一个时间标识),那么用户滚动加载更多时,入参就是第10条数据的时间戳和指定获取几条count,那么后端就根据这个时间戳,找到第10条数据,然后往后取count条。以此类推,达到类似“分页”的效果,获取更多数据。

上述例子要说的方式就是, 前端传递两个参数,一个是标识,用来让后端确定数据的位置,一个是条数,让后端找到位置往后取多少条数据。 按照这种方式来获取更多数据,就不会存在删除导致分页的问题,简单粗暴。同样,用这个方式来补数据也是没问题的。

这种方式有一个好处,就是不必基于处理中途加入新数据的实现基础上实现,对数据实时性非常友好,虽然排在标识前面的新加入数据还是不能获取到,但是对于排在后面的新数据,就能展示出来。

但是,这种方式有几点是需要注意的

  • 数据最好要能根据传递的标识来进行数据的排序,如上面说的时间戳,这样后端可以直接按标识排序过滤出结果,然后取数返回。要是这个标识不能排序的,这样后端就需要在一堆数据里从头开始一个个遍历找到,然后这个标识对应的数据,这样明显性能不好(数据量少的时候还可以,不过一般实际使用场景,滚动加载也不会加载成千上万条数据)
  • 确保传递的这个标识,后端是能够根据这个标识可查到数据的,例如有一个场景,页面的数据支持批量删除,一下子把页面展示的所有数据都删了,这个时候,要补数据,该传递什么标识呢?如果传删除的所有数据的最后一条的标识,明显有问题,因为此时它已经被删了,后端查不到,也就定位不到,自然找不到后面的数据。因此存在这种场景的情况下,前端要比当前所展示的数据下预取多一条数据,这样就算用户全删了界面上展示的数据,也总有下一条数据可以用来当标识给后端定位,当然我是建议这个“多一条”数据,是后端接口返回告知,即获取列表数据的时候就告知下一条数据的标识,不然前端每次获取列表数据还要切割最后一条然后每次滚动加载更多还要做切割拼接,明显把这部分工作放在前端处理不友好,对维护和阅读也不好。而放在接口里,意义很明确,看接口就能知道会有这部分工作需要留意,毕竟获取正确数据是后端的一个基础能力,把这边处理压力放在前端,不是很合适。

总结

是的,真的没想到,我们日常经常使用的这么一个移动端列表,这么司空见惯的需求,却隐藏着这么多问题。不过很多大佬应该早就知道这部分的问题了,但不能排除还有些人会遗漏这些细节问题,我接手的一个项目,就是原本的前后端没意识到这个问题,我提出来了,他们才想起,但是碍于顶层设计的时候没有做好考虑,导致目前只能改动很大。

文章分类
前端
文章标签