高性能虚拟列表实现:从原理到生产级组件

3696 字
18 分钟
高性能虚拟列表实现:从原理到生产级组件

10 万条数据的列表,直接渲染?DOM 节点撑爆内存,滚动卡成 PPT。这不是夸张——一个普通的列表项哪怕只有 3 个 DOM 节点,10 万条就是 30 万个 DOM 节点,任何浏览器都扛不住。

虚拟列表(Virtual List / Virtualized List)是解决长列表性能问题的核心技术。它的原理简单而优雅:只渲染可视区域内的元素。但从原理到生产级实现,中间有大量的工程细节需要处理。

本文将从零开始,手写定高虚拟列表、不定高虚拟列表、动态测量方案、滚动锚定和无限滚动,一步步构建一个完整的生产级虚拟列表组件。

一、虚拟列表的基本原理#

1.1 核心思路#

┌─────────────────────────┐
│ 不可见区域(上方) │ ← 不渲染,用 padding/transform 占位
├─────────────────────────┤
│ │
│ 可视区域(viewport) │ ← 只渲染这些元素
│ │
├─────────────────────────┤
│ 不可见区域(下方) │ ← 不渲染,用 padding/transform 占位
└─────────────────────────┘

三个关键值:

  1. scrollTop:当前滚动位置
  2. startIndex:可视区域第一个元素的索引
  3. endIndex:可视区域最后一个元素的索引

1.2 数学关系(定高情况)#

// 假设每项高度为 itemHeight,容器高度为 containerHeight
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
const visibleCount = endIndex - startIndex;
// 上方占位高度
const offsetTop = startIndex * itemHeight;
// 总列表高度
const totalHeight = totalCount * itemHeight;

二、定高虚拟列表:最简实现#

先从最简单的场景开始——每个列表项高度固定。

2.1 HTML 结构#

<div id="virtual-list" style="height: 600px; overflow: auto;">
<div id="phantom" style="position: relative;">
<!-- 实际渲染的列表项 -->
</div>
</div>

2.2 完整实现#

class FixedHeightVirtualList {
constructor(container, options) {
this.container = container;
this.itemHeight = options.itemHeight;
this.renderItem = options.renderItem;
this.data = options.data || [];
this.overscan = options.overscan || 5; // 上下各多渲染几个,减少白屏
// 创建 DOM 结构
this.phantom = document.createElement('div');
this.phantom.style.position = 'relative';
this.container.appendChild(this.phantom);
this.contentWrapper = document.createElement('div');
this.contentWrapper.style.position = 'absolute';
this.contentWrapper.style.top = '0';
this.contentWrapper.style.left = '0';
this.contentWrapper.style.width = '100%';
this.phantom.appendChild(this.contentWrapper);
// 状态
this.startIndex = 0;
this.endIndex = 0;
this.visibleItems = [];
// 绑定事件
this._onScroll = this._onScroll.bind(this);
this.container.addEventListener('scroll', this._onScroll, { passive: true });
this._update();
}
setData(data) {
this.data = data;
this._update();
}
_onScroll() {
this._update();
}
_update() {
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
const totalCount = this.data.length;
// 计算可见范围
let startIndex = Math.floor(scrollTop / this.itemHeight) - this.overscan;
let endIndex =
Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.overscan;
startIndex = Math.max(0, startIndex);
endIndex = Math.min(totalCount, endIndex);
// 如果范围没变,跳过渲染
if (startIndex === this.startIndex && endIndex === this.endIndex) return;
this.startIndex = startIndex;
this.endIndex = endIndex;
// 设置总高度(撑开滚动条)
const totalHeight = totalCount * this.itemHeight;
this.phantom.style.height = totalHeight + 'px';
// 设置内容偏移
const offsetTop = startIndex * this.itemHeight;
this.contentWrapper.style.transform = `translateY(${offsetTop}px)`;
// 渲染可见项
this._renderItems();
}
_renderItems() {
// 清空旧内容
this.contentWrapper.innerHTML = '';
const fragment = document.createDocumentFragment();
for (let i = this.startIndex; i < this.endIndex; i++) {
const item = this.renderItem(this.data[i], i);
item.style.height = this.itemHeight + 'px';
item.style.boxSizing = 'border-box';
fragment.appendChild(item);
}
this.contentWrapper.appendChild(fragment);
}
destroy() {
this.container.removeEventListener('scroll', this._onScroll);
this.phantom.remove();
}
}
// 使用
const container = document.getElementById('virtual-list');
const data = Array.from({ length: 100000 }, (_, i) => ({
id: i,
text: `Item #${i}${Math.random().toString(36).slice(2)}`,
}));
const list = new FixedHeightVirtualList(container, {
itemHeight: 50,
overscan: 5,
data,
renderItem(item, index) {
const div = document.createElement('div');
div.className = 'list-item';
div.textContent = item.text;
div.style.borderBottom = '1px solid #eee';
div.style.padding = '12px 16px';
div.style.lineHeight = '26px';
return div;
},
});

