场景摘要
- 页面:内容流/商品列表,数据量 >10k,支持筛选、排序、无限滚动、批量操作。部署在微前端容器。
- 目标:性能、可扩展性、可维护性。
- 已知问题:1) key 不稳定 2) 订阅泄漏 3) 滚动未节流 4) 同步过滤/排序阻塞 5) 过度嵌套导致回流/重绘 6) N+1 请求未合并与缓存。
总体设计要点
- 组件:VirtualList(虚拟滚动),可插拔单元渲染器 CellRenderer,骨架占位与行高估算,稳定 key 策略。
- Hooks:useVirtualize(计算可见窗口/高度/测量回调),useInfiniteScroll(IntersectionObserver 触达加载),请求合并/预取/缓存。
- 数据层:请求合并与去重、预取下一屏、分页/游标缓存、批量详情请求聚合。
- 渲染节流:scroll/resize 使用 requestAnimationFrame/被动监听,读写分离,避免布局抖动。
- 布局:容器绝对定位 + translateY,CSS contain/content-visibility,估算高的占位骨架避免 CLS。
- 度量:首屏、首次可交互、首条渲染、INP/响应延迟上报;组件内插桩。
- 微前端:作用域隔离(样式、事件、缓存命名)、无全局污染、容器提供的滚动根自动识别。
一、四大方面最佳实践
- 性能优化
- 虚拟化与可见窗口:仅渲染可见 + overscan,变量高度需“估算 + 增量测量”以平衡跳动与准确度。
- 稳定 key 策略:优先 item.id;无 id 时构造 hashKey(业务稳定字段);骨架或未加载记录用 pageToken+index 的 composite key,避免 index 作为最终 key。
- 滚动/尺寸事件节流:使用 rAF 合帧,passive:true,避免频繁 setState;读写 DOM 分离(先读 scrollTop/clientHeight,再在 rAF 中 setState)。
- 请求合并/去重/预取:
- 同步相同查询参数的请求合并(in-flight de-dup)。
- 批量详情 N+1 合并成单请求(微任务收集 ID,定时/上限触发)。
- 预取:接近尾部时预取下一页;筛选/排序变更后,先命中缓存(stale-while-revalidate)。
- 计算移出主线程:大型过滤/排序使用 Web Worker/Offscreen(可选),或在数据层异步切片分批处理,避免长任务。
- 图片/媒体:lazy-loading + 解码提示(HTMLImageElement.decode),统一尺寸或占位,避免 CLS。
- CSS:content-visibility:auto,contain: layout style paint;contain-intrinsic-size 设定估计高度,降低布局成本。
- 布局
- 列表容器:position: relative;项使用 position: absolute + transform: translateY(top);包裹一个“填充高度”的 spacer 元素。
- 行高策略:estimatedItemHeight 基线 + 测量真实高度缓存 map;渲染后用 ResizeObserver 或 ref 测量矫正;局部校正不触发大范围回流。
- 骨架占位:在真实数据未到时渲染固定高骨架,确保不挤压已渲染区域;骨架 key 与目标数据 key 绑定,数据到达后平滑替换。
- 简化嵌套:避免多层 flex/nested grids;列表项内部布局固定栅格,减少 min-content 计算。
- 滚动容器选择:优先在组件内自给滚动容器,或显式接收 root 容器(适配微前端容器的 shadow/嵌套滚动)。
- 复用性
- 可插拔单元渲染器:renderItem 或 CellRenderer 作为插槽,接收 data、index、selected、测量 ref。
- 与数据源解耦:VirtualList 不关心 fetch,外置 DataClient;通过回调 onRangeChange 帮助上层预取。
- 自定义 keyExtractor、estimatedItemHeight、overscan、行内缓存策略可配置。
- 与状态隔离:选择/勾选为受控或受控+回调模式;跨页选择依赖 id-set 存储。
- 可测试性
- Hooks 与组件分层:useVirtualize/useInfiniteScroll 可单测,VirtualList 做薄。
- 模拟 DOM 尺寸和滚动:使用 jsdom + fake timers + raf stub;IntersectionObserver/ResizeObserver mock。
- 覆盖:key 稳定性、滚动节流、可见范围计算、卸载清理、请求合并/缓存、骨架替换、度量上报。
二、针对已知问题的解决策略
- 使用索引作为 key 导致频繁重排
- 规定 keyExtractor 优先 id;无 id 时业务字段哈希(如 sku + variant);骨架共享 key 映射到目标 id。
- 在 VirtualList 中对 keyExtractor 必填校验,开发期警告 index 作为 key。
- 列表项订阅未解除造成泄漏
- 所有事件/observer 在 useEffect 返回中清理;单元渲染器若有订阅,提供 useEffect clean;VirtualList 卸载时统一清理 ResizeObserver/IntersectionObserver/滚动监听。
- 数据层 in-flight 请求支持 AbortController,筛选/排序切换时取消旧请求。
- 滚动事件未节流引发卡顿
- 仅注册一次被动 scroll 监听;内部通过 rAF 合帧更新;还可可选节流帧率上限(如 60Hz -> 30Hz)。
- 过滤与排序在主线程同步执行,阻塞交互
- 小集合:使用 microtask 切片 setTimeout(0)/requestIdleCallback。
- 大集合:Web Worker 排序/过滤;或服务端完成排序过滤,前端只分页;UI 立即渲染骨架/上一次结果,后台刷新。
- 过度嵌套布局造成重排与重绘
- 扁平化结构;列表项外层绝对定位 + 内部单层布局;使用 CSS contain 和 content-visibility;避免百分比高度链式计算。
- N+1 请求缺少合并与缓存
- DataClient 实现:
- 统一 cacheKey(filter, sort, page/cursor) 的分页缓存与 in-flight 去重。
- 详情聚合器:collect(ids) 在小延时内合并为 /items?ids=… 请求,支持最大包大小/时间窗。
- 预取下一页;滚动到阈值触发;缓存策略 SWR+TTL;错误熔断与退避。
三、核心代码示例(TypeScript/React)
types.ts
export type Item = { id: string; [k: string]: any };
export type Query = {
filter: Record<string, any>;
sort: { field: string; order: 'asc'|'desc' };
cursor?: string; // or page
pageSize: number;
};
export type Page = {
items: T[];
nextCursor?: string;
};
dataClient.ts
type CacheEntry = { promise: Promise<Page>; ts: number };
export class DataClient {
private pageCache = new Map<string, CacheEntry>();
private inflight = new Map<string, Promise<Page>>();
private detailsInflight = new Map<string, Promise<Record<string, T>>>();
private batchIds = new Set();
private batchTimer?: number;
constructor(
private fetchPageFn: (q: Query, signal?: AbortSignal) => Promise<Page>,
private fetchDetailsFn?: (ids: string[], signal?: AbortSignal) => Promise<Record<string, T>>,
private ttlMs = 30_000
) {}
private key(q: Query) {
return JSON.stringify({ f: q.filter, s: q.sort, c: q.cursor, p: q.pageSize });
}
fetchPage(q: Query, signal?: AbortSignal) {
const k = this.key(q);
const now = Date.now();
const cached = this.pageCache.get(k);
if (cached && now - cached.ts < this.ttlMs) return cached.promise;
if (this.inflight.has(k)) return this.inflight.get(k)!;
const p = this.fetchPageFn(q, signal).finally(() => this.inflight.delete(k));
this.inflight.set(k, p);
this.pageCache.set(k, { promise: p, ts: now });
return p;
}
// Batch details by microtask/window
fetchDetailsBatched(ids: string[], signal?: AbortSignal, windowMs = 8, maxBatch = 100) {
if (!this.fetchDetailsFn) throw new Error('details fetcher missing');
ids.forEach(id => this.batchIds.add(id));
return new Promise<Record<string, T>>((resolve, reject) => {
if (this.batchTimer) clearTimeout(this.batchTimer);
this.batchTimer = window.setTimeout(async () => {
const batch = Array.from(this.batchIds).slice(0, maxBatch);
this.batchIds.clear();
const key = batch.sort().join(',');
if (this.detailsInflight.has(key)) {
this.detailsInflight.get(key)!.then(resolve, reject);
return;
}
const prom = this.fetchDetailsFn!(batch, signal).finally(() => this.detailsInflight.delete(key));
this.detailsInflight.set(key, prom);
prom.then(resolve, reject);
}, windowMs);
});
}
clear() {
this.pageCache.clear();
}
}
useVirtualize.ts
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
type KeyExtractor = (item: T, index: number) => string;
export type UseVirtualizeOptions = {
count: number;
estimateItemHeight: number;
overscan?: number;
getItemKey: KeyExtractor;
getItemAt: (index: number) => T | undefined;
scrollRoot?: HTMLElement | null;
onRangeChange?: (start: number, end: number) => void;
};
export function useVirtualize(opts: UseVirtualizeOptions) {
const {
count,
estimateItemHeight,
overscan = 5,
getItemKey,
getItemAt,
onRangeChange,
} = opts;
const scrollRootRef = useRef<HTMLElement | null>(opts.scrollRoot ?? null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportH, setViewportH] = useState(0);
// heightMap stores measured heights; posCache stores cumulative tops
const heightMap = useRef<Map<string, number>>(new Map());
const posCache = useRef<number[]>([]); // prefix sums with estimate fallback
// Build prefix sums lazily
const totalHeight = useMemo(() => {
const arr: number[] = new Array(count);
let acc = 0;
for (let i = 0; i < count; i++) {
const item = getItemAt(i);
const key = item ? getItemKey(item, i) : placeholder-${i};
const h = heightMap.current.get(key) ?? estimateItemHeight;
arr[i] = acc;
acc += h;
}
posCache.current = arr;
return acc;
}, [count, estimateItemHeight, getItemAt, getItemKey]);
const findStartIndex = (st: number) => {
// binary search over posCache
let lo = 0, hi = posCache.current.length - 1, mid = 0;
while (lo <= hi) {
mid = (lo + hi) >> 1;
const top = posCache.current[mid];
if (top < st) lo = mid + 1;
else hi = mid - 1;
}
return Math.max(0, lo - 1);
};
const [range, setRange] = useState({ start: 0, end: 0 });
// rAF-scrolled update
const rafId = useRef<number | null>(null);
const onScroll = useCallback((e: Event) => {
if (rafId.current != null) return;
rafId.current = requestAnimationFrame(() => {
rafId.current = null;
const root = scrollRootRef.current!;
setScrollTop(root.scrollTop);
});
}, []);
// passive listeners and cleanup
useEffect(() => {
const root = scrollRootRef.current ?? document.documentElement;
scrollRootRef.current = root;
const resize = () => setViewportH(root.clientHeight);
setViewportH(root.clientHeight);
root.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', resize, { passive: true });
return () => {
root.removeEventListener('scroll', onScroll as any);
window.removeEventListener('resize', resize as any);
if (rafId.current != null) cancelAnimationFrame(rafId.current);
};
}, [onScroll]);
// compute visible range
useEffect(() => {
const start = findStartIndex(scrollTop);
let end = start;
const limit = scrollTop + viewportH;
while (end < count && posCache.current[end] < limit) end++;
const nextStart = Math.max(0, start - overscan);
const nextEnd = Math.min(count - 1, end + overscan);
const newRange = { start: nextStart, end: nextEnd };
setRange(prev => (prev.start === newRange.start && prev.end === newRange.end ? prev : newRange));
onRangeChange?.(nextStart, nextEnd);
}, [scrollTop, viewportH, count, overscan, onRangeChange]);
// measurement callback for items
const measureRefs = useRef(new Map<string, (el: HTMLElement | null) => void>());
const roRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
roRef.current = new ResizeObserver(entries => {
for (const entry of entries) {
const el = entry.target as HTMLElement;
const key = el.dataset.vkey;
if (!key) continue;
const newH = entry.contentRect.height;
const oldH = heightMap.current.get(key);
if (oldH !== newH && newH > 0) {
heightMap.current.set(key, newH);
// schedule layout recompute
setScrollTop(prev => prev + 0); // trigger effects; cheap no-op
}
}
});
return () => roRef.current?.disconnect();
}, []);
const getMeasureRef = useCallback((key: string) => {
if (measureRefs.current.has(key)) return measureRefs.current.get(key)!;
const refCb = (el: HTMLElement | null) => {
if (!el) return;
el.dataset.vkey = key;
roRef.current?.observe(el);
};
measureRefs.current.set(key, refCb);
return refCb;
}, []);
return {
range,
totalHeight,
getTopForIndex: (i: number) => posCache.current[i] ?? i * estimateItemHeight,
getMeasureRef,
setScrollRoot: (el: HTMLElement | null) => (scrollRootRef.current = el),
};
}
import { useEffect, useRef } from 'react';
type UseInfiniteOptions = {
root?: HTMLElement | null;
onLoadMore: () => void | Promise;
rootMargin?: string; // e.g., '200px'
disabled?: boolean;
};
export function useInfiniteScroll(opts: UseInfiniteOptions) {
const { root, onLoadMore, rootMargin = '600px', disabled } = opts;
const sentinelRef = useRef<HTMLDivElement | null>(null);
const ioRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (disabled) return;
const rootEl = root ?? null;
ioRef.current = new IntersectionObserver(
entries => {
entries.forEach(e => {
if (e.isIntersecting) onLoadMore();
});
},
{ root: rootEl, rootMargin, threshold: 0 }
);
const s = sentinelRef.current;
if (s) ioRef.current.observe(s);
return () => ioRef.current?.disconnect();
}, [root, onLoadMore, rootMargin, disabled]);
return { sentinelRef };
}
VirtualList.tsx
import React, { CSSProperties, useMemo, useRef } from 'react';
import { useVirtualize } from './useVirtualize';
type VirtualListProps = {
items: (T | null)[]; // null indicates placeholder
renderItem: (ctx: { item: T | null; index: number; measureRef: (el: HTMLElement | null) => void; selected: boolean }) => React.ReactNode;
keyExtractor: (item: T | null, index: number) => string; // must be stable; null should map to future id or composite key
estimateItemHeight: number;
overscan?: number;
height?: number | string;
width?: number | string;
scrollRootRef?: React.RefObject;
selectedIds?: Set;
onRangeChange?: (start: number, end: number) => void;
};
export function VirtualList<T extends { id?: string }>(props: VirtualListProps) {
const {
items,
renderItem,
keyExtractor,
estimateItemHeight,
overscan = 5,
height = '100%',
width = '100%',
scrollRootRef,
selectedIds,
onRangeChange,
} = props;
const getItemAt = (i: number) => items[i] ?? undefined;
const getItemKey = (item: T | null, index: number) => keyExtractor(item, index);
const { range, totalHeight, getTopForIndex, getMeasureRef, setScrollRoot } = useVirtualize<T | null>({
count: items.length,
estimateItemHeight,
overscan,
getItemKey: (it, i) => getItemKey(it, i),
getItemAt: (i) => getItemAt(i),
onRangeChange,
});
const containerRef = useRef<HTMLDivElement | null>(null);
// assign scroll root if provided
React.useEffect(() => {
if (scrollRootRef?.current) setScrollRoot(scrollRootRef.current);
else if (containerRef.current) setScrollRoot(containerRef.current);
}, [scrollRootRef, setScrollRoot]);
const containerStyle: CSSProperties = {
position: 'relative',
height,
width,
overflow: 'auto',
contain: 'strict',
};
const spacerStyle: CSSProperties = {
height: totalHeight,
width: '1px',
};
const children: React.ReactNode[] = [];
for (let i = range.start; i <= range.end; i++) {
const item = items[i] ?? null;
const key = getItemKey(item, i);
const top = getTopForIndex(i);
const style: CSSProperties = {
position: 'absolute',
transform: translateY(${top}px),
width: '100%',
willChange: 'transform',
contain: 'content',
};
const isSelected = !!(selectedIds && item && (item as any).id && selectedIds.has((item as any).id as string));
children.push(
<div key={key} style={style} data-index={i} ref={getMeasureRef(key)} aria-posinset={i + 1} aria-setsize={items.length}>
{renderItem({ item, index: i, measureRef: getMeasureRef(key), selected: isSelected })}
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { VirtualList } from './VirtualList';
import { useInfiniteScroll } from './useInfiniteScroll';
import { DataClient, Query } from './dataClient';
const loadingRef = useRef(false);
const abortRef = useRef<AbortController | null>(null);
const loadNext = useCallback(async () => {
if (loadingRef.current) return;
loadingRef.current = true;
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
const cursor = pages.length ? (pages.pagesCursor as any) : undefined;
const q = { ...query, cursor };
try {
const res = await client.fetchPage(q, ac.signal);
setPages(prev => [...prev, res.items]);
(pages as any).pagesCursor = res.nextCursor ?? null;
} finally {
loadingRef.current = false;
}
}, [client, pages, query]);
// 初次加载
React.useEffect(() => {
setPages([]);
(pages as any).pagesCursor = undefined;
loadNext();
return () => abortRef.current?.abort();
}, [query]);
const keyExtractor = useCallback((item: Product | null, index: number) => {
if (item?.id) return item.id;
return hashKeyFromFilterSort(index, query);
}, [query]);