npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kne/scroll-loader

v0.1.14

Published

滚动加载下一页数据.

Readme

scroll-loader

描述

滚动加载下一页数据.

安装

npm i --save @kne/scroll-loader

概述

ScrollLoader

基于 IntersectionObserver 的 React 滚动加载组件库,当用户滚动到列表底部时自动加载更多数据,实现无限滚动体验。

核心特性

  • 两种加载模式ScrollLoader 提供基础滚动监听能力,需配合 @kne/react-fetch 手动管理分页;FetchScrollLoader 内置数据获取与分页逻辑,开箱即用
  • 智能哨兵检测:使用 IntersectionObserver 监听底部元素,内容不足一屏时自动补加载,直到填满可视区域
  • 双滚动方案:默认集成 SimpleBar 美化滚动条,也支持原生 div 滚动
  • 国际化内置:自带中英文支持,加载完成提示自动适配语言
  • 灵活的分页配置:支持自定义分页参数字段名、每页条数、数据合并策略
  • 搜索联动FetchScrollLoader 支持 searchProps 变化时自动重置并重新加载

示例

示例样式

@use '@kne/responsive-utils/scss' as responsive;

.scroll-list {
  @include responsive.up(md) {
    max-height: 500px;
  }
}

示例代码

  • ScrollLoader
  • 基础滚动加载组件,结合 Fetch 手动管理分页逻辑;移动端自动使用视口滚动,桌面端使用 SimpleBar 容器滚动
  • _ScrollLoader(@kne/current-lib_scroll-loader)[import * as _ScrollLoader from "@kne/scroll-loader"],(@kne/current-lib_scroll-loader/dist/index.css),_reactFetch(@kne/react-fetch),lodash(lodash),antd(antd),_ResponsiveUtils(@kne/responsive-utils)[import * as _ResponsiveUtils from "@kne/responsive-utils"]
const { get, merge, range } = lodash;
const { default: Fetch } = _reactFetch;
const { default: ScrollLoader } = _ScrollLoader;
const { useIsMobile } = _ResponsiveUtils;
const { Flex, Switch, Typography, Divider, Tag } = antd;
const { useState } = React;

const { Text } = Typography;

// 模拟商品列表数据
const mockProductList = ({ currentPage = 1, perPage = 20 }) => {
  return new Promise((resolve) => {
    const start = (currentPage - 1) * perPage;
    setTimeout(() => {
      resolve({
        totalCount: 75,
        pageData: range(start, Math.min(start + perPage, 75)).map((key) => ({
          id: key + 1,
          name: `商品名称 ${key + 1}`,
          price: (Math.random() * 1000 + 10).toFixed(2),
          category: ['电子产品', '服装', '食品', '家居'][key % 4],
          stock: Math.floor(Math.random() * 500),
          status: key % 3 === 0 ? '缺货' : '在售'
        }))
      });
    }, 800);
  });
};

const BaseExample = () => {
  const isMobile = useIsMobile();
  const useSimpleBar = !isMobile;
  const [customTips, setCustomTips] = useState(false);

  return (
    <Flex vertical gap={16}>
      <Flex gap={16} align="center" style={{ padding: '0 8px' }} wrap="wrap">
        <Text strong>配置项:</Text>
        <Tag color={isMobile ? 'orange' : 'blue'}>
          {isMobile ? '移动端:视口滚动' : '桌面端:SimpleBar 容器滚动'}
        </Tag>
        <Flex align="center" gap={8}>
          <Text type="secondary">自定义完成提示</Text>
          <Switch checked={customTips} onChange={setCustomTips} />
        </Flex>
      </Flex>

      <Divider style={{ margin: 0 }} />

      <Fetch
        loader={({ data }) => {
          return mockProductList(Object.assign({ currentPage: 1, perPage: 20 }, data));
        }}
        render={(fetchApi) => {
          const pagination = {
            paramsType: 'data',
            current: 'currentPage',
            pageSize: 'perPage',
            defaultPageSize: 20
          };
          const current = get(fetchApi.requestParams, [pagination.paramsType, pagination.current], 1);
          const pageSize =
            get(fetchApi.requestParams, [pagination.paramsType, pagination.pageSize]) ||
            pagination.defaultPageSize;

          const formatData = {
            list: fetchApi.data.pageData,
            total: fetchApi.data.totalCount
          };

          return (
            <ScrollLoader
              className="scroll-list"
              isLoading={!fetchApi.isComplete}
              noMore={!formatData.total || current * pageSize >= formatData.total}
              completeTips={customTips ? '— 已展示全部商品 —' : undefined}
              maxFullCount={3}
              useSimpleBar={useSimpleBar}
              onLoader={async () => {
                await fetchApi.loadMore(
                  merge({
                    data: {
                      [pagination.pageSize]: pageSize,
                      [pagination.current]: current + 1
                    }
                  }),
                  (data, newData) => {
                    return Object.assign({}, newData, {
                      pageData: data.pageData.concat(newData.pageData)
                    });
                  }
                );
              }}
            >
              <Flex vertical gap={8}>
                {formatData.list.map((item) => (
                  <Flex
                    key={item.id}
                    justify="space-between"
                    align="center"
                    style={{
                      padding: '12px 16px',
                      background: '#fafafa',
                      borderRadius: 8,
                      border: '1px solid #f0f0f0'
                    }}
                  >
                    <Flex vertical gap={4}>
                      <Text strong>{item.name}</Text>
                      <Flex gap={8}>
                        <Tag color="blue">{item.category}</Tag>
                        <Text type="secondary">库存:{item.stock}</Text>
                      </Flex>
                    </Flex>
                    <Flex align="center" gap={12}>
                      <Tag color={item.status === '在售' ? 'green' : 'red'}>{item.status}</Tag>
                      <Text strong style={{ fontSize: 16, color: '#ff4d4f' }}>
                        ¥{item.price}
                      </Text>
                    </Flex>
                  </Flex>
                ))}
              </Flex>
            </ScrollLoader>
          );
        }}
      />
    </Flex>
  );
};

