remote-editable-pager

框架无关的可编辑远程分页器:分页加载 + 跨页编辑 + 增删行 + 保存时自动汇总全量数据
395
Install
npm install remote-editable-pager
Latest Version:1.0.0
License:MIT
Last Updated:Oct 8, 2026
Links: npm
Maintainer: yangph

remote-editable-pager

框架无关的可编辑远程分页器 —— 分页加载 + 跨页编辑 + 增删行 + 保存时自动汇总全量数据。

零运行时依赖,不碰 DOM,不依赖 Vue / React,可在任意前端项目里直接用。


它解决什么问题

普通分页表格遇到「用户要编辑」时,通常只有两条路,都不好用:

做法 问题
一次性拉全量,前端自己分页 明细上万行时首屏卡死、内存吃满
真分页,编辑只改当前页 翻页后改动丢失,保存时拿不到完整数据

这个库走第三条路:数据按页从后端取,但编辑状态与后端解耦地维护在前端, 无论用户翻到第几页、改了多少页,保存时都能一次性拿到「全量 + 全部改动」的完整明细。

它把编辑状态拆成四个互不干扰的盒子:

盒子 作用 生命周期
rawBySourceIndex 原始只读仓库,后端返回什么就存什么,永不修改 跟随后端,可失效重拉
patchesById 已入库行的修改补丁,只存真正变化的字段 跟随编辑,改回原值即清空
newRows 手动新增、尚未入库的临时行 跟随编辑,单独存放
deletedById 删除黑名单,汇总时自动跳过 跟随编辑

页面渲染的每一行都是 原始行 + 补丁 临时拼出来的,所以原始数据永远是干净的, 「撤销编辑」和「重新拉取」都不会打架。


安装

个人 / 跨项目复用,三种方式按需选:

# 方式 1:直接从本地路径装(推荐,改完源码重新 build 即可)
npm install <本包所在路径>

# 方式 2:npm link(想多项目共享同一份实时源码时用)
cd <本包所在路径> && npm link
cd <你的项目> && npm link remote-editable-pager

# 方式 3:零构建直接搬(不想引入构建链路时)
#   把 dist/esm/ 下 4 个 .js 丢进项目里 import,或把 dist/cjs/ 用于老项目

支持三种消费方式,已通过真实安装验证:

import { createRemoteEditablePager } from 'remote-editable-pager'  // ESM
const { createRemoteEditablePager } = require('remote-editable-pager') // CJS

快速开始

import { createRemoteEditablePager } from 'remote-editable-pager'

const pager = createRemoteEditablePager({
  pageSize: 15,

  // 【必填】分页接口。返回 { list, total },也兼容 records / totalCount / totalRecords
  fetchPage: async ({ pageNum, pageSize }) => {
    const res = await api.listDetailPage({ orderId: 'A001', pageNum, pageSize })
    return { list: res.data.list, total: res.data.total }
  },

  // 以下均可选
  getRowId: (row) => row.id,                            // 取行唯一 ID,默认 row.id
  normalizeRow: (row) => ({ ...row, qty: row.qty || 0 }), // 后端行格式化
  deleteRemote: (id) => api.deleteDetail(id),           // 传了 = 删除即落库
  maxTotal: 10000,                                      // 明细行数上限
  minRows: 1,                                           // 至少保留 1 行
  debug: false,                                         // 打开内部日志
})

// 1. 加载某一页并渲染
const rows = await pager.loadPage(1)

// 2. 用户改完表格后,把当前页数据回传同步(通常放在表格数据变化的 watch 里)
rows[0].qty = 100
pager.cacheEdits(rows)

// 3. 翻页、增删
await pager.loadPage(2)
await pager.add({ name: '手动新增行' })
await pager.remove(rows[3])

// 4. 保存:一次性拿到全量数据(含所有页的改动)
const allRows = await pager.collectAllRows()
await api.save({ details: allRows })

collectAllRows() 会自动把还没加载过的分页补齐,再过滤删除行、叠加补丁、拼接新增行, 返回的数据已经剥掉了所有内部标记,可以直接丢给后端。


API

配置项

字段 类型 默认值 说明
fetchPage (params: { pageNum, pageSize }) => Promise<{ list, total }> 必填 分页查询接口。缺省会立即抛错,而不是等到第一次请求
pageSize number 15 每页条数
getRowId (row) => string | number row => row.id 取行唯一 ID。取不到 ID 的已入库行无法删除
normalizeRow (row) => row 浅拷贝 后端行格式化,结果会存入原始仓库
deleteRemote (id: string, row) => Promise<void> — 传了 = 真删库模式;不传 = 前端黑名单模式(见下)。注意 id 为 string 类型,后端若要求数字请自行转换
maxTotal number 10000 明细行数上限,达到后 add() 返回 { added: false }
minRows number 0 明细最少保留行数,触底的 remove() 返回 { removed: false, reason: 'minRows' }
debug boolean false 打开内部日志;关闭时不产生任何日志开销
logger (event, payload) => void console 自定义日志出口,仅 debug: true 时生效
onStateChange (snapshot) => void — 状态变更回调,给需要显式订阅的框架用

