前言: 前两天接到了一个需求,主要功能是实现类似于 B站 消息页面的那种效果,右侧几个 tab 都需要使用到无限加载的功能。
大家都知道,程序员是很懒的,不可能这几个页面全都写一遍重复的逻辑。所以在接到这个需求的时候,就开始思考能不能设计一个通用的函数,可以帮我快速的完成这个需求。🤔
一. 什么是无限列表
- 如果你之前不了解什么是无限列表,但是我敢保证你一定在不知情的情况下体验过这个。我们还拿 B站 举例子。如下图:
你会发现,只有当你往下滚动后,这个页面的数据才会不断被填充,如果你留心的话,你会注意到右侧的滚动条会在快接近尾部的时候,又一次滑动到上面一点距离,那是因为后面那一部分数据是之后才加进来的。
接下来让我们模拟一下这个场景,来告诉你这样设计的好处。
-
当用户点击左侧《系统消息》按钮的时候,假设现在数据库有100条消息,那么我们有两个方案可以选择:
-
- 后端直接返回100条数据
-
- 后端只把最前面最新的数据返回
-
-
很显而易见,站在用户的角度来讲,其实我们最希望点进来后,首屏幕显示的是最新的那几条消息,其实之前过老的消息我不是特别关心,但是我们又不能把老信息完全不给用户看。(万一用户有一个中奖消息被遗忘到了后面,用户某天突然想起来怎么办呢?)
-
那么这时候我们就需要将上面两种方式结合起来,我们可以假设用户刚进来此页面,我们暂时先只返回最新的前10条数据给用户,一旦当用户滚动到最下方时,我们就判断用户是想查阅更为之前时间段的消息,那么我们再返回后 10 条给用户。如果用户再向下滚动,我们再返回10条…以此类推,直到100条数据全部返回。
-
体会到了上面的场景,我想你应该已经理解了无限列表存在的意义。
二. 传统无限列表
-
在动手处理这个需求之前,我参考了网上之前已经有的现成的轮子和大部分无限列表的设计思路,发现了很多都是通过观察 scroll 事件来判断容器是否已经滚动到底部。
- 如果到底:发起请求,将新获得的数据 push 到数组中。
- 没到底:不做任何处理。
-
我们写一个简单的 demo 来模拟一下这个场景。简单来讲就是一个容器 div 放了过多的内容,导致了溢出,并且我们设置了 overflow-auto ,使容器可以向溢出的方向滚动。
这里我为了方便掩饰,创建了一个长度为 10 的空数组,然后通过 v-for 去渲染,每一个 div 固定一个长度 100 px。
渲染的结果如下:
-
根据上面我们刚刚提到的无限列表的逻辑,现在我们需要判断用户什么时候 “滚到底了”。这里第一步需要给当前的容器元素绑定 scroll 事件。
这样我们就可以获取到容器元素的 scrollTop 属性。 -
可能你还会有疑问🤔,拿到 scrollTop 有什么用呢?确实,我们单单知道这一个属性是没有什么用的,我们需要搭配使用另外两个十分重要的属性一起使用,才可以达到我们的目的。那就是
clientHeight
还有scollHeight
。这里有一个触底计算方法。clientHeight + scrollTop = scrollHegiht
-
最开始看到这个计算方法的时候,我也很迷惑,为什么这样就表示到底了呢?这几个属性我之前的文章里有详细解释。
🎁你必须知道的 clientWidth, offsetWidth, scrollWidth.
如果你懒得看,没关系,我接下来会简单描述一下,不会影响你进一步阅读本文的主主体内容。
- clientHeight 代表我们容器内容区域的高度。更加直观来讲,当你元素溢出了,并且你设置一个 overflow-hidden,那么忽略溢出的内容,你可以直接看到的区域就是 clientHeight ,也就是这一部分的高度。
- scrollTop 代表我们容器向下滚动了多少高度。这里为了更好的表现出 scrollTop,我们在控制台输出一下。可以看到 scrollTop 随着我们向下滚动,值越来越大。
- scrollHeight 其实代表着这个元素实际的高度,因为人家本来就这么高,只不过之前你给容器设置了
overflow-auto
,把人家的高度给隐藏了一部分,现在还给人家了而已。
为了更直观的看到这个属性的含义,我们把容器的overflow-auto
设置为overflow-visible
。
我们验证一下,我们已知道每个元素的高度是 100px,现在有 10 个元素,那么如果我们推断的没错,那么 scroll 的值应该100px * 10 = 1000px
。让我们选中这个容器高度,在选项卡中搜索scrollHeight
,可以看到我们的猜想没错,它代表的就是实际高度。
- clientHeight 代表我们容器内容区域的高度。更加直观来讲,当你元素溢出了,并且你设置一个 overflow-hidden,那么忽略溢出的内容,你可以直接看到的区域就是 clientHeight ,也就是这一部分的高度。
-
大概了解了这三个属性的含义,那么我们回过头再来看我们的触底公式。
clientHeight + scrollTop = scrollHegiht
在这里你需要理清一个非常重要的细节,我们的 scrollTop 的值是有极限的,即使你滚动到底了,那么还是会有一个可视区域的高度在你眼前,它是不可能滚动到最后一个元素也看不见的。如下图:
当第10个元素出现的时候,其实你已经无法滚动了,此时的 scrollTop 就是最大值。也代表着不可见元素(被隐藏的元素)的总高度。 -
想清楚上面这个细节,我们就可以反推出当容器滚动到底的情况, (不可见元素高度
scrollTop
),加上当前可视区域的高度(clientHeight
) 不正好就是实际的总高度嘛!(scrollHeight
)。此时正好对上了我们的触底公式,此时也正是在底部的时候。 -
根据上面的触底公式,我们很容易的可以写出下面的判断逻辑。
让我们验证一下是否可行。
-
为了模拟更真实的情况,我们在触底的时候,改变数组的长度。
再来看一下效果
可以看到我们的数组长度从本来的 10,变为了 20。 -
随着滚动,重复上述步骤,其实就是传统无限列表的实现原理。但是我们大家都知道,获取
clientHeight
等这些属性浏览器为了保证拿到最新的数据是会引起重绘的,并且 scroll 事件触发的频率极高,但是这个场景下又不能做节流和防抖。那有没有更好的解决方法呢?🤔
三. 转变思路
-
我们把之前 scroll 相关的函数和属性都去掉,接下来我们在容器元素内加上一个垫底元素,说白了,就是容器元素的最后一个子元素。
现在的样式大概是这样的:
理所当然的滚动到底部,就会看到我们的垫底元素。 -
那我们的思路是否就可以从判断元素的触底公式转变为 => 什么时候看到垫底元素 了呢?那怎样判断才能优化浏览器的事件还能完美达成我们的无限列表加载呢?
-
接下来引入我们今天的主角,IntersectionObserver,你可以直接翻译中文----交叉观察者。
四. IntersectionObserver API
-
具体的细节的介绍,你可以点击下方查看,在文中我只会介绍这个 API 的核心功能。不过我还是强烈建议你先查阅以后再开观看,能让你更深入理解本文的思想。(T.T 真不是我懒,真的是阮大讲的太好了,我就不再献丑了,我只把我的设计思想告诉大家,相信大家都是很聪明的!)
-
MDN IntersectionObserver
-
阮一峰 intersectionObserver 教程
-
-
首先你必须知道的一点,这个 api 是一个构造函数,可以接受一个函数作为参数。所以你第一步的使用方式应该像下面这样。
-
在此之前,我们先做一下准备工作。
你需要在真实元素挂载以后,调取 observer 实例对象身上的 observe 方法,它接收一个真实 dom 作为参数。这里我们把垫底元素放进去观察,具体怎么个观察法,我们接下来会讲到。 -
当你成功开始观察时,你的回调函数会被触发,可以在控制台上打印一下我们回调函数的参数,可以看到一个叫做 IntersectionObserverEntry的类型变量。为什么是数组呢?因为这个 api 允许你同时观察多个元素,所以这个参数才是数组。
5.接下来我们重点就是要去处理回调函数里的逻辑,在这里我直接讲重点,由于我们只观察了一个元素,所以我们的回调函数的参数重,垫底元素就是 entries[0]
。我们控制台打印一下这个变量。
可以看到这个变量身上有很多属性。
-
这里我直接讲重点,我们暂时只需要关心这个
intersectionRatio
的值。这个值代表着垫底元素和视口元素的交叉比例。你可以暂时简单的理解整个文档的根元素。
-
当我们页面没有发生滚动时,我们假设红色方块为我们被隐藏了的垫底元素,现在你的视野里是没有它的,所以交叉比例为0。
-
当我们滚动到底部的时候,这时候的交叉比例就是 100%,也就是 1。但是在这里你需要用到这个 api 的第二个参数才能看到这个情况。
让我们设置一个叫做 threshold 的属性值(阈值)这样你就可以指定到达交叉的比例时再触发回调函数。
通过下面的 gif 图可以看到,只有当我们元素完全出现的时候,才会触发回调函数。(tips:第一次打印是因为这个 api 初始化的时候会默认执行一次。)
-
那么接下来的我相信你应该明白我的意思了,我们只需要在交叉比例为1 的时候,去发起请求即可。
让我们看一下效果:
可以看到,我们已经完美复刻了传统的无限列表方案,并且这个 api 是异步执行的,只会在主进程闲下来的时候再执行回调函数,避免了我们手动优化 scroll 事件带来的负面影响。
五. 设计一个通用函数
-
我们再回过头看一下 B 站的左侧 tab,会发现这几个页面都是很类似的,所以我们可以设计一个函数来封装一个通用的 IntersectionObserver 函数。
-
你可以搭配标题六来观看本小节,首先这个函数会返回一个 init 函数 和一个响应式变量 list。
-
接下来我讲解一下我的设计思路。
-
首先这个函数需要接收一个函数作为参数,这个参数就是你每个页面去请求后端的那个函数。我在函数内部封装了一个叫做 fetchData 的函数,它会在某些条件下去请求后端,不断填充我们的
list
变量。
-
核心函数其实就是 init,我们需要借助 vue3 组合式 api,来封装好它。注意,这个 init 需要接收一个容器元素作为参数,因为需要给这个传进来的容器元素添加垫底元素来判断是否已经滚动到底部了。
-
首先第一次加载的时候,我们需要默认填充一次我们的 list。
-
然后我们在 nextTick 里去动态添加一个垫底元素。
-
紧接着开启观察者 API 来判断交叉比例,如果为 1,那么调取 fetchData函数 填充我们的 list 即可。
-
接下来你只需要在每个需要用到的页面里去调取这个函数即可。
六. 源码
import { ref, nextTick } from "vue";
export function useInfiniteLoad(fetchListFn: () => Promise<any[]>) {
const data = ref<any[]>();
const list = ref<any[]>([]);
async function fetchData() {
data.value = await fetchListFn();
list.value.push(...data.value);
}
// observerFn
async function init(containerEl: HTMLElement) {
await fetchData();
if (!containerEl) return;
await nextTick(() => {
const dom = document.createElement("div");
dom.setAttribute("id", "loadmore");
dom.style.height = "1px";
dom.textContent = " ";
containerEl.appendChild(dom);
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
const ratio = entries[0].intersectionRatio;
if (ratio === 1) {
fetchData();
}
}
);
observer.observe(dom);
});
}
return { init, list };
}
七. 结语
这个函数仅仅只是启发你的设计思路,并不能在实际项目中完全满足你的需求,我在项目中用到的函数其实是根据我们后端分页设计来完善的,但是总体的思想是不变的,你需要做的根据项目来封装你学到的内容。🎁