render(<BaseExample />);
  • FetchScrollLoader
  • 集成数据获取的滚动加载组件,支持搜索过滤、自定义分页;移动端自动使用视口滚动加载
  • _ScrollLoader(@kne/current-lib_scroll-loader)[import * as _ScrollLoader from "@kne/scroll-loader"],(@kne/current-lib_scroll-loader/dist/index.css),antd(antd),_ResponsiveUtils(@kne/responsive-utils)[import * as _ResponsiveUtils from "@kne/responsive-utils"]
const { FetchScrollLoader } = _ScrollLoader;
const { useIsMobile } = _ResponsiveUtils;
const { Flex, Typography, Divider, Tag, Input, Select } = antd;
const { useState, useCallback } = React;

const { Text, Title } = Typography;

// 模拟用户列表接口
const mockUserList = ({ data = {} }) => {
  const { currentPage = 1, perPage = 20, keyword, role } = data;
  return new Promise((resolve) => {
    const roles = ['管理员', '编辑', '普通用户'];
    const allUsers = Array.from({ length: 86 }, (_, i) => ({
      id: i + 1,
      name: &#96;用户_${String(i + 1).padStart(3, '0')}&#96;,
      email: &#96;user${i + 1}@example.com&#96;,
      role: roles[i % 3],
      department: ['技术部', '产品部', '设计部', '运营部'][i % 4],
      createTime: &#96;2024-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}&#96;
    }));

    let filtered = allUsers;
    if (keyword) {
      filtered = filtered.filter(
        (u) => u.name.includes(keyword) || u.email.includes(keyword)
      );
    }
    if (role) {
      filtered = filtered.filter((u) => u.role === role);
    }

    const start = (currentPage - 1) * perPage;
    const pageData = filtered.slice(start, start + perPage);

    setTimeout(() => {
      resolve({
        totalCount: filtered.length,
        pageData
      });
    }, 600);
  });
};

