remote-editable-pager
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()若发现某一页没加载成功, 会直接抛错而不是返回残缺数组。
常见坑
- 忘了调
cacheEdits—— 表格上的改动不会被保存。它必须挂到表格数据变化的监听上。 displayRows不要直接改后端行对象 —— 改displayRows里的行,然后交给cacheEdits。- 新增行没有数据库 ID —— 这是正常的,它们存在
newRows盒里,保存时一并提交。 minRows统计的是前端可见总行数,包含本地新增行。- 改了
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
JSON-defined visualization animations — framework-agnostic scene graph with Reac...
Vanilla TypeScript calculator + calendar + 3D flipper. Framework-agnostic, zero...
Lightweight pagination utility with math-based approach. 22x faster than alterna...
Version History
| Version | Released | License |
|---|---|---|
| 1.0.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |