Skip to content

服务端窗口取数(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)。

四步配方 ​

  1. 分段取数:把请求区间对齐到固定段(如 200 行/段),一次响应覆盖后续多个窗口——请求数通常降一个数量级。
  2. 只保留最新:连续滚动时取消在途请求(AbortController)或用请求序号丢弃过期响应;否则旧响应晚到会让视图回跳。
  3. 按 id 合并回全量:oas-tasks-change / 编辑回写只含切片;宿主必须按 id 合并,不得整体替换全量数据。
  4. 原子回填:先写 data 再写 window-start(同一同步段内),避免「新切片 + 旧起点」的中间态。
ts
// 真实项目:分段拉取 + 只保留最新 + 原子回填
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 ​

tsx
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 ​

vue
<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 通道则无此问题)。

相关 ​

基于 MIT OR Apache-2.0 双许可发布