2.3 性能数据#

10 万条数据,定高虚拟列表 vs 直接渲染:
直接渲染:
- DOM 节点数:~300,000
- 首次渲染时间:4200ms
- 内存占用:~180MB
- 滚动帧率:8-15fps
虚拟列表:
- DOM 节点数:~40(视口内 + overscan)
- 首次渲染时间:12ms
- 内存占用:~15MB
- 滚动帧率:60fps

三、不定高虚拟列表:真正的挑战#

真实场景中,列表项的高度几乎不可能完全一致。评论列表、聊天消息、Feed 流……每项内容不同,高度自然不同。

3.1 核心难点#

  1. 总高度未知:没渲染就不知道每项的实际高度
  2. 无法用除法快速定位 startIndex:因为每项高度不同
  3. 滚动位置映射复杂:scrollTop → index 需要累加计算

3.2 解决思路:预估 + 动态修正#

class DynamicHeightVirtualList {
constructor(container, options) {
this.container = container;
this.renderItem = options.renderItem;
this.data = options.data || [];
this.estimatedHeight = options.estimatedHeight || 80; // 预估高度
this.overscan = options.overscan || 5;
// 缓存每项的实际高度和位置
// positions[i] = { top, bottom, height }
this.positions = [];
this._initPositions();
// DOM 结构
this.phantom = document.createElement('div');
this.phantom.style.position = 'relative';
this.container.appendChild(this.phantom);
this.contentWrapper = document.createElement('div');
this.contentWrapper.style.position = 'absolute';
this.contentWrapper.style.top = '0';
this.contentWrapper.style.left = '0';
this.contentWrapper.style.width = '100%';
this.phantom.appendChild(this.contentWrapper);
// 状态
this.startIndex = 0;
this.endIndex = 0;
// 绑定事件
this._onScroll = this._onScroll.bind(this);
this.container.addEventListener('scroll', this._onScroll, { passive: true });
this._update();
}
_initPositions() {
this.positions = this.data.map((_, index) => ({
index,
height: this.estimatedHeight,
top: index * this.estimatedHeight,
bottom: (index + 1) * this.estimatedHeight,
}));
}
// 二分搜索:根据 scrollTop 找到 startIndex
_binarySearch(scrollTop) {
let low = 0;
let high = this.positions.length - 1;
let result = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midBottom = this.positions[mid].bottom;
if (midBottom === scrollTop) {
return mid + 1;
} else if (midBottom < scrollTop) {
low = mid + 1;
} else {
// midBottom > scrollTop
result = mid;
high = mid - 1;
}
}
return result;
}
// 渲染后更新实际高度
_updatePositions() {
const items = this.contentWrapper.children;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const index = this.startIndex + i;
if (index >= this.positions.length) break;
const rect = item.getBoundingClientRect();
const actualHeight = rect.height;
const oldHeight = this.positions[index].height;
// 只在高度变化时更新
if (Math.abs(actualHeight - oldHeight) > 0.5) {
const diff = actualHeight - oldHeight;
this.positions[index].height = actualHeight;
this.positions[index].bottom += diff;
// 更新后续所有项的位置
for (let j = index + 1; j < this.positions.length; j++) {
this.positions[j].top = this.positions[j - 1].bottom;
this.positions[j].bottom = this.positions[j].top + this.positions[j].height;
}
}
}
}
_getTotalHeight() {
const last = this.positions[this.positions.length - 1];
return last ? last.bottom : 0;
}
_onScroll() {
this._update();
}
_update() {
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
// 二分查找 startIndex
let startIndex = this._binarySearch(scrollTop) - this.overscan;
startIndex = Math.max(0, startIndex);
// 从 startIndex 开始,累加高度找到 endIndex
let endIndex = startIndex;
let accHeight = this.positions[startIndex]
? this.positions[startIndex].top - scrollTop
: 0;
while (endIndex < this.data.length && accHeight < containerHeight) {
accHeight += this.positions[endIndex].height;
endIndex++;
}
endIndex = Math.min(this.data.length, endIndex + this.overscan);
this.startIndex = startIndex;
this.endIndex = endIndex;
// 更新 phantom 总高度
this.phantom.style.height = this._getTotalHeight() + 'px';
// 偏移
const offsetTop = this.positions[startIndex]
? this.positions[startIndex].top
: 0;
this.contentWrapper.style.transform = `translateY(${offsetTop}px)`;
// 渲染
this._renderItems();
// 渲染后测量实际高度并更新
requestAnimationFrame(() => {
this._updatePositions();
// 重新更新 phantom 高度(可能因为实际高度和预估不同而变化)
this.phantom.style.height = this._getTotalHeight() + 'px';
});
}
_renderItems() {
this.contentWrapper.innerHTML = '';
const fragment = document.createDocumentFragment();
for (let i = this.startIndex; i < this.endIndex; i++) {
const item = this.renderItem(this.data[i], i);
item.dataset.index = i;
fragment.appendChild(item);
}
this.contentWrapper.appendChild(fragment);
}
// 滚动到指定索引
scrollToIndex(index) {
if (index >= 0 && index < this.positions.length) {
this.container.scrollTop = this.positions[index].top;
}
}
destroy() {
this.container.removeEventListener('scroll', this._onScroll);
this.phantom.remove();
}
}

