前端长列表优化之分页/懒加载/虚拟列表

3,275 阅读5分钟

最近写项目时候,遇到了处理如何长列表的问题。记录一下优化方案。

分页

image.png

优点

能够快速跳转,方便回溯定位 分页更多用在B 端产品,假设使用分页,哪怕你已经浏览了十多多条数据,想回到某一数据的位置,只需要输入页码就好。如果是懒加载或者虚拟列表,你还需要手动滑动去寻找某一数据,速度非常慢。

缺点

用户不体验好 想要看新的数据,还需要手动去点击页码切换。

实现

前端

这里以react+antd为例

import { Table, message } from 'antd';
import { useState, useEffect } from 'react';
import axios from 'axios';

// 后端接口。查询用户发贴记录(查询用户文章列表)
const getPostsByCollectingAction = async (params) => {
  return await axios.get('/api/users/collect', {
    params
  });
}

const columns = [
  {
    title: '帖子标题',
    dataIndex: 'title',
    render: (text) => <div className="title">{text}</div>
  },
  {
    title: '收藏时间',
    dataIndex: 'created',
  },
];


const Collections = () => {

  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  // 总条数
  const [total, setTotal] = useState(0);
  // 当前页码
  const [current, setCurrent] = useState(1);
  // 每页数据条数
  const [pageSize, setPageSize] = useState(10);
  const fetchData = async () => {
    setLoading(true);
    const { data, code, msg, total } = await getPostsByCollectingAction({
      page: current - 1,
      limit: pageSize
    });
    if (code === 200) {
      setData(data.map((item) => {
        const { pid, title, created } = item;
        return {
          key: pid,
          title,
          created,
        }
      }));
      setTotal(total);
    } else {
      message.open({
        type: 'error',
        content: msg,
      });
    }
    setLoading(false);
  }
  const handleOnChange = (page, pageSize) => {
    setPageSize(pageSize);
    setCurrent(page);
  }

  useEffect(() => {
    fetchData();
  }, [current, pageSize])

  return (
    <Table
      columns={columns}
      dataSource={data}
      loading={loading}
      pagination={{
        current,
        pageSize,
        total,
        onChange: handleOnChange,
        showSizeChanger: true
      }}
    />
  );
}
export default Collections;

后端

后端接口要支持分页。新增两个参数page和limit。

参数:

参数名必选类型说明
pagestring分页
limitstring每页返回多少条数据,默认10条

返回示例

  {
    "code": 200,
    "data": [{
		"_id": "63e272c2ef48a999cd32af4a",
		"pid": "63e12f8ebd7398910857e60f",
		"title": "aa",
		"created": "2023-02-06T16:49:18.287Z"
	}],
    "msg":"查询用户收藏的帖子成功",
    "total": 1
  }

返回参数说明

参数名类型说明
codeint200-成功
dataArray数据
msgstring系统数据
totalNumber总数

返回参数中要有total,让前端分页器能确定要展示几个页码。

详情可以参考(接口文档地址)www.showdoc.com.cn/weshare/978…

懒加载

image.png 页面快滚动到底部时,自动向后端发请求拿数据,然后渲染到页面上。

优点

用户体验好 只要不断下滑就能继续看,不像分页那样总要被打断一下。更多用在C端产品。

缺点

不方便快速跳转,不方便回溯定位 假设你已经浏览了十多多条数据,想回到某一数据的位置,你还需要手动往回滑动去寻找某一数据,速度非常慢。

实现

如果是手动实现的话有两种方案。

方案1(手动实现)

首先将页面上的图片的 src 属性设为空字符串,而图片的真实路径则设置在data-url属性中, 当页面滚动的时候需要去监听scroll事件,在scroll事件的回调中,判断我们的懒加载的图片是否进入可视区域,如果图片在可视区内将图片的 src 属性设置为data-url 的值。

但这种方案有很明显的缺点,scroll 事件可能会被高频度的触发,并且在scroll事件里又去操作dom,页面便会重排重绘,页面会卡顿。

方案2(手动实现)

利用 IntersectionObserver 接口,它可以异步监听目标元素与其祖先或视窗的交叉状态,它不随着目标元素的滚动同步触发,所以它并不会影响页面的滚动性能。

方案3(利用成熟的轮子)

这里用antdreact-infinite-scroll-component来实现滚动自动加载列表。

react-infinite-scroll-component这个库底层也是用了方案1的scroll来实现,不过在此之上做了很多优化的点去提高性能,比如对scroll事件进行节流。

image.png

