前言
长列表渲染是前端领域较为常见的渲染需求,通常是指,当我们面对一个数据量较大的列表时,不能一次性直接获取并渲染全部内容(因为这样速度会很慢,影响用户体验),而是采用一些技术,能够让列表的内容逐步地呈现。
下面,我们就来一一看看长列表渲染的解决方案吧。本文直接参照比较知名的组件库的长列表实现,并试图还原一个简化的源码,以实现不依赖组件库本身的方案。
这篇笔记包含三个部分,第一部分是antd分页列表的实现,第二部分是无限下拉列表的实现,第三部分是实战部分,最终的目的是在React不依赖antd组件库的前提下实现一个下拉列表。
一、Antd与分页列表(Pagination)
Ant design是蚂蚁集团的前端团队,其设计的ant design也是React组件生态圈里比较知名的组件库,这里我们主要参考List 列表这个页面下的分页列表组件。
1.1 Antd List源码
我们先去Github上拷贝ant-design仓库的源码到本地("version": "4.16.8"),ant-design的所有组件都在components
这个目录下,我们进入到该目录,打开list
文件夹,打开index.tsx
这个文件,就能看到列表的核心逻辑。
一个组件库有大量细节的逻辑需要处理,我们在读源码时尽量先忽略这些细节,把实现目标作为我们主要的关注点。
这里我们主要关注antd是如何实现分页的。先来看一看与分页列表有关的对象属性(props——,
const paginationProps = {
...defaultPaginationProps,
total: dataSource.length,
current: paginationCurrent,
pageSize: paginationSize,
...(pagination || {}),
};
第一个参数defaultPaginationProps
是在组件函数里硬编码写好,
const defaultPaginationProps = {
current: 1,
total: 0,
};
第二个参数total
表示这个列表的总长度,即该长列表总共包含多少个元素,这里的dataSource
在组件函数的props里传入的默认值是一个空列表,即,
function List<T>({
// ...
dataSource = [],
// ...
})
第三个参数current
通过React的state设定,表示目前所在的页面序号,如下,
const [paginationCurrent, setPaginationCurrent] = React.useState(
paginationObj.defaultCurrent || 1,
);
// paginationObj也是来自props,如果没有就设定为1
第四个参数pageSize
表示一页展示多少个项目,也是通过state设定,
const [paginationSize, setPaginationSize] = React.useState(paginationObj.defaultPageSize || 10);
// 默认为10个项目
上面这几个参数比较重要,我们再梳理一遍,方便更好地理解后面「切换页面」和「渲染Item」的逻辑,(注意,我们用Item列表内部的一个项目)
-
total
:总长度,即列表Item的总个数 -
pageSize
:一个页下Item的个数,有total / pageSize = largestPage
,其中largestPage
表示最大页的页序号 -
current
:当前页的页序号,满足1 <= current <= largestPage
下面,关键点来了,有了上面的这几个数,我们如何实现渲染当前页面的逻辑呢?
当前页面展示的Items其实就等于从第
(current - 1) * pageSize
项,往下数pageSize
个,也即当前页面的所有Item的索引。 源码里的逻辑如下,let splitDataSource = [...dataSource]; if (pagination) { if (dataSource.length > (paginationProps.current - 1) * paginationProps.pageSize) { splitDataSource = [...dataSource].splice( (paginationProps.current - 1) * paginationProps.pageSize, paginationProps.pageSize, ); } }
看起来比较复杂,关键是这个
splice
函数,就完成我们上述的取出列表对应范围内项目的逻辑,splice的第一个参数表示索引起点,第二个参数表示从原列表中取出的个数,这个函数返回这一段数据,形成一个新的数组。最后,再加入一段代码,使得
current
不会超过largestPage
,const largestPage = Math.ceil(paginationProps.total / paginationProps.pageSize); if (paginationProps.current > largestPage) { paginationProps.current = largestPage; }
上面所有的props会传入到
Pagination
这个子组件里,这个组件又是怎么实现的呢,我们接着看吧~注意,上面的代码仅含页序号切换的逻辑,实际上在每次渲染时还需要根据页序号选择内部渲染的项目,这块逻辑也比较直接,所以这里省略。
1.2 从antd到rc-pagination
事实上,ant-design的列表分页逻辑并非完全从零搭建而起,也是也依赖了React Component这个组织编写的代码,在ant-design的
package.json
里,我们可以看到很多以rc-
开头的包依赖,这些都是React Component组织编写的组件,antd是在它上层进行的封装。比如这个分页列表,就是建立在
rc-pagination
这个包之上的,我们来看看怎么使用的吧,import React, { useState } from "react"; import RCPagination from "rc-pagination"; import "rc-pagination/assets/index.css"; import "./App.css"; function App() { const [current, setCurrent] = useState(1); function onChange(page) { setCurrent(page); } return ( <> <RCPagination onChange={onChange} current={current} pageSize={3} total={25} /> </> ); } export default App;
展示如下,
这段已经是尽可能最小化的demo了,可以看出,ant-design直接沿用了rc-pagination的属性名称,比如上面提到的
current
,total
,pageSize
,另外,onChange
这个函数是用于分页的切换函数,也是需要手动实现并且传入到RCPagination
这个组件里。本来打算继续看看
rc
的代码,去Github克隆一个仓库,打开./src/index.js
,发现它是这样,export { default } from './Pagination';
ok,那我们直接看同目录下的
Pagination.jsx
。不看不知道,一看吓一跳,这个分页栏的实现有700多行,还引入了两个其他组件,分别是
Options.jsx
(170行),Paper.jsx
(30行),没想到啊,小小分页列表,逻辑尽然会这么复杂,难怪Antd也需要站在巨人的肩膀上了。1.3 rc-pagination源码
这里,我们只拆解出页面跳转显示的逻辑来讲一讲。页面跳转和UI的关系是,需要显示当前页左右边可以点击的页序号。 比如,按上面图片的例子,当我们在第5页时,往前只显示到3页,往后只显示到7页,这是需要手动设置的逻辑。这一块的设置方法源码如下,
const getJumpPrevPage = () => Math.max(1, this.state.current - (this.props.showLessItems ? 3 : 5)); const getJumpNextPage = () => Math.min( calculatePage(undefined, this.state, this.props), this.state.current + (this.props.showLessItems ? 3 : 5), ); function calculatePage(p, state, props) { const pageSize = typeof p === 'undefined' ? state.pageSize : p; return Math.floor((props.total - 1) / pageSize) + 1; }
注意,
rc-pagination
的源码还用的是Class的写法,所以会有很多this
,另外,这个showLessItems
属性是一个bool值,也就是控制这个显示条可以拉到多宽。(比如,上图展示的是宽度为5的情况,如果showLessItems === true
,那么宽度为3,那么就只会显示中间的4,5,6三个页序号)。rc
有大量的源码都是在处理页序号的显示和跳转问题,因为rc-pagination
的分页栏支持各种各样定制的功能,也需要做的非常稳固(不能出bug),所以源码比较复杂,更多定制的用法请见官网示例。
二、Antd与下拉加载列表(Scroller List)
下拉加载列表也是比较常用的需求,即当用户下拉到页面底部时,自动触发加载下一页的函数,让列表中添加新的项目。 目前,「掘金」和「今日头条」采取的都是这种方案。
Antd官网展示的下拉加载列表组件是直接使用的第三方库,即'react-infinite-scroller'
,我们直接到Github仓库查看该库的源码,理解下拉的实现方式。
这个库的核心代码有三百行,其中最重要的一个函数是scrollListener
,它通过原生的addEventListener
方式挂在到目标DOM元素上,在页面初始化渲染时完成挂载。
我们来看函数的内容,篇幅比较长,(加了一些注释以便阅读)
scrollListener() {
// 获取当前元素和父元素
const el = this.scrollComponent; // 通过React ref引用
const scrollEl = window;
const parentNode = this.getParentElement(el);
// 偏移量
let offset;
// 根据父元素(容器)计算偏移量
if (this.props.useWindow) {
const doc =
document.documentElement || document.body.parentNode || document.body;
const scrollTop =
scrollEl.pageYOffset !== undefined
? scrollEl.pageYOffset
: doc.scrollTop;
if (this.props.isReverse) {
offset = scrollTop;
} else {
offset = this.calculateOffset(el, scrollTop);
}
} else if (this.props.isReverse) {
offset = parentNode.scrollTop;
} else {
offset = el.scrollHeight - parentNode.scrollTop - parentNode.clientHeight;
}
// 根据偏移量判断是否需要加载下一页
if (
offset < Number(this.props.threshold) &&
(el && el.offsetParent !== null)
) {
this.detachScrollListener();
this.beforeScrollHeight = parentNode.scrollHeight;
this.beforeScrollTop = parentNode.scrollTop;
// Call loadMore after detachScrollListener to allow for non-async loadMore functions
if (typeof this.props.loadMore === 'function') {
this.props.loadMore((this.pageLoaded += 1));
this.loadMore = true;
}
}
}
有两个关键点,
offset
:这个偏移量记录了列表容器的当前位置到底部的距离,前面有一连串的if-else
来设定这个值,如果useWindow === true
,那么就直接把整个视窗(或html
标签)当作容器,否则就把列表项的父级元素this.getParentElement(el)
当作容器,这提示我们在放置长列表时,要默认外一层的元素作为容器。loadMore
:函数内容的下半部分是一个判断语句,如果滑动距离底部的距离offset
小于了阈值this.props.threshold
(默认为250),就会触发加载下一页的函数loadMore
,然后把当前页的状态this.pageLoaded
加一,以供后续的加载。
源码的其他部分都是一些细节处理,比如辅助计算偏移的函数calculateOffset
,挂载和取消事件的函数detachMousewheelListener
、detachScrollListener
,等等,有兴趣的话可以自己去阅读。
三、下拉列表实战
下面,我们试图脱离antd这个组件库,直接利用react-infinite-scroller
实现一个无限下拉列表。在本文的最后会提供codesandbox完整的在线演示。
注意,react-infinite-scroller这个库是「仅仅依赖React的」的,这意味着我们甚至不需要通过npm安装,而是直接复制源码到本地,即可以使用(就像自己实现的一样)。
不过,为了方便起见,我们还是会import这个库。
先来讲讲初始化的变量,其他部分还是模仿了antd的例子,
const fetchItemEachNumber = 5;
const fetchItemTotalNumber = 100;
const fakeDataUrl = `https://randomuser.me/api/?results=${fetchItemEachNumber}&inc=name,gender,email,nat&noinfo`;
我们通过randomuser.me网站提供的api获取数据,这里设定每次获取5条数据(fetchItemEachNumber),在数据量超过100条(fetchItemTotalNumber)时停止加载。
我们总共维护三个状态,一个是长列表data
,一个是正在加载的表示loading
,当我们正在加载数据时,这个状态会变为true
,于是可以根据状态的变化设定加载时动画(此案例没有),最后一个状态是hasMore
,这个状态用来表示是否还有新的数据可供加载,当加载数据量达到100条时,这个状态会变为false。
const initState = { data: [], loading: false, hasMore: true };
const [state, setState] = useState(initState);
下面是页面初始化后的行为,首先抓取一次数据,展示前5项。
useEffect(() => {
fetchData((res) => {
setState((state) => {
return { ...state, data: res.results };
});
});
}, []);
// 抓取数据的函数,用到reqwest库
const fetchData = (callback) => {
reqwest({
url: fakeDataUrl,
type: "json",
method: "get",
contentType: "application/json",
success: (res) => {
callback(res);
}
});
};
然后是下拉列表触发新一页的函数,如下,
const handleInfiniteOnLoad = () => {
let { data } = state;
// 设定状态为加载中
setState((state) => {
return { ...state, loading: true };
});
// 数据量已满,直接返回
if (data.length > fetchItemTotalNumber) {
setState((state) => {
return { ...state, hasMore: false, loading: false };
});
return;
}
// 加载新数据
fetchData((res) => {
const fetchedData = data.concat(res.results);
setState((state) => {
return { ...state, data: fetchedData, loading: false };
});
});
};
最后是返回的React Element,
<article>
<div className="container">
<InfiniteScroll
initialLoad={false}
pageStart={0}
loadMore={handleInfiniteOnLoad}
hasMore={!state.loading && state.hasMore}
useWindow={false}
>
<>
{state.data.map((item) => {
return (
<section className="inner" key={item.email}>
{item.name.last}
</section>
);
})}
</>
</InfiniteScroll>
</div>
</article>
这里,我们的容器是类名为container
的<div>
,在InfiniteScroll
内部通过一个map
方法把数据全部表征出来。这里我们只展示item.name.last
。
最后,还需要设置一些CSS,以便能完成下拉和自动加载的动作,见完整的代码和线上演示请点击文末的链接~
(感谢阅读,写作不易,请多支持,有错误或不足的地方也请多指点)
在线演示:codeboxsand链接🔗