3.3 优化:减少 _updatePositions 的开销#

上面的实现在更新位置时,需要遍历后续所有项。对于 10 万条数据,这个 O(n) 的操作会很慢。可以用前缀和数组线段树来优化。

// 使用前缀和数组优化位置查询
class PrefixSumPositionCache {
constructor(estimatedHeight, count) {
this.heights = new Float64Array(count).fill(estimatedHeight);
this.prefixSum = new Float64Array(count + 1);
this._rebuildPrefixSum();
}
_rebuildPrefixSum() {
this.prefixSum[0] = 0;
for (let i = 0; i < this.heights.length; i++) {
this.prefixSum[i + 1] = this.prefixSum[i] + this.heights[i];
}
}
// 更新某项高度 —— O(n),但只在高度变化时调用
updateHeight(index, newHeight) {
const oldHeight = this.heights[index];
if (Math.abs(newHeight - oldHeight) < 0.5) return false;
const diff = newHeight - oldHeight;
this.heights[index] = newHeight;
// 只更新 index 之后的前缀和
for (let i = index + 1; i <= this.heights.length; i++) {
this.prefixSum[i] += diff;
}
return true;
}
// 获取某项的 top 位置 —— O(1)
getTop(index) {
return this.prefixSum[index];
}
// 获取某项的 bottom 位置 —— O(1)
getBottom(index) {
return this.prefixSum[index + 1];
}
// 总高度 —— O(1)
getTotalHeight() {
return this.prefixSum[this.heights.length];
}
// 二分查找 scrollTop 对应的 index —— O(log n)
findIndex(scrollTop) {
let low = 0;
let high = this.heights.length - 1;
let result = 0;
while (low <= high) {
const mid = (low + high) >>> 1;
if (this.prefixSum[mid + 1] <= scrollTop) {
low = mid + 1;
} else {
result = mid;
high = mid - 1;
}
}
return result;
}
}

四、动态测量:ResizeObserver 方案#

上面的方案在 requestAnimationFrame 中手动测量高度,存在一个问题:如果列表项内部有异步内容(图片加载、文本展开),高度变化后不会自动更新。

更好的方案是使用 ResizeObserver

