前言
大家好,我是风骨,在工作中我们会遇到在一个滚动容器中渲染一组数据列表的需求。
如果数据比较少,一次性全部渲染到列表中到是没什么问题,但对于较长的列表,比如现在有 1000 条数据,如果同时将这 1000 个数据渲染在页面上,就会生成 1000 个 DOM 容器。这样容易导致浏览器在渲染阶段占用主线程时间过长,用户在操作页面时出现卡顿。
针对此问题,我们可以在数据渲染时做优化:对于用户而言,并不关心你是否是一次性将数据全部渲染到列表容器中,只要在容器的可视区域内的 DOM 数据能够正常看到就行。因此我们优化思路是:结合滚动条位置和容器的可视区域,将对应的数据项渲染到滚动容器中。
在 npm.js 上开源的组件库:react-tiny-virtual-list 提供了虚拟滚动列表的功能实现。本文也将基于 react-tiny-virtual-list 的内部实现,使用 React Hooks 结合 TS 来编写一个虚拟滚动组件,实现代码 140 行左右。
1、测试用例
import { VirtualList } from "../src/index";
const rowStyle = { padding: "0 10px", borderBottom: "1px solid grey", lineHeight: "50px" };
function App() {
const renderItem = ({ style, index }: { style: any; index: number }) => {
return (
<div style={{ ...rowStyle, ...style }} key={index}>
Row #{index}
</div>
);
};
return (
<VirtualList
width="auto"
height={400}
itemCount={1000}
renderItem={renderItem}
itemSize={50}
className="VirtualList"
/>
);
}
// 样式
.VirtualList {
margin: 20px;
background: #FFF;
border-radius: 2px;
box-shadow:
0 2px 2px 0 rgba(0,0,0,.14),
0 3px 1px -2px rgba(0,0,0,.2),
0 1px 5px 0 rgba(0,0,0,.12);
}
通过虚拟滚动列表,渲染后的 DOM 结构如下:
2、代码实现
2.1、Props 定义
在熟悉和使用一个组件前,相信大家首要工作就是了解该组件暴露给用户有哪些属性,基于 TS 开发的组件,在阅读层面也变得更加容易。
export interface IProps {
width: number | string; // list宽度
height: number | string; // list高度
itemCount: number; // item 个数
itemSize: number; // item 固定的高度/宽度(取决于滚动方向)
renderItem(itemInfo: { index: number, style: ItemStyle }): React.ReactNode; // item 渲染节点
className?: string;
style?: React.CSSProperties;
scrollDirection?: DIRECTION; // 列表应该垂直还是水平滚动。“垂直”(默认)或“水平”之一
overscanCount?: number; // 上方/下方渲染的额外缓冲区项数
scrollOffset?: number; // 可用于设置初始化滚动偏移量
onScroll?(offset: number, event: Event): void;
}
从 Props 定义上,我们可以看到有 5 个必需属性:
width:滚动列表的宽度;height:滚动列表的高度,如上面测试用例,是一个高为 400px 的滚动列表;itemCount:滚动列表中数据的个数,如:1000 条数据;itemSize:单个项目的尺寸,默认是垂直滚动,itemSize 就代表每条数据要渲染的高度;renderItem:每条数据渲染的 DOM 节点。
2.2、思路分析
根据上面的几个必需属性中,就可以完成虚拟滚动列表的实现。以垂直滚动为例:
-
首先需要一个 div 作为滚动容器,Props 中的
width、height将作用于该元素,并且在这个容器上定义overflow: auto使其垂直方向可以滚动; -
接着需要一个 div 作为滚动容器的 child,这个 div 的
height会根据 Props 中的itemCount、itemSize相乘计算得出,从而得到一个很长很长的长列表,让滚动容器能够滚动; -
有了长列表,那 item 的位置如何设置呢?注意,并不是所有的 item 都要同时渲染在长列表内,只有可视区域内的 item 需要渲染,因此布局方式采用 脱离文档流(定位) 方式来实现。通过
position: absolute控制top来确定 item 的位置; -
如何控制可视区域显示的 item 个数呢?容器的尺寸(height)和 item 的尺寸(itemSize)相除,就可以计算出容器的可视区域能显示多少个 item。
2.3、DOM 结构
const sizeProp = {
[DIRECTION.VERTICAL]: "height",
[DIRECTION.HORIZONTAL]: "width",
};
const positionProp = {
[DIRECTION.VERTICAL]: "top",
[DIRECTION.HORIZONTAL]: "left",
};
const STYLE_WRAPPER: React.CSSProperties = {
overflow: "auto",
willChange: "transform",
WebkitOverflowScrolling: "touch",
};
const STYLE_INNER: React.CSSProperties = {
position: "relative",
minWidth: "100%",
minHeight: "100%",
};
const VirtualList: React.FC<IProps> = props => {
const {
width,
height,
itemCount,
itemSize,
renderItem,
style = {},
onScroll,
scrollOffset,
overscanCount = 3,
scrollDirection = DIRECTION.VERTICAL,
...otherProps
} = props;
// ...
const wrapperStyle = { ...STYLE_WRAPPER, ...style, height, width };
const innerStyle = {
...STYLE_INNER,
[sizeProp[scrollDirection]]: itemCount * itemSize, // 计算列表整体高度/宽度(取决于垂直或水平)
};
return (
<div ref={rootNode} {...otherProps} style={wrapperStyle}>
<div style={innerStyle}>{items}</div>
</div>
);
};
从上面代码中可以看到,DOM 结构已经满足 实现思路 中的前两步。
2.4、可视范围(区间)
有了容器之后,我们就可以计算出容器在可视区域下能容纳的 item 范围:
const getVisibleRange = () => {
// 获取可视范围
const { clientHeight = 0, clientWidth = 0 } = (rootNode.current as HTMLDivElement) || {};
const containerSize: number = scrollDirection === DIRECTION.VERTICAL ? clientHeight : clientWidth;
let start = Math.floor(offset / itemSize - 1); // start --> 向下取整 (索引是从0开始,所以 - 1)
let stop = Math.ceil((offset + containerSize) / itemSize - 1); // stop --> 向上取整
return {
start: Math.max(0, start - overscanCount),
stop: Math.min(stop + overscanCount, itemCount - 1),
};
};
const { start, stop } = getVisibleRange();
以垂直滚动为例:
- rootNode 是通过 ref 绑定的滚动容器 DOM,通过它我们可以获取到容器的高度
clientHeight; - offset 是组件的内部状态,记录当前滚动条的位置,通过
offset / itemSize就可以计算出当前滚动环境下,容器可视区域的第一个节点位于数据列表中的索引,即得到start; offset + containerSize可以计算得出容器可视区域下最后一个节点位于数据列表中的索引,即得到stop;overscanCount是在可见项上方/下方渲染的额外缓冲区项目数。可以帮助减少浏览器滚动过程中出现的间断或闪烁。
到这里相信大家会发现一个问题:Hooks 函数组件在执行时调用了 getVisibleRange 方法,此时 DOM 还没有真正渲染到页面中,此时通过 rootNode.current 访问容器节点,肯定是拿不到容器高度的。
有一个简便的方式是:因为组件 Props 上传递了 height,也就是容器的高度,直接拿来用就行了,也省去了执行获取操作。
但如果用户传递的不是一个 number 类型的 height 呢?他就是想传一个 string,比如:height="80%",那这样就无法通过 height 来计算可视范围了。
既然函数组件执行时不能获取到 DOM 信息,那等它挂载后触发一次组件更新来获取 DOM 信息。这将通过一次更新来换取功能实现,但不用担心,很多场景下都是需要通过一次可以忽略不计的更新来换取功能实现。
经过上面分析后,我们可以在 useEffect 中去完成它,当然还需要借助与一个 state:
const VirtualList: React.FC<IProps> = props => {
const [isMount, setMount] = useState<boolean>(false);
useEffect(() => {
if (!isMount) setMount(true); // 强制更新一次,供 getVisibleRange 方法获取 DOM 挂载后 containerSize
}, []);
// ...
};
2.5、item 渲染
有了可视范围,我们就可以计算 top 将该范围内的 item 渲染到滚动容器中:
const VirtualList: React.FC<IProps> = props => {
// ...
const getStyle = (index: number) => {
const style = styleCache[index];
if (style) return style;
return (styleCache[index] = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
[sizeProp[scrollDirection]]: props.itemSize, // height / width
[positionProp[scrollDirection]]: props.itemSize * index, // top / left
});
};
// ...
const items: React.ReactNode[] = [];
const { start, stop } = getVisibleRange();
for (let index = start; index <= stop; index++) {
items.push(renderItem({ index, style: getStyle(index) }));
}
return (
<div ref={rootNode} {...otherProps} style={wrapperStyle}>
<div style={innerStyle}>{items}</div>
</div>
);
};
2.6、滚动 onScroll
剩下最后一个步骤,就是容器的监听滚动事件,通过获取滚动的距离 offsetTop 更新到 offset(组件内部存储滚动距离的 state),进而触发组件的重新渲染来更新容器可视区域内的 item 节点。
const VirtualList: React.FC<IProps> = props => {
const {
width,
height,
itemCount,
itemSize,
renderItem,
style = {},
onScroll,
scrollOffset,
overscanCount = 3,
scrollDirection = DIRECTION.VERTICAL,
...otherProps
} = props;
const [offset, setOffset] = useState<number>(scrollOffset || 0);
useEffect(() => {
if (!isMount) setMount(true); // 强制更新一次,供 getVisibleRange 方法获取 DOM 挂载后 containerSize
rootNode.current?.addEventListener("scroll", handleScroll);
return () => {
rootNode.current?.removeEventListener("scroll", handleScroll);
};
}, []);
const handleScroll = (event: Event) => {
const { scrollTop = 0, scrollLeft = 0 } = rootNode.current as HTMLDivElement;
const newOffset = scrollDirection === DIRECTION.VERTICAL ? scrollTop : scrollLeft;
if (newOffset < 0 || newOffset === offset || event.target !== rootNode.current) {
return;
}
setOffset(newOffset);
if (typeof onScroll === "function") {
onScroll(offset, event);
}
};
// ...
};
完整代码
import React, { useRef, useState, useEffect } from "react";
export enum DIRECTION {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
}
export interface ItemStyle {
position: "absolute";
top?: number;
left: number;
width: string | number;
height?: number;
marginTop?: number;
marginLeft?: number;
marginRight?: number;
marginBottom?: number;
zIndex?: number;
}
export interface IProps {
width: number | string; // list宽度
height: number | string; // list高度
itemCount: number; // item 个数
itemSize: number; // item 固定的高度/宽度(取决于滚动方向)
renderItem(itemInfo: { index: number; style: ItemStyle }): React.ReactNode; // item 渲染节点
className?: string;
style?: React.CSSProperties;
scrollDirection?: DIRECTION; // 列表应该垂直还是水平滚动。“垂直”(默认)或“水平”之一
overscanCount?: number; // 上方/下方渲染的额外缓冲区项数
scrollOffset?: number; // 可用于设置初始化滚动偏移量
onScroll?(offset: number, event: Event): void;
}
const STYLE_WRAPPER: React.CSSProperties = {
overflow: "auto",
willChange: "transform",
WebkitOverflowScrolling: "touch",
};
const STYLE_INNER: React.CSSProperties = {
position: "relative",
minWidth: "100%",
minHeight: "100%",
};
const sizeProp = {
[DIRECTION.VERTICAL]: "height",
[DIRECTION.HORIZONTAL]: "width",
};
const positionProp = {
[DIRECTION.VERTICAL]: "top",
[DIRECTION.HORIZONTAL]: "left",
};
const VirtualList: React.FC<IProps> = props => {
const {
width,
height,
itemCount,
itemSize,
renderItem,
style = {},
onScroll,
scrollOffset,
overscanCount = 3,
scrollDirection = DIRECTION.VERTICAL,
...otherProps
} = props;
const rootNode = useRef<HTMLDivElement | null>(null);
const [offset, setOffset] = useState<number>(scrollOffset || 0);
const [styleCache] = useState<{ [id: number]: ItemStyle }>({});
const [isMount, setMount] = useState<boolean>(false);
useEffect(() => {
if (!isMount) setMount(true); // 强制更新一次,供 getVisibleRange 方法获取 DOM 挂载后 containerSize
if (scrollOffset) scrollTo(scrollOffset);
rootNode.current?.addEventListener("scroll", handleScroll);
return () => {
rootNode.current?.removeEventListener("scroll", handleScroll);
};
}, []);
const handleScroll = (event: Event) => {
const { scrollTop = 0, scrollLeft = 0 } = rootNode.current as HTMLDivElement;
const newOffset = scrollDirection === DIRECTION.VERTICAL ? scrollTop : scrollLeft;
if (newOffset < 0 || newOffset === offset || event.target !== rootNode.current) {
return;
}
setOffset(newOffset);
if (typeof onScroll === "function") {
onScroll(offset, event);
}
};
const scrollTo = (value: number) => {
if (scrollDirection === DIRECTION.VERTICAL) {
rootNode.current!.scrollTop = value;
} else {
rootNode.current!.scrollLeft = value;
}
};
const getVisibleRange = () => {
// 获取可视范围
const { clientHeight = 0, clientWidth = 0 } = (rootNode.current as HTMLDivElement) || {};
const containerSize: number =
scrollDirection === DIRECTION.VERTICAL ? clientHeight : clientWidth;
let start = Math.floor(offset / itemSize - 1); // start --> 向下取整 (索引是从0开始,所以 - 1)
let stop = Math.ceil((offset + containerSize) / itemSize - 1); // stop --> 向上取整
return {
start: Math.max(0, start - overscanCount),
stop: Math.min(stop + overscanCount, itemCount - 1),
};
};
const getStyle = (index: number) => {
const style = styleCache[index];
if (style) return style;
return (styleCache[index] = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
[sizeProp[scrollDirection]]: props.itemSize, // height / width
[positionProp[scrollDirection]]: props.itemSize * index, // top / left
});
};
const wrapperStyle = { ...STYLE_WRAPPER, ...style, height, width };
const innerStyle = {
...STYLE_INNER,
[sizeProp[scrollDirection]]: itemCount * itemSize, // 计算列表整体高度/宽度(取决于垂直或水平)
};
const items: React.ReactNode[] = [];
const { start, stop } = getVisibleRange();
for (let index = start; index <= stop; index++) {
items.push(renderItem({ index, style: getStyle(index) }));
}
return (
<div ref={rootNode} {...otherProps} style={wrapperStyle}>
<div style={innerStyle}>{items}</div>
</div>
);
};
export default VirtualList;
感谢阅读。