实例方法

方法 说明
loadPage(pageNum, { preloadPrevious }) 切换页码,返回当前页渲染数据。默认会预加载上一页让回退更顺滑。越界页码会被自动钳制到有效范围(如只有 7 页时传 999 → 落到第 7 页;传 0 或负数 → 第 1 页),不会给用户一个空白页
cacheEdits(rows) 把表格上的编辑同步进内部缓存。必须在表格数据变化后调用,否则改动不会被保存
add(row?) 新增一行并自动跳到最后一页,返回 { added, pageNum, row }
remove(row) 删除一行,返回 { removed, reason? }
collectAllRows() 汇总全量数据,保存前调用。若某页请求失败会直接抛错,不会静默返回不完整数据
changePageSize(pageSize) 改每页条数,清空缓存并回到第一页
reset({ keepChanges }) 重置状态。keepChanges: true 可保留编辑记录
requestPage(pageNum) 单独请求并缓存某页,一般不需要直接用
syncTotal() 内部方法,重新计算前端总条数,一般不需要直接用

状态

pager.state 可直接绑定到 UI:

pager.state.pageNum       // 当前页码
pager.state.pageSize      // 每页条数
pager.state.total         // 前端展示总条数 = 后端有效未删行 + 本地新增行
pager.state.remoteTotal   // 数据库真实总条数;null 表示尚未加载过
pager.state.displayRows   // 当前页渲染数据

state 上还有 pageCache / patchesById / newRows 等内部字段,属于实现细节,不建议依赖。


两种删除模式

模式 触发条件 行为
真删库 传了 deleteRemote 删除即调用后端接口,成功后清空全部缓存并重拉(避免下标整体错位)
前端黑名单 未传 deleteRemote 只在前端记账,collectAllRows() 汇总时自动跳过。适合「保存时才统一落库」的表单

两种模式下删除的行都会立刻从当前页渲染中消失,且 total 会同步减一。


接入示例

完整的落地流程(Vue 2 + Element UI 可复制页面、旧实现迁移步骤、故障排查表)见 docs/INTEGRATION.md。

Vue 2

export default {
  data() {
    return { pagerState: { pageNum: 1, pageSize: 15, total: 0, displayRows: [] } }
  },
  created() {
    this.pager = createRemoteEditablePager({
      pageSize: 15,
      fetchPage: async ({ pageNum, pageSize }) => {
        const res = await api.listPage({ orderId: this.orderId, pageNum, pageSize })
        return { list: res.data.list, total: res.data.total }
      },
    })
    this.pagerState = this.pager.state
    this.pager.loadPage(1)
  },
  watch: {
    // 表格数据变化 → 同步编辑缓存
    'pagerState.displayRows': {
      deep: true,
      handler(rows) {
        this.pager.cacheEdits(rows)
      },
    },
  },
  methods: {
    onPageChange(pageNum) {
      this.pager.loadPage(pageNum)
    },
    async onSave() {
      const all = await this.pager.collectAllRows()
      await api.save({ details: all })
    },
  },
}

表格数据渲染用 pagerState.displayRows,编辑后由 watch 回传给 cacheEdits。 这个「渲染 → watch → 回传」的闭环是必须的:库无法知道表格里哪一格被改过。

Vue 3

import { ref, shallowRef, watch } from 'vue'

const pager = shallowRef(null)
const rows = ref([])
const total = ref(0)
const pageNum = ref(1)

pager.value = createRemoteEditablePager({
  pageSize: 15,
  fetchPage,
  onStateChange(snapshot) {
    total.value = snapshot.total
    pageNum.value = snapshot.pageNum
  },
})

watch(rows, (value) => pager.value.cacheEdits(value), { deep: true })

async function load(page) {
  rows.value = await pager.value.loadPage(page)
}

React

const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const pagerRef = useRef(null)

if (!pagerRef.current) {
  pagerRef.current = createRemoteEditablePager({
    pageSize: 15,
    fetchPage,
    onStateChange: (snapshot) => setTotal(snapshot.total),
  })
}

const load = async (page) => setRows(await pagerRef.current.loadPage(page))

useEffect(() => {
  pagerRef.current.cacheEdits(rows)
}, [rows])

后端接口约定