class ResizeObserverVirtualList extends DynamicHeightVirtualList {
constructor(container, options) {
super(container, options);
// 使用 ResizeObserver 监听每个渲染项的尺寸变化
this.resizeObserver = new ResizeObserver((entries) => {
let needsUpdate = false;
for (const entry of entries) {
const index = parseInt(entry.target.dataset.index, 10);
if (isNaN(index)) continue;
const newHeight = entry.contentBoxSize?.[0]?.blockSize
?? entry.contentRect.height;
if (this.positions[index]) {
const oldHeight = this.positions[index].height;
if (Math.abs(newHeight - oldHeight) > 0.5) {
this.positions[index].height = newHeight;
needsUpdate = true;
}
}
}
if (needsUpdate) {
this._recalculatePositions();
this.phantom.style.height = this._getTotalHeight() + 'px';
}
});
}
_recalculatePositions() {
// 从头重新计算所有位置
for (let i = 1; i < this.positions.length; i++) {
this.positions[i].top = this.positions[i - 1].bottom;
this.positions[i].bottom =
this.positions[i].top + this.positions[i].height;
}
}
_renderItems() {
// 先解除对旧元素的观察
const oldItems = this.contentWrapper.children;
for (const item of oldItems) {
this.resizeObserver.unobserve(item);
}
// 渲染新元素
super._renderItems();
// 观察新元素
const newItems = this.contentWrapper.children;
for (const item of newItems) {
this.resizeObserver.observe(item);
}
}
destroy() {
this.resizeObserver.disconnect();
super.destroy();
}
}

五、滚动锚定(Scroll Anchoring)#

不定高虚拟列表有一个常见问题:当上方元素的实际高度与预估高度不同时,滚动位置会发生跳动。

5.1 问题复现#

场景:
1. 用户滚动到第 100 项
2. 此时第 1-50 项的图片加载完成,实际高度比预估高了 200px
3. 整个列表的总高度增加了 200px
4. 用户看到的内容突然"跳"了一下

5.2 滚动锚定实现#

class AnchoredVirtualList extends ResizeObserverVirtualList {
constructor(container, options) {
super(container, options);
this._lastScrollTop = 0;
this._anchorIndex = 0;
this._anchorOffset = 0;
}
_onScroll() {
const scrollTop = this.container.scrollTop;
// 记录锚点:当前视口顶部对应的元素和偏移
const anchorIndex = this._binarySearch(scrollTop);
const anchorTop = this.positions[anchorIndex]?.top || 0;
this._anchorIndex = anchorIndex;
this._anchorOffset = scrollTop - anchorTop;
this._lastScrollTop = scrollTop;
this._update();
}
// 当上方元素高度变化时,修正滚动位置
_recalculatePositions() {
const oldAnchorTop = this.positions[this._anchorIndex]?.top || 0;
super._recalculatePositions();
const newAnchorTop = this.positions[this._anchorIndex]?.top || 0;
const diff = newAnchorTop - oldAnchorTop;
if (Math.abs(diff) > 1) {
// 修正滚动位置,保持锚点元素在视口中的相对位置不变
this.container.scrollTop += diff;
this._lastScrollTop = this.container.scrollTop;
}
}
}

5.3 CSS 原生滚动锚定#

现代浏览器也内置了滚动锚定机制:

/* 启用浏览器原生滚动锚定(默认开启) */
.scroll-container {
overflow-anchor: auto;
}
/* 某些元素不作为锚点 */
.ad-banner, .loading-spinner {
overflow-anchor: none;
}

但对于虚拟列表,原生滚动锚定往往不够用(因为元素是动态创建/销毁的),需要手动实现。

六、无限滚动(Infinite Scroll)#

虚拟列表通常需要配合无限滚动来实现”滚到底部自动加载更多”的效果。

6.1 基于 IntersectionObserver 的实现#