import React, { useEffect, useState } from 'react';
import { Avatar, Divider, List, Skeleton, Tag, Space } from 'antd';
import { MessageOutlined } from '@ant-design/icons';
import { BASE_URL } from '../../../../service/config';
import InfiniteScroll from 'react-infinite-scroll-component';
import axios from 'axios';
// 后端接口
const getLitsAction = async (params) => {
  return await axios.get('/api/public/lists', {
    params
  })
}


// 分页,每次加载20个
const limit = 20;
// 点击加载更多,loading状态时页面显示骨架屏的个数
const skeletonNumber = 1;
export const tagConfig = {
  index: {
    name: "首页",
    color: "green"
  },
  ask: {
    name: "提问",
    color: "magenta"
  },
  advise: {
    name: "建议",
    color: "gold"
  },
  discuss: {
    name: "交流",
    color: "lime"
  },
  share: {
    name: "分享",
    color: "geekblue"
  },
  logs: {
    name: "动态",
    color: "purple"
  },
  notice: {
    name: "公告",
    color: "cyan"
  },
}

const MyList = ({ header, className, catalog, isTop, isEnd, sort, key }) => {
  //  分页第几页
  const [pageSize, setPageSize] = useState(0);

  const [loading, setLoading] = useState(false);
  const [data, setData] = useState([]);
  const [total, setTotal] = useState(0);

  const loadMoreData = async () => {
    if (loading) {
      return;
    }
    setLoading(true);
    const result = await getLitsAction({
      isTop,
      catalog,
      limit,
      page: pageSize,
      isEnd,
      sort
    })
    const { code, total } = result;
    if (code === 200) {
      setPageSize(pageSize + 1);
      setData([...data, ...result.data]);
      setTotal(total);
    }
    setLoading(false);
  }


  useEffect(() => {
    loadMoreData();
  }, [catalog, isEnd, sort]);

  return (
    !(total === 0 && isTop === '1')
    &&
    <InfiniteScroll
      dataLength={data.length}
      next={loadMoreData}
      hasMore={data.length < total}
      loader={
        <Skeleton
          avatar
          paragraph={{
            rows: skeletonNumber,
          }}
          active
        />
      }
      endMessage={isTop == '0' && <Divider plain>没有更多了🤐</Divider>}
    >
      <List
        key={key}
        header={header}
        className={className}
        itemLayout="horizontal"
        dataSource={data}
        locale={{ emptyText: "快去发表第一条帖子吧!" }}
        style={{ marginBottom: "10px" }}
        renderItem={(item) => {
          const { uid: user } = item;
          return (
            <List.Item
              className='list-item'
              actions={[
                ...item?.tags?.map(({ name, class: color }) =>
                  <Tag color={color || "#87d068"}>{name}</Tag>
                ),
                <div className='list-item-favs'><MessageOutlined />{item.answer}</div>,
              ]}
            >
              <Skeleton avatar title={false} loading={item.loading} active>
                <List.Item.Meta
                  avatar={<Avatar src={`${BASE_URL}${user.pic}`} />}
                  title={
                    <>
                      <Tag color={tagConfig[item.catalog].color}>
                        {tagConfig[item.catalog].name}
                      </Tag>
                      <span className='list-item-title'>{item.title}</span>
                    </>
                  }
                />
              </Skeleton>
            </List.Item>
          )
        }
        }
      >
      </List>
    </InfiniteScroll>
  );
}
export default MyList;

虚拟列表

假如ul下有5个li,当你往下滚的时候,虽然数据发生更新了,但是还是只有5个li,所以叫虚拟列表。

而使用懒加载的话,往下滚的时候,页面就不止5个li,可能有10几个li了。

优点

懒加载在一定程度上没有解决长列表的DOM操作带来的性能问题。而虚拟列表解决了这个问题。

举个例子,你打开知乎首页,一直往下拉(知乎用的就是懒加载),当你拉到1000个之后,你挑一个点击赞同,你会发现,页面很卡顿。超多DOM的造成回流重绘,性能问题无法避免。

而虚拟列表会较好的解决这个问题,因为DOM一直就这么点。

实现

todo

项目地址

WeShare是一款发帖分享论坛。

🚀 登录模块实现了用户注册、登录,忘记密码(通过邮箱验证找回)、修改密码功能

🚀 帖子模块实现了发帖、删帖、收藏帖子、评论回复、评论点赞功能

🚀 用户模块实现了修改用户资料、展示用户资料、每日签到获取积分功能

项目亮点

🚀 采用jwt+localStorage进行会话管理,实现了用户在期限内免登录的效果

🚀 利用axios请求拦截器解决异步请求竞态问题

🚀 对首页帖子展示实现滚动加载(懒加载)

前端github.com/missingone6…

后端github.com/missingone6…

后端接口文档www.showdoc.com.cn/weshare/972…