鼓吹技术无用论的,只能说,阶段不同,经验不同,视角不同,技术对个体的意义就不同。
所以这篇文章还是回归技术本身: 聊聊 table 的卡顿如何解决。
本文记录的是我在工作中遇到的一个技术问题:
在一个后台中,大约有一个 30-40 列且最小分页条数最小只能设置为几百一千条的表格。
同时部分列,存在 popover, model, select, tooltip 等这样的非简单列的场景。
组件本身是用的 antd 的 table,在不做优化的情况下,大概会有 50 多秒的卡顿,会弹出当前页面无响应的提示。
用 chrome 的 performance 去录制,都会因为时长太久而无法显示性能结果。
通过不断的删除表格的列元素,基本可以断定,卡顿是由于一些非简单列的渲染导致的。
这里面比如有一列是 select 元素,一个 select 大概有上千个选项。
一个 button 元素,点击后会唤起 popover 这样的交互。
去除这些非简单列之后,渲染时间就回归了正常,达到了可以接受的程度。
所以问题基本就明确了:
在 antd 的 table 下,如果存在大量的非简单列同时分页必须是大分页的场景下,卡顿如何简单的解决?
作为多年的老司机,很容易就能想到几个方案:
- 虚拟列表
- 批量加载
- 懒渲染非简单列
- 非简单列修改交互
当然,也可以和产品沟通,减少分页数量。
虚拟列表
很容易想到的方案就是开启虚拟列表,antd 本身也提供了这样的能力。

开启之后,初始的渲染速度确实非常快,但是滚动却是肉眼可见的卡顿。
网友遇到的同款 issue 也是很多:

2024 年的 issue 了,也没解决,可能真是有难言之隐吧。
批量加载
因为是行数太多和非简单列的元素过多导致的渲染缓慢,很容易想到的就是减少行数。
给外部的数据加一个内部变量,定时器不断往内部变量塞数据就可以在前端模拟批量加载的效果,伪代码如下:
jsx
const MyTable = ({
data,
}) => {
const [batchData, setBatchData] = useState([])
const timerRef = useRef(null)
useEffect(() => {
// 清理上次的定时器
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = null
setBatchData([])
let batchSize = 20
let currentBatch = 0
const batch = () => {
let startIndex = currentBatch * batchSize
let endIndex = (currentBatch + 1) * batchSize
if (endIndex >= data.length) {
endIndex = data.length
}
setBatch((pre) => ([
...pre,
...data.slice(startIndex, endIndex)
]))
currentBatch++
setTimeout(() => {
batch()
}, 50)
}
batch()
}, [data])
}
上述是 setTimeout
的批量加载,也可以用 requestAnimationFrame
或者 requestIdleCallback
来完成类似的循环。
但是实际效果依赖 batchSize 和 定时的时间。
初始的速度快了,但是每次批量更新的时候,依旧会出现短暂的卡顿。
体感上从原来的一次卡顿,变成了多次卡顿,体验很差。
而延时和 batchSize 的选择,都和渲染时间有关,而渲染时间和机型有关,很难调整到一个满意的效果。
懒渲染非简单列
问题的根源是非简单列的渲染耗时,那其实可以参考懒加载图片的实现:
只有当进入视区的时候,才渲染这些列。
因为一般而言懒加载是指从远程惰性加载某些资源,而此处是惰性渲染某些组件,所以代称为懒渲染。
实现方案也很简单,大概的伪代码如下:
jsx
// 创建一个懒加载的单元格组件
const LazyColumn = ({
children,
fallback = <div style={{ height: '32px' }} />,
threshold = 0.1,
onClick = () => {},
}) => {
const [isVisible, setIsVisible] = useState(false)
const cellRef = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(entry.isIntersecting)
if (entry.isIntersecting) {
// 如果出现在视区则停止监听,之后会一直渲染 children
observer.unobserve(entry.target)
}
},
{
threshold,
// 扩展根边距,提前加载即将进入视区的内容
rootMargin: '50px'
}
)
if (cellRef.current) {
observer.observe(cellRef.current)
}
return () => observer.disconnect()
}, [threshold])
return (
<div ref={cellRef} style={{ minHeight: '32px' }} onClick={onClick}>
{isVisible ? children : fallback}
</div>
)
}
export default LazyColumn
初始不渲染 children, 只有当进入视区之后,才始终渲染 children。
通过这种方式,来实现一个懒渲染的效果。
而这种方式,整体效果比较好,不论是初始渲染时间,还是滚动流畅性,体感都还不错,可以说是性价比很高了。
非简单列修改交互
由于问题出在非简单列上,比如 select + 1000多个 option, 按钮加 popover 的组合。
其中,select 可以考虑增加一个编辑按钮,点击后才渲染 select。
对于 按钮 + popover 或 modal 的组合,可以把 popover 或者 modal 外置,统一只渲染一个。
通过这样的交互修改,可以大大减少非简单列需要渲染的元素,从而避免 table 的卡顿。
但是和产品的 battle 的难度又是另外一个层级了。
总结
本文分享了解决 antd table 在大分页和非简单列的场景下出现卡顿的解决方案。
最终懒渲染非简单列的方案以其较快的初始渲染速度和流畅的滚动体验胜出。
当然,类似的懒渲染的手段也适用于其他的卡顿的场景。
除此之外,合理的修改交互,和产品 battle 也是很好的解决方案。
最后,欢迎大家关注我,时不时的分享技术和生活。
在这个天天喊程序员要凉的时代,我也组建了一个抱团取暖群,欢迎加入,苟住就是胜利。