class InfiniteVirtualList extends AnchoredVirtualList {
constructor(container, options) {
super(container, options);
this.loadMore = options.loadMore; // 加载更多数据的回调
this.isLoading = false;
this.hasMore = true;
this.loadThreshold = options.loadThreshold || 5; // 距离底部多少项时触发
// 创建哨兵元素
this.sentinel = document.createElement('div');
this.sentinel.style.height = '1px';
this.sentinel.className = 'infinite-scroll-sentinel';
this.container.appendChild(this.sentinel);
// 使用 IntersectionObserver 监听哨兵
this.intersectionObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !this.isLoading && this.hasMore) {
this._loadMore();
}
},
{
root: this.container,
rootMargin: '0px 0px 200px 0px', // 提前 200px 触发
}
);
this.intersectionObserver.observe(this.sentinel);
}
async _loadMore() {
this.isLoading = true;
this._showLoading();
try {
const newItems = await this.loadMore(this.data.length);
if (!newItems || newItems.length === 0) {
this.hasMore = false;
this._showEndMessage();
return;
}
// 追加数据
this.data = [...this.data, ...newItems];
// 初始化新项的位置
const lastPos = this.positions[this.positions.length - 1];
const lastBottom = lastPos ? lastPos.bottom : 0;
for (let i = 0; i < newItems.length; i++) {
const index = this.positions.length;
this.positions.push({
index,
height: this.estimatedHeight,
top: lastBottom + i * this.estimatedHeight,
bottom: lastBottom + (i + 1) * this.estimatedHeight,
});
}
this._update();
} catch (error) {
console.error('加载失败:', error);
this._showError();
} finally {
this.isLoading = false;
this._hideLoading();
}
}
_showLoading() {
this.sentinel.textContent = '加载中...';
this.sentinel.style.height = '40px';
this.sentinel.style.textAlign = 'center';
this.sentinel.style.lineHeight = '40px';
}
_hideLoading() {
this.sentinel.textContent = '';
this.sentinel.style.height = '1px';
}
_showEndMessage() {
this.sentinel.textContent = '没有更多了';
this.sentinel.style.height = '40px';
this.sentinel.style.textAlign = 'center';
this.sentinel.style.lineHeight = '40px';
this.sentinel.style.color = '#999';
this.intersectionObserver.disconnect();
}
_showError() {
this.sentinel.innerHTML = '加载失败,<a href="#">点击重试</a>';
this.sentinel.querySelector('a')?.addEventListener('click', (e) => {
e.preventDefault();
this._loadMore();
});
}
destroy() {
this.intersectionObserver.disconnect();
this.sentinel.remove();
super.destroy();
}
}
// 使用
const list = new InfiniteVirtualList(container, {
estimatedHeight: 80,
overscan: 5,
data: initialData,
renderItem(item, index) {
const div = document.createElement('div');
div.className = 'feed-item';
div.innerHTML = `
<h3>${item.title}</h3>
<p>${item.content}</p>
<span class="meta">${item.author} · ${item.time}</span>
`;
return div;
},
async loadMore(offset) {
const response = await fetch(`/api/feed?offset=${offset}&limit=20`);
const data = await response.json();
return data.items;
},
});

七、滚动性能优化技巧#

7.1 使用 passive 事件监听#

// ✅ passive 告诉浏览器不会调用 preventDefault()
// 浏览器可以立即开始滚动,不用等 JS 执行完
container.addEventListener('scroll', handler, { passive: true });

7.2 使用 will-change 和 contain#

.virtual-list-container {
overflow: auto;
contain: strict;
will-change: scroll-position;
}
.virtual-list-item {
contain: content;
}

7.3 使用 content-visibility#

/* 对于不在视口内但已渲染的 overscan 元素 */
.virtual-list-item {
content-visibility: auto;
contain-intrinsic-size: auto 80px;
}

7.4 减少 innerHTML 重绘#

// ❌ 每次更新都清空重建
_renderItems() {
this.contentWrapper.innerHTML = '';
// ... 重新创建所有元素
}
// ✅ DOM 复用(对象池模式)
class DOMPool {
constructor(createFn, recycleFn) {
this.pool = [];
this.createFn = createFn;
this.recycleFn = recycleFn;
}
acquire() {
return this.pool.pop() || this.createFn();
}
release(element) {
this.recycleFn(element);
this.pool.push(element);
}
}
// 使用 DOM 复用
const domPool = new DOMPool(
() => {
const div = document.createElement('div');
div.className = 'list-item';
return div;
},
(el) => {
el.textContent = '';
el.style.cssText = '';
}
);

7.5 使用 requestAnimationFrame 节流#

