React实战之如何实现一个长列表:antd源码解读

1,268 阅读7分钟

前言

长列表渲染是前端领域较为常见的渲染需求,通常是指,当我们面对一个数据量较大的列表时,不能一次性直接获取并渲染全部内容(因为这样速度会很慢,影响用户体验),而是采用一些技术,能够让列表的内容逐步地呈现。

下面,我们就来一一看看长列表渲染的解决方案吧。本文直接参照比较知名的组件库的长列表实现,并试图还原一个简化的源码,以实现不依赖组件库本身的方案。

这篇笔记包含三个部分,第一部分是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列表内部的一个项目)

  1. total:总长度,即列表Item的总个数

  2. pageSize:一个页下Item的个数,有total / pageSize = largestPage ,其中largestPage表示最大页的页序号

  3. 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;
    

    展示如下,

    image.png

    这段已经是尽可能最小化的demo了,可以看出,ant-design直接沿用了rc-pagination的属性名称,比如上面提到的currenttotalpageSize,另外,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;
      }
    }
  }

有两个关键点,

  1. offset:这个偏移量记录了列表容器的当前位置到底部的距离,前面有一连串的if-else来设定这个值,如果useWindow === true,那么就直接把整个视窗(或html标签)当作容器,否则就把列表项的父级元素this.getParentElement(el)当作容器,这提示我们在放置长列表时,要默认外一层的元素作为容器。
  2. loadMore:函数内容的下半部分是一个判断语句,如果滑动距离底部的距离offset小于了阈值this.props.threshold(默认为250),就会触发加载下一页的函数loadMore,然后把当前页的状态this.pageLoaded加一,以供后续的加载。

源码的其他部分都是一些细节处理,比如辅助计算偏移的函数calculateOffset,挂载和取消事件的函数detachMousewheelListenerdetachScrollListener,等等,有兴趣的话可以自己去阅读。

三、下拉列表实战

下面,我们试图脱离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链接🔗