从零开始创建一个 React 虚拟化/窗口组件
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
不久前,我所在的开发团队正在开发一款SaaS应用,该应用需要渲染大量数据(该模块的目的是模拟社交媒体)。由于每个项目本身都是相当庞大的React组件,因此我们必须使用渲染优化技术来提升UI性能,而虚拟化是业内最常用的技术之一。
今天,我将详细讲解我当时创建的组件,以便大家更好地理解大多数项目中使用的库的底层原理。这是一个相当高级的演示,因为我不仅讲解了常见的实现方式,还添加了一些我自己的改进。我会引导大家完成所有步骤,让大家彻底理解这个高性能解决方案背后的技巧。轻松渲染海量项目。
我知道你在想什么!既然已经有了久经考验的解决方案,为什么还要重新发明轮子(使用 React Virtualized)呢?为什么还要费劲从头开始创建自己的方案?问题在于,大多数人甚至不了解底层原理,这很危险!不仅会损害你的代码库,也会损害你的知识储备。你不仅可以自定义最终组件的每一个细节,还能了解现有的局限性以及如何改进它们,这将帮助你成为你理想中的优秀开发者。
在我们开始之前,你需要先了解一些基本知识。
-
TypeScript/Javascript(我更喜欢前者)
-
React(当然你也可以选择其他任何 UI 客户端,本演示中使用的是 React)
-
浏览器工作原理基础
虚拟化
仅就 UI 而言,虚拟化意味着维护/保存一些并非完全存在于渲染画布(在 Web 环境中,即DOM)中的数据。事实上,React 核心架构的最初理念正是基于虚拟 DOM,它只是对虚拟化基本思想的迭代。虚拟化列表的概念并不新鲜,Android/iOS 等原生平台和桌面应用早已开箱即用地实现了这一点。虽然目前还没有浏览器优先的 API 来支持这项技术,但它早已广为人知。当需要渲染的组件列表异常庞大时,与其将所有元素都挂载到 DOM(这会造成大量的资源开销),不如只渲染当前容器视口中预期存在的少量元素。就是这样,这就是关键所在!别开玩笑,就是这么简单。一旦你了解了它的原理,一切就都明白了。
组件结构
让我们定义组件模式,以便明确我们想要实现的目标。
export interface WindowProps {
rowHeight: number;
children: Array<JSX.Element>;
gap?: number;
isVirtualizationEnabled?: boolean;
}
为了增强功能,我们不再将容器宽度作为 prop 传递。作为一个智能组件,它应该能够自行推断容器宽度(这也是我想要自己构建组件的原因之一)。
此外,作为 React 子组件,我们只接受 JS 元素列表。TypeScript 的限制虽然没有那么具体,但你可以更进一步,只接受具有预定义 prop 结构的特定列表(这又是另一个话题了)。毋庸置疑,所有子组件都需要是结构相似的同质组件。
间隙表示两个元素之间可见的间隙。我们需要预设 rowHeight,因为我们的组件需要固定的行高(虽然我们可以从子元素中提取行高,但这没有必要,因为动态化行高会增加计算开销,这是另一个问题)。isVirtualizationEnabled只是一个额外的属性,用于展示性能优势。
实施细节
const [containerRef, { height: containerHeight }] = useElementSize<
HTMLUListElement
>();
const [scrollPosition, setScrollPosition] = React.useState(0);
为了实用性,我使用自定义钩子useElementSize
来跟踪Window组件的容器
(您可以自己创建一个,去试试看)
,并使用另一个状态scrollPosition来在滚动时保持容器的顶部滚动高度。
const onScroll = React.useMemo(
() =>
throttle(
function (e: any) {
setScrollPosition(e.target.scrollTop);
},
50,
{ leading: false }
),
[]
);
这是用于保持容器中滚动位置的回调函数,这里我使用了lodash 的节流功能来进一步优化滚动事件,因为由于浏览器处理 DOM 事件的方式,onScroll 事件会被多次触发(这正是我们使用节流的一个非常好的用例),我每 50 毫秒更新一次滚动位置。
现在我们来谈谈最重要的部分(如何实际渲染子元素)。
// get the children to be renderd
const visibleChildren = React.useMemo(() => {
if (!isVirtualizationEnabled)
return children.map((child, index) =>
React.cloneElement(child, {
style: {
position: "absolute",
top: index * rowHeight + index * gap,
height: rowHeight,
left: 0,
right: 0,
lineHeight: `${rowHeight}px`
}
})
);
const startIndex = Math.max(
Math.floor(scrollPosition / rowHeight) - bufferedItems,
0
);
const endIndex = Math.min(
Math.ceil((scrollPosition + containerHeight) / rowHeight - 1) +
bufferedItems,
children.length - 1
);
return children.slice(startIndex, endIndex + 1).map((child, index) =>
React.cloneElement(child, {
style: {
position: "absolute",
top: (startIndex + index) * rowHeight + index * gap,
height: rowHeight,
left: 0,
right: 0,
lineHeight: `${rowHeight}px`
}
})
);
}, [
children,
containerHeight,
rowHeight,
scrollPosition,
gap,
isVirtualizationEnabled
]);
这里我们需要计算要渲染的子元素切片的起始索引和结束索引,并使用指定的属性从 props 中克隆这些子元素。每个子元素都会相对于容器顶部有一个偏移量进行渲染,这个偏移量我们可以通过滚动位置、行高和子元素的索引轻松计算出来。请注意,我们将子元素的位置设置为绝对定位,这是因为容器中普通的 ` display: flex` 属性不起作用。由于 DOM 中 flex 布局的工作方式,它会在初始渲染后触发额外的滚动事件,从而导致无限循环渲染。因此,我们需要使用间隙和偏移量来固定容器中每个子元素的位置。我使用 `useMemo` 只是为了控制渲染周期。
(我使用了React 的`cloneElement`方法,以便将实际元素的渲染与我们的 `Window` 组件解耦。有很多方法可以解决这个问题,例如,您也可以使用`Render-props`模式。)
return (
<ul
onScroll={onScroll}
style={{
overflowY: "scroll",
position: "relative"
}}
ref={containerRef}
className="container"
>
{visibleChildren}
</ul>
);
别忘了把容器的 position 属性设为 relative,否则会一团糟。
绩效指标
为了观察性能提升,我使用了react-fps,它会监控屏幕刷新率,并在组件中添加了一个开关来启用/禁用虚拟化。
希望这能帮助您理清其中的细节。如果您有任何改进建议,欢迎留言,我们将尽力使其更加流畅,并适应更多场景。
这是代码的沙箱链接。
https://codesandbox.io/embed/practical-haze-bxfqe9?fontsize=14&hidenavigation=1&theme=dark
还有 GitHub 链接
https://github.com/Akashdeep-Patra/React-virtualization
也欢迎您在其他平台上关注我。
文章来源:https://dev.to/mr_mornin_star/create-a-react-virtualizationwindowing-component-from-scratch-54lj

