服务端窗口取数(windowed 配方)
面向十万行级数据:组件只负责算「需要哪一段」,取数、取消、合并由宿主负责。本页给出可直接落地的四步配方与可运行示例(
oas-table演示;gantt / resource / tree / list 契约相同,差异见文末)。
契约
- 属性:
windowed(开启)+total(全量行数)+window-start(data[0]的绝对行号);data只承载[window-start, window-start + data.length)。 - 事件:
oas-window-request,detail: { start, end, reason }(半开区间、绝对行号;reason=init/scroll/resize/data)。同一期望区间不重复派发;被覆盖后复位。 - 前提:必须有可测滚动视口(
height或可测max-height),否则组件不派发请求(devWarn一次)。表格 windowed 的切片须为扁平行(不递归children)。
四步配方
- 分段取数:把请求区间对齐到固定段(如 200 行/段),一次响应覆盖后续多个窗口——请求数通常降一个数量级。
- 只保留最新:连续滚动时取消在途请求(
AbortController)或用请求序号丢弃过期响应;否则旧响应晚到会让视图回跳。 - 按 id 合并回全量:
oas-tasks-change/ 编辑回写只含切片;宿主必须按 id 合并,不得整体替换全量数据。 - 原子回填:先写
data再写window-start(同一同步段内),避免「新切片 + 旧起点」的中间态。
// 真实项目:分段拉取 + 只保留最新 + 原子回填
let seq = 0
let controller: AbortController | null = null
const SEG = 200
el.addEventListener('oas-window-request', async (event) => {
const { start, end } = (event as CustomEvent<{ start: number; end: number }>).detail
const my = ++seq
controller?.abort()
controller = new AbortController()
const segFrom = Math.floor(start / SEG)
const segTo = Math.floor((Math.max(start, end - 1)) / SEG)
const from = segFrom * SEG
const to = Math.min(total, (segTo + 1) * SEG)
const rows = await fetch(`/api/rows?from=${from}&to=${to}`, {
signal: controller.signal,
}).then((r) => r.json())
if (my !== seq) return // 过期响应,丢弃
el.setAttribute('window-start', String(from))
el.setAttribute('data', JSON.stringify(rows))
})可运行示例(本地模拟服务端)
以下 demo 用本地数据模拟服务端:200 行/段、120ms 延迟、按段缓存、只保留最新请求。滚动表格观察下方计数——段内滚动不会再发请求(一次响应覆盖多个窗口)。
windowed + 分段缓存 + 只保留最新(模拟 5 万行)
等待首个窗口请求…
甘特 / 资源窗口化(时间轴托管)
行窗口之外,时间轴组件可用 range-start / range-end 让宿主托管时间范围;可视时间窗接近轴端时派发 oas-range-request,宿主扩展范围即可。下面两个 demo 演示「按段取行 + 按需扩轴」。
甘特(8000 任务 / 轴一年 / 段 200)
gantt windowed + 滚到轴端自动扩轴
等待首个窗口请求…
资源(400 资源 / 轴半年 / 段 50)
resource windowed + 滚到轴端自动扩轴
等待首个窗口请求…
React / Vue 宿主封装
Web Components 桥接注意:Vue 需要在编译器把 oas-* 标记为自定义元素(isCustomElement: (tag) => tag.startsWith('oas-'));React 19+ 原生支持自定义元素(19 以下用 ref + addEventListener)。不要在事件回调里同步写属性(会重入 update),回填放到取数完成后。
React
import { useEffect, useRef } from 'react'
const SEG = 200
export function WindowedTable({ total }: { total: number }) {
const ref = useRef<HTMLElement | null>(null)
useEffect(() => {
const el = ref.current
if (!el) return
let seq = 0
let controller: AbortController | null = null
const onRequest = async (event: Event) => {
const { start, end } = (event as CustomEvent<{ start: number; end: number }>).detail
const my = ++seq
controller?.abort()
controller = new AbortController()
const from = Math.floor(start / SEG) * SEG
const to = Math.min(total, from + SEG)
const rows = await fetch(`/api/rows?from=${from}&to=${to}`, { signal: controller.signal })
.then((r) => r.json())
.catch(() => null)
if (!rows || my !== seq) return
el.setAttribute('data', JSON.stringify(rows))
el.setAttribute('window-start', String(from))
}
el.addEventListener('oas-window-request', onRequest)
return () => {
controller?.abort()
el.removeEventListener('oas-window-request', onRequest)
}
}, [total])
return (
<oas-table
ref={ref}
windowed=""
total={String(total)}
window-start="0"
height="320"
row-key="id"
columns={JSON.stringify([{ key: 'id', title: 'ID' }, { key: 'name', title: '名称' }])}
/>
)
}TypeScript 下自定义元素需扩展 JSX 类型(如声明
oas-table的 intrinsic element),或在该标签上用// @ts-expect-error。
Vue 3
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const SEG = 200
const table = ref<HTMLElement | null>(null)
let seq = 0
let controller: AbortController | null = null
async function onWindowRequest(event: Event) {
const el = table.value
if (!el) return
const { start, end } = (event as CustomEvent<{ start: number; end: number }>).detail
const my = ++seq
controller?.abort()
controller = new AbortController()
const from = Math.floor(start / SEG) * SEG
const to = Math.min(50000, from + SEG)
const rows = await fetch(`/api/rows?from=${from}&to=${to}`, { signal: controller.signal })
.then((r) => r.json())
.catch(() => null)
if (!rows || my !== seq) return
el.setAttribute('data', JSON.stringify(rows))
el.setAttribute('window-start', String(from))
}
onMounted(() => table.value?.addEventListener('oas-window-request', onWindowRequest))
onBeforeUnmount(() => {
controller?.abort()
table.value?.removeEventListener('oas-window-request', onWindowRequest)
})
</script>
<template>
<oas-table
ref="table"
windowed
total="50000"
window-start="0"
height="320"
row-key="id"
:columns="JSON.stringify([{ key: 'id', title: 'ID' }, { key: 'name', title: '名称' }])"
/>
</template>事件名在模板里可写
@oas-window-request="onWindowRequest"(Vue 3 会把oas-window-request当自定义事件转发)。
各组件差异
| 组件 | windowed 语义 | 额外前提 |
|---|---|---|
oas-table | 行切片;total 复用为全量行数 | height;切片扁平行;本地排序/筛选/分组/分页交宿主 |
oas-gantt | 行切片 | height;切片须扁平行;range-start/range-end 托管时间轴;auto-schedule/关键路径/行重排跳过 |
oas-resource | 资源行切片 | height;range-start/range-end 托管时间轴;负载统计仅覆盖已装载切片 |
oas-tree | 根节点切片(子节点随根携带) | height;本地 filter/check-all 跳过;回填建议「扩窗累积」 |
oas-list | 行切片 | height 或可测 max-height;列表语义由组件自持(宿主无需 role) |
gantt / resource 在可视时间窗接近轴端时还会派发 oas-range-request(detail: { startMs, endMs, reason }),用于扩展时间范围——同样遵循「只保留最新 + 合并」原则。
常见坑
- 回填不足期望区间:宿主只回一小片 → 组件继续请求。请返回覆盖
[start, end)的段(或超集)。 - 整体替换数据:编辑回写(
oas-tasks-change)只含切片;整体替换会丢未装载行。 - 忘记
window-start:数据与绝对行号错位(渲染到错误行、a11y 行号错误)。 - 在事件回调里同步写其它属性(如
loading):会重入 update(渲染中途scrollTop可能瞬时归零,并多发请求)。请在取数完成后再回填;确需 loading 等状态,放到微任务或取数完成后再写。 - 逐窗口取数:请求数会随滚动线性增长;按段取数可降一个数量级。
- 无
height:组件不会派发请求(无滚动视口),不是 bug,是需要先给视口。 - property 写入早于组件升级:页面脚本里写
el.tasks/el.resources等 property 前,先await customElements.whenDefined('oas-gantt')(或对应 tag)——元素未升级时 property 会变成普通字段并遮蔽升级后的 setter,数据静默丢失(本页 demo 已按此处理;改用 attribute 通道则无此问题)。