基于虚拟列表与可编辑 DIV的20W条ip数据渲染解决方案

背景与挑战

  1. 数据规模:需要在浏览器中渲染和操作 20 万行 IP 数据。
  2. 性能问题
    • 完整渲染全部数据导致内存占用和页面卡顿。
    • 编辑、搜索或滚动等交互时 DOM 更新过多,操作不流畅。
  3. 功能需求
    • 支持动态渲染可视区域内数据(虚拟列表)。
    • 行内编辑功能,支持即时更新(可编辑 DIV)。
    • 提供高效的搜索功能,支持定位和导航。

解决方案

1. 虚拟列表

通过仅渲染当前可视区域的行,显著降低 DOM 节点数量。

实现流程

  1. 计算当前滚动位置 (scrollTop) 和行高度 (ROW_HEIGHT) 确定可见起始索引。
  2. 根据起始索引动态生成可视区域的数据行。
  3. 为了避免滚动跳动,使用 position: absolute 对每行进行定位,确保数据在滚动容器中呈现正确位置。
  4. 滚动事件触发更新,动态调整渲染内容。

2. 可编辑 DIV

利用 contentEditable 属性实现即点即编辑,避免切换组件带来的 DOM 重新渲染。

实现流程

  1. 用户点击某一行时,将其内容标记为可编辑状态。
  2. 通过监听 onInputonBlur 事件,实时获取用户的编辑内容并更新数据。
  3. 高亮当前正在编辑的行以增强用户体验。

优势

  • 无需频繁切换到 <input><textarea>,更直观。
  • 支持富文本或格式化文本的扩展需求。

3. 搜索与导航功能

实现快速定位数据的功能,增强用户对大数据集的交互体验。

实现流程

  1. 用户输入关键词后,通过遍历数据过滤出所有匹配项,记录匹配行的索引。
  2. 当前匹配结果使用动态高亮颜色显示。
  3. 提供“上一个”和“下一个”按钮,通过索引循环定位到不同匹配项,同时滚动至对应位置。

技术难点

  • 处理滚动与虚拟渲染的联动,确保高亮行可见。
  • 实现上下导航的索引管理,支持环绕定位。

完整代码示例

完整代码实现请参考文末


性能优化亮点

  1. 大幅减少 DOM 节点数量:从渲染 20 万个节点优化为仅渲染 20 个(可见区域行数)。
  2. 减少重绘重排:利用绝对定位和动态更新,避免频繁的全量 DOM 操作。
  3. 快速搜索与定位:索引缓存与滚动联动实现高效的用户交互。

学习与改进方向

  1. 安全性增强:对 contentEditable 的输入内容进行 HTML 过滤,防止 XSS 攻击。
  2. 功能扩展
    • 支持多选和批量编辑。
    • 添加键盘快捷键,如搜索高亮导航中的 Ctrl + F
  3. 代码模块化:将虚拟列表、可编辑 DIV 和搜索功能抽象成独立组件,提高复用性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166

'use client';

import React, { useState, useRef, useCallback, useMemo } from 'react';

const ROW_HEIGHT = 30; // 每行的固定高度
const VISIBLE_COUNT = 20; // 可视区域内的行数

function Page() {
// 生成 20 万条 IP 数据
const data = useMemo(
() =>
Array.from({ length: 200000 }, () =>
Array.from({ length: 4 }, () => Math.floor(Math.random() * 256)).join('.')
),
[]
);

const [startIndex, setStartIndex] = useState(0); // 当前起始索引
const [editingIndex, setEditingIndex] = useState<number | null>(null); // 正在编辑的行索引
const [inputValue, setInputValue] = useState(''); // 当前编辑中的值
const [searchTerm, setSearchTerm] = useState(''); // 搜索关键字
const [searchResults, setSearchResults] = useState<number[]>([]); // 搜索结果索引列表
const [currentSearchIndex, setCurrentSearchIndex] = useState(0); // 当前高亮的搜索结果索引

const containerRef = useRef<HTMLDivElement>(null);

// 滚动事件处理,计算起始索引
const handleScroll = useCallback(() => {
if (containerRef.current) {
const scrollTop = containerRef.current.scrollTop;
const newIndex = Math.floor(scrollTop / ROW_HEIGHT);
setStartIndex(newIndex);
}
}, []);

// 搜索逻辑
const handleSearch = () => {
const results = data
.map((item, index) => (item.includes(searchTerm) ? index : -1))
.filter((index) => index !== -1);
setSearchResults(results);
setCurrentSearchIndex(0);
if (results.length > 0) scrollToIndex(results[0]);
};

// 滚动到指定行
const scrollToIndex = (index: number) => {
if (containerRef.current) {
containerRef.current.scrollTop = index * ROW_HEIGHT;
setStartIndex(Math.floor(containerRef.current.scrollTop / ROW_HEIGHT));
}
};

// 搜索结果的上下导航
const handleNavigateSearch = (direction: 'prev' | 'next') => {
if (searchResults.length === 0) return;
const newIndex =
direction === 'next'
? (currentSearchIndex + 1) % searchResults.length
: (currentSearchIndex - 1 + searchResults.length) % searchResults.length;
setCurrentSearchIndex(newIndex);
scrollToIndex(searchResults[newIndex]);
};

// 保存编辑后的值
const saveEdit = useCallback(
(index: number) => {
if (editingIndex !== null) {
data[editingIndex] = inputValue;
setEditingIndex(null);
setInputValue('');
}
},
[editingIndex, inputValue, data]
);

// 渲染单行
const renderRow = (index: number) => {
const isEditing = index === editingIndex;
const isSearchResult = searchResults.includes(index);
const isCurrentResult = index === searchResults[currentSearchIndex];

return (
<div
key={index}
style={{
height: ROW_HEIGHT,
display: 'flex',
alignItems: 'center',
padding: '0 10px',
borderBottom: '1px solid #ddd',
backgroundColor: isCurrentResult
? '#ffd700'
: isSearchResult
? '#f0f8ff'
: 'white',
position: 'absolute',
top: index * ROW_HEIGHT,
width: '100%',
}}
onClick={() => {
setEditingIndex(index);
setInputValue(data[index]);
}}
>
{isEditing ? (
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onBlur={() => saveEdit(index)}
style={{ width: '100%', height: '100%', border: 'none', outline: 'none' }}
autoFocus
/>
) : (
<span>{data[index]}</span>
)}
</div>
);
};

return (
<div>
<div style={{ marginBottom: 10 }}>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索..."
style={{ marginRight: 10, padding: '5px', width: '200px' }}
/>
<button onClick={handleSearch} style={{ marginRight: 10 }}>
搜索
</button>
<button onClick={() => handleNavigateSearch('prev')} style={{ marginRight: 10 }}>
上一个
</button>
<button onClick={() => handleNavigateSearch('next')}>下一个</button>
</div>
<div
ref={containerRef}
style={{
width: '100%',
height: `${ROW_HEIGHT * VISIBLE_COUNT}px`,
overflowY: 'auto',
position: 'relative',
border: '1px solid #ddd',
}}
onScroll={handleScroll}
>
{/* 虚拟容器,用于撑开滚动区域 */}
<div style={{ height: `${data.length * ROW_HEIGHT}px`, position: 'relative' }}>
{/* 渲染可见区域的行 */}
{Array.from({ length: VISIBLE_COUNT + 1 }).map((_, i) => {
const index = startIndex + i;
if (index >= data.length) return null;
return renderRow(index);
})}
</div>
</div>
</div>
);
}

export default Page;