fetchPage 需要返回能取到「列表 + 总数」的对象:

{
  list: [...],        // 也兼容 records
  total: 120,         // 也兼容 totalCount / totalRecords
}

总数是必需的 —— 缺了会直接抛错,而不是静默返回半截数据。 这是刻意的:分页库最危险的失败模式是「少了几行但没人发现」。


设计要点

  • 双版本锁防竞态:epoch 管缓存失效(切页大小、真删库后作废全部旧请求), viewEpoch 管渲染失效(快速连点翻页时只认最后一次渲染)。
  • 下标校正:删除行后,后续行的全局源下标会整体前移,getSourceIndex() 负责换算, 保证翻页既不重复也不丢行。
  • 补丁盒只存变化字段:只是翻页浏览过的行不会占用补丁盒内存; 改完又改回原值的行会自动清空补丁。
  • 汇总宁可报错不丢数据:collectAllRows() 若发现某一页没加载成功, 会直接抛错而不是返回残缺数组。

常见坑

  1. 忘了调 cacheEdits —— 表格上的改动不会被保存。它必须挂到表格数据变化的监听上。
  2. displayRows 不要直接改后端行对象 —— 改 displayRows 里的行,然后交给 cacheEdits。
  3. 新增行没有数据库 ID —— 这是正常的,它们存在 newRows 盒里,保存时一并提交。
  4. minRows 统计的是前端可见总行数,包含本地新增行。
  5. 改了 pageSize 后旧缓存的页码全部失效 —— 所以 changePageSize() 会清缓存并回到第一页。

配套工具

import { fetchAllPages } from 'remote-editable-pager'

// 一次拉全量,适合导出 / 批量校验等「只要数据不要分页展示」的场景
const rows = await fetchAllPages(api.listDetailPage, { orderId: 'A001' }, 200)

后端有 total 时按总数精确翻页;没有 total 时按「本页是否满页」自动降级判断。


开发

npm install
npm test        # 编译 + 跑 60 个单测
npm run build   # 产出 dist/esm + dist/cjs + dist/types
npm run typecheck
npm run demo    # 启动交互式演示 → http://localhost:5178/demo/

npm run demo 会起一个零依赖静态服务器,演示页面里可以直接体验: 跨页编辑不丢改动、补丁盒只记变化字段、新增行自动跳末页、 前端黑名单与真删库两种删除模式的差异、保存时汇总全量数据。

测试覆盖:分页边界、缓存命中、跨页编辑、新增行跨页、删除双模式与下标校正、 并发竞态与版本锁、汇总完整性、fetchAllPages 的两种终止策略。

npm install 会自动触发 prepare 构建,所以从仓库 clone 下来直接装也能拿到 dist。


来源与演进

本包由一份在生产系统中长期使用的企业级工具(原为单文件 JavaScript 实现)重写而来, 在保持对外 API 完全兼容的前提下补齐了类型、测试与构建产物,并修复了原实现中的缺陷:

项 说明
修复 新增行数量跨页时,本页容量被误算,导致第 2 页起重复渲染同一批新增行。已用不变量测试固化(每页行数严格等于 min(pageSize, total - (pageNum-1)*pageSize),且跨页无重复、无丢失)
修复 cacheEdits 原本把整行复制进补丁盒,与「只存改动字段」的设计意图不符,且补丁盒会随浏览页数线性增长。已改为字段级比对
改进 日志从硬编码 console.log 改为 debug 开关(默认静默)
改进 FetchPageResult 类型收紧为「至少提供 list 或 records 之一」—— 字段名写错(如误写 data)会在编译期被拦住,而不是运行时静默得到一张空表
改进 fetchAllPages 增加泛型参数 <T>,调用方可直接拿到自己的行类型,无需手动断言
新增 TypeScript 类型声明、fetchPage 返回字段兼容、onStateChange 订阅回调、单测、双格式构建、交互式 demo

兼容性说明

本包为原实现的超集:原有 10 个公开成员签名与行为保持不变,可直接替换引入。 如需回迁移到原工程项目,请参见仓库内 docs/BACKPORT.md(该文档不随包发布)。


License

MIT

Related Packages

google-places-core

A lightweight Google Places API logic library.

2,343 7
@kokoa/clotho

JSON-defined visualization animations — framework-agnostic scene graph with Reac...

3,181 4
@alekstar79/flip-combo

Vanilla TypeScript calculator + calendar + 3D flipper. Framework-agnostic, zero...

538 0
simple-pagination-hook

Lightweight pagination utility with math-based approach. 22x faster than alterna...

409 2

Version History

Version Released License
1.0.0 MIT
Pre-releases (1)
Version Released License
0.0.0-stage