_onScroll() {
if (this._rafId) return;
this._rafId = requestAnimationFrame(() => {
this._update();
this._rafId = null;
});
}

八、键盘导航与无障碍#

虚拟列表容易忽视无障碍(Accessibility)。因为只有部分元素在 DOM 中,屏幕阅读器和键盘导航可能出问题。

class AccessibleVirtualList extends InfiniteVirtualList {
constructor(container, options) {
super(container, options);
// ARIA 属性
this.container.setAttribute('role', 'listbox');
this.container.setAttribute('aria-label', options.label || '列表');
this.container.setAttribute('tabindex', '0');
// 键盘导航
this._focusedIndex = -1;
this.container.addEventListener('keydown', this._onKeyDown.bind(this));
}
_onKeyDown(event) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this._moveFocus(1);
break;
case 'ArrowUp':
event.preventDefault();
this._moveFocus(-1);
break;
case 'Home':
event.preventDefault();
this._setFocus(0);
break;
case 'End':
event.preventDefault();
this._setFocus(this.data.length - 1);
break;
case 'Enter':
case ' ':
event.preventDefault();
this._selectItem(this._focusedIndex);
break;
}
}
_moveFocus(delta) {
const newIndex = Math.max(
0,
Math.min(this.data.length - 1, this._focusedIndex + delta)
);
this._setFocus(newIndex);
}
_setFocus(index) {
this._focusedIndex = index;
// 确保聚焦项可见
this.scrollToIndex(index);
// 更新 ARIA
this.container.setAttribute('aria-activedescendant', `item-${index}`);
// 更新视觉焦点
const items = this.contentWrapper.children;
for (const item of items) {
const itemIndex = parseInt(item.dataset.index, 10);
item.classList.toggle('focused', itemIndex === index);
item.setAttribute('aria-selected', itemIndex === index ? 'true' : 'false');
}
}
_renderItems() {
super._renderItems();
// 为每个渲染的项添加 ARIA 属性
const items = this.contentWrapper.children;
for (const item of items) {
const index = parseInt(item.dataset.index, 10);
item.setAttribute('role', 'option');
item.setAttribute('id', `item-${index}`);
item.setAttribute(
'aria-selected',
index === this._focusedIndex ? 'true' : 'false'
);
item.setAttribute('aria-setsize', this.data.length);
item.setAttribute('aria-posinset', index + 1);
}
}
}

九、测试数据与总结#

9.1 完整性能对比#

测试环境:Chrome 120, 中端 Android 手机
数据量:50,000 条,不定高(60-200px)
| 方案 | DOM 节点 | 首屏渲染 | 滚动帧率 | 内存 |
|------|---------|---------|---------|------|
| 直接渲染 | 150,000 | 8.5s | 5-10fps | 250MB |
| 定高虚拟列表 | 45 | 8ms | 60fps | 12MB |
| 不定高虚拟列表 | 50 | 15ms | 55-60fps | 15MB |
| 不定高+ResizeObserver | 50 | 16ms | 55-60fps | 16MB |
| 不定高+锚定+无限滚动 | 50 | 18ms | 52-58fps | 18MB |

9.2 什么时候需要虚拟列表?#

经验法则:

  • < 100 条:不需要,直接渲染
  • 100 - 1000 条:可能需要,取决于列表项复杂度
  • > 1000 条:几乎一定需要
  • > 10000 条:必须使用

9.3 生产级方案选择#

如果你不想从零造轮子,可以考虑以下库:

  • @tanstack/virtual:框架无关的虚拟滚动核心库,极度灵活
  • vue-virtual-scroller:Vue 生态的虚拟列表
  • Svelte Virtual List:Svelte 生态方案

但无论用什么库,理解底层原理是最重要的——当遇到库处理不了的边界情况时,你需要知道该在哪里动手。

虚拟列表看似简单,实则是前端工程的一个缩影:从简单的数学关系出发,逐步处理各种真实场景中的复杂问题——不定高度、动态内容、滚动锚定、无限加载、无障碍……每一步都是从”能用”走向”好用”的工程实践。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

高性能虚拟列表实现:从原理到生产级组件
https://boke.hackerdream.xyz/posts/virtual-list-implementation/
作者
晴天
发布于
2026-02-24
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录