const BaseExample = () => {
  const isMobile = useIsMobile();
  const useSimpleBar = !isMobile;
  const [keyword, setKeyword] = useState('');
  const [role, setRole] = useState(undefined);

  const handleSearch = useCallback((value) => {
    setKeyword(value);
  }, []);

  return (
    <Flex vertical gap={16}>
      {/* 搜索区域 */}
      <Flex gap={12} align="center" style={{ padding: '0 8px' }} wrap="wrap">
        <Text strong>搜索条件:</Text>
        <Tag color={isMobile ? 'orange' : 'blue'}>
          {isMobile ? '移动端:视口滚动' : '桌面端:SimpleBar 容器滚动'}
        </Tag>
        <Input.Search
          placeholder="搜索用户名或邮箱"
          allowClear
          onSearch={handleSearch}
          style={{ width: 260 }}
        />
        <Select
          placeholder="按角色筛选"
          allowClear
          value={role}
          onChange={setRole}
          style={{ width: 140 }}
          options={[
            { label: '管理员', value: '管理员' },
            { label: '编辑', value: '编辑' },
            { label: '普通用户', value: '普通用户' }
          ]}
        />
      </Flex>

      <Divider style={{ margin: 0 }} />

      {/* 列表区域 */}
      <FetchScrollLoader
        className="scroll-list"
        useSimpleBar={useSimpleBar}
        api={{
          loader: mockUserList
        }}
        searchProps={{ keyword, role }}
        getSearchProps={(props) => {
          const result = {};
          if (props.keyword) result.keyword = props.keyword;
          if (props.role) result.role = props.role;
          return result;
        }}
        pagination={{
          paramsType: 'data',
          current: 'currentPage',
          pageSizeName: 'perPage',
          pageSize: 15
        }}
        maxFullCount={2}
        completeTips="— 已加载全部用户 —"
        render={({ fetchApi, children }) => {
          return (
            <Flex vertical gap={8}>
              <Flex justify="space-between" align="center" style={{ padding: '0 4px' }}>
                <Text type="secondary">
                  共 {fetchApi.data.totalCount} 条记录
                </Text>
                <Text type="secondary">
                  当前 {fetchApi.data.pageData.length} 条
                </Text>
              </Flex>
              {children}
            </Flex>
          );
        }}
      >
        {({ list }) => {
          if (!list || list.length === 0) {
            return (
              <Flex justify="center" style={{ padding: 40 }}>
                <Text type="secondary">暂无数据</Text>
              </Flex>
            );
          }
          return (
            <Flex vertical gap={8}>
              {list.map((item) => (
                <Flex
                  key={item.id}
                  justify="space-between"
                  align="center"
                  style={{
                    padding: '12px 16px',
                    background: '#fafafa',
                    borderRadius: 8,
                    border: '1px solid #f0f0f0'
                  }}
                >
                  <Flex vertical gap={4}>
                    <Flex align="center" gap={8}>
                      <Text strong>{item.name}</Text>
                      <Tag
                        color={
                          item.role === '管理员'
                            ? 'red'
                            : item.role === '编辑'
                              ? 'blue'
                              : 'default'
                        }
                      >
                        {item.role}
                      </Tag>
                    </Flex>
                    <Flex gap={16}>
                      <Text type="secondary" style={{ fontSize: 12 }}>
                        {item.email}
                      </Text>
                      <Text type="secondary" style={{ fontSize: 12 }}>
                        {item.department}
                      </Text>
                    </Flex>
                  </Flex>
                  <Text type="secondary" style={{ fontSize: 12 }}>
                    创建于 {item.createTime}
                  </Text>
                </Flex>
              ))}
            </Flex>
          );
        }}
      </FetchScrollLoader>
    </Flex>
  );
};

render(<BaseExample />);

API

ScrollLoader

基础的滚动加载组件,通过 IntersectionObserver 监听底部哨兵元素,当哨兵进入视口时触发 onLoader 回调加载更多数据。支持 SimpleBar 自定义滚动容器和原生滚动两种模式。

属性

| 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| | className | string | — | 自定义 CSS 类名,会添加到滚动容器上 | | noMore | boolean | — | 是否已无更多数据,设为 true 后停止触发加载 | | onLoader | () => Promise<void> | — | 触发加载的回调函数,支持异步操作 | | isLoading | boolean | — | 是否正在加载中,加载中时显示 Spin 且不重复触发 | | completeTips | string | "已加载全部数据" | 全部数据加载完毕后的提示文案 | | maxFullCount | number | 3 | 内容不足以产生滚动条时,自动加载的最大次数 | | useSimpleBar | boolean | true | 是否使用 SimpleBar 作为滚动容器;false 则使用原生 div 滚动 | | children | ReactNode | — | 列表内容,通常为数据渲染结果 |


FetchScrollLoader

集成了 @kne/react-fetch 数据获取能力的滚动加载组件,封装了分页逻辑,通过配置即可实现滚动分页加载,无需手动管理 Fetch 和分页状态。

属性

| 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| | api | object | { loader: () => ({ pageData: [], totalCount: 0 }) } | 数据请求配置,loader 函数接收请求参数,返回 { pageData, totalCount } | | dataFormat | (data) => ({ list, total }) | data => ({ list: data.pageData, total: data.totalCount }) | 将接口返回数据格式化为 { list, total } 结构 | | mergeList | (data, newData) => newData | 按 pageData 拼接合并 | 新旧数据合并策略,默认将 pageData 数组拼接 | | searchProps | object | — | 传递给接口的搜索条件,变化时重新请求第一页 | | getSearchProps | (searchProps) => object | — | 将 searchProps 转换为实际请求参数,返回空对象时不发起请求 | | children | ({ fetchApi, list, data }) => ReactNode | — | 列表渲染函数,list 为格式化后的列表数据 | | render | ({ fetchApi, children }) => ReactNode | ({ children }) => children | 外层渲染插槽,可在 ScrollLoader 外部包裹额外 UI | | pagination | object | 见下方子属性 | 分页参数配置 | | loading | ReactNode | — | 自定义加载中展示 | | error | ReactNode | — | 自定义错误状态展示 |

另外还支持透传 ScrollLoader 的所有属性(如 classNamecompleteTipsmaxFullCountuseSimpleBar 等)。

pagination 子属性

| 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| | paramsType | string | 'data' | 分页参数在请求对象中的键名 | | current | string | 'currentPage' | 当前页码的字段名 | | pageSizeName | string | 'perPage' | 每页条数的字段名 | | pageSize | number | 20 | 每页数据条数 |