react-virtualized 组件的虚拟列表优化分析
- Dominant language
- JavaScript
- Stars
- 3.8k
- Forks
- 443
- PR merge metrics
- No merged PRs in 30d
Description
### 前言
> 本文源码分析基于 v9.20.1 以及本文 demo 的测试环境:Macbook Pro(Core i7 2.2G, 16G), Chrome 69,React 16.4.1
在[上一篇](https://github.com/dwqs/blog/issues/72)文章中,我简单分析了 `react-virtualized` 的 List 组件是怎么实现虚拟列表的,在文章的最后,留下了一个问题:怎么尽量避免元素内容重叠的问题?本篇将进行简单分析。
`react-virtualized` 的 List 组件虽然存在上述所说的问题,但是它还是可以通过和其它组件的组合来做的更好,**尽量避免在渲染图文场景下的元素内容重叠问题。**
在 [Rendering large lists with React Virtualized](https://blog.logrocket.com/rendering-large-lists-with-react-virtualized-82741907a6b3) 一文中介绍了怎么通过 react-virtualized 来做长列表数据的渲染优化,并详细介绍通过 [`AutoSizer`](https://github.com/bvaughn/react-virtualized/blob/master/docs/AutoSizer.md) 和 [`CellMeasurer`](https://github.com/bvaughn/react-virtualized/blob/master/docs/CellMeasurer.md) 组件来实现 List 组件对列表项动态高度的支持:
* AutoSizer:可以自动调整其子组件大小(高度和宽度)的高阶组件
* CellMeasurer:会自动计算组件的大小(高度和宽度)
这篇文章我们就分析一下这两个组件。
### AutoSizer
如果不使用 `AutoSizer` 组件,直接使用 `List` 组件可能如下:
```js
```
使用 `AutoSizer` 组件之后,代码可能变成如下:
```js
{
({width, height}) => (
)
}
```
因为 `List` 组件使用了一个固定高度,所以将 `AutoSizer` 的 `disableHeight` 设置成 `true` 就相当于告诉 `AutoSizer` 组件不需要管理子组件的高度。
`AutoSizer` 的实现也比较简单,先看起 `render` 方法:
```js
// source/AutoSizer/AutoSizer.js
// ...
render() {
const {
children,
className,
disableHeight,
disableWidth,
style,
} = this.props;
const {height, width} = this.state;
// 外部 div 的样式,外部 div 不需要设置高宽
// 而内部组件应该使用被计算后的高宽值
// https://github.com/bvaughn/react-virtualized/issues/68
const outerStyle: Object = {overflow: 'visible'};
const childParams: Object = {};
if (!disableHeight) {
outerStyle.height = 0;
childParams.height = height;
}
if (!disableWidth) {
outerStyle.width = 0;
childParams.width = width;
}
return (
{children(childParams)}
);
}
// ...
_setRef = (autoSizer: ?HTMLElement) => {
this._autoSizer = autoSizer;
};
// ...
```
然后再看下 `componentDidMount` 方法:
```js
// source/AutoSizer/AutoSizer.js
// ...
componentDidMount() {
const {nonce} = this.props;
// 这里的每一个条件都可能是为了修复某一个边界问题(edge-cases),如 #203 #960 #150 etc.
if (
this._autoSizer &&
this._autoSizer.parentNode &&
this._autoSizer.parentNode.ownerDocument &&
this._autoSizer.parentNode.ownerDocument.defaultView &&
this._autoSizer.parentNode instanceof
this._autoSizer.parentNode.ownerDocument.defaultView.HTMLElement
) {
// 获取父节点
this._parentNode = this._autoSizer.parentNode;
// 创建监听器,用于监听元素大小的变化
this._detectElementResize = createDetectElementResize(nonce);
// 设置需要被监听的节点以及回调处理
this._detectElementResize.addResizeListener(
this._parentNode,
this._onResize,
);
this._onResize();
}
}
// ...
```
在 `componentDidMount` 方法中,主要创建了监听元素大小变化的监听器。`createDetectElementResize` 方法([源代码](https://github.com/bvaughn/react-virtualized/blob/master/source/vendor/detectElementResize.js))是基于 [javascript-detect-element-resize](https://github.com/sdecima/javascript-detect-element-resize) 实现的,针对 SSR 的支持更改了一些代码。接下来看下 `_onResize` 的实现:
```js
// source/AutoSizer/AutoSizer.js
// ...
_onResize = () => {
const {disableHeight, disableWidth, onResize} = this.props;
if (this._parentNode) {
// 获取节点的高宽
const height = this._parentNode.offsetHeight || 0;
const width = this._parentNode.offsetWidth || 0;
const style = window.getComputedStyle(this._parentNode) || {};
const paddingLeft = parseInt(style.paddingLeft, 10) || 0;
const paddingRight = parseInt(style.paddingRight, 10) || 0;
const paddingTop = parseInt(style.paddingTop, 10) || 0;
const paddingBottom = parseInt(style.paddingBottom, 10) || 0;
// 计算新的高宽
const newHeight = height - paddingTop - paddingBottom;
const newWidth = width - paddingLeft - paddingRight;
if (
(!disableHeight && this.state.height !== newHeight) ||
(!disableWidth && this.state.width !== newWidth)
) {
this.setState({
height: height - paddingTop - paddingBottom,
width: width - paddingLeft - paddingRight,
});
onResize({height, width});
}
}
};
// ...
```
`_onResize` 方法做的事就是计算元素新的高宽,并更新 `state`,触发 `re-render`。接下来看看 `CellMeasurer` 组件的实现。
### CellMeasurer
`CellMeasurer` 组件会根据自身的内容自动计算大小,需要配合 [`CellMeasurerCache`](https://github.com/bvaughn/react-virtualized/blob/master/docs/CellMeasurer.md#cellmeasurercache) 组件使用,这个组件主要缓存已计算过的 cell 元素的大小。
先修改一下代码,看看其使用方式:
```js
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized";
class App extends Component {
constructor() {
...
this.cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 180
});
}
...
}
```
首先,我们创建了 `CellMeasurerCache` 实例,并设置了两个属性:
* fixedWidth:表示 cell 元素是固定宽度的,但高度是动态的
* defaultHeight:未被渲染的 cell 元素的默认高度(或预估高度)
然后,我们需要修改 `List` 组件的 `renderRow` 方法以及 `List` 组件:
```js
// ...
renderRow({ index, key, style, parent }) {
// 上一篇分析过,List 是 columnCount 为 1 的 Grid 组件,
// 因而 columnIndex 是固定的 0
return (
{
// 省略
}
);
}
// ...
{
({width, height}) => (
)
}
```
对于 `List` 组件有三个变动:
1. `rowHeight` 属性的值变成 `this.cache.rowHeight`
2. 新增了 `deferredMeasurementCache` 属性,并且其值为 `CellMeasurerCache` 的实例
3. 在 `renderRow` 方法返回的元素外用 `CellMeasurer` 组件包裹了一层
从 `List` 组件的[文档](https://github.com/bvaughn/react-virtualized/blob/master/docs/List.md)看,并没有 `deferredMeasurementCache` 属性说明,但在上一篇文章分析过,`List` 组件的内部实现是基于 `Grid` 组件的:
```js
// source/List/List.js
// ...
render() {
//...
return (
);
}
// ...
```
而 `Grid` 组件是拥有这个属性的,其值是 `CellMeasurer` 实例,因而这个属性实际上是传递给了 `Grid` 组件。
回到 `CellMeasurer` 组件,其实现是比较简单的:
```js
// source/CellMeasurer/CellMeasurer.js
// ...
componentDidMount() {
this._maybeMeasureCell();
}
componentDidUpdate() {
this._maybeMeasureCell();
}
render() {
const {children} = this.props;
return typeof children === 'function'
? children({measure: this._measure})
: children;
}
// ...
```
上述代码非常简单,`render` 方法只做子组件的渲染,并在组件挂载和更新的时候都去调用 `_maybeMeasureCell` 方法,这个方法就会去计算 cell 元素的大小了:
```js
// source/CellMeasurer/CellMeasurer.js
// ...
// 获取元素的大小
_getCellMeasurements() {
// 获取 CellMeasurerCache 实例
const {cache} = this.props;
// 获取组件自身对应的 DOM 节点
const node = findDOMNode(this);
if (
node &&
node.ownerDocument &&
node.ownerDocument.defaultView &&
node instanceof node.ownerDocument.defaultView.HTMLElement
) {
// 获取节点对应的大小
const styleWidth = node.style.width;
const styleHeight = node.style.height;
/**
* 创建 CellMeasurerCache 实例时,如果设置了 fixedWidth 为 true,
* 则 hasFixedWidth() 返回 true;如果设置了 fixedHeight 为 true,
* 则 hasFixedHeight() 返回 true。两者的默认值都是 false
* 将 width 或 heigth 设置成 auto,便于得到元素的实际大小
**/
if (!cache.hasFixedWidth()) {
node.style.width = 'auto';
}
if (!cache.hasFixedHeight()) {
node.style.height = 'auto';
}
const height = Math.ceil(node.offsetHeight);
const width = Math.ceil(node.offsetWidth);
// 获取到节点的实际大小之后,需要重置样式
// https://github.com/bvaughn/react-virtualized/issues/660
if (styleWidth) {
node.style.width = styleWidth;
}
if (styleHeight) {
node.style.height = styleHeight;
}
return {height, width};
} else {
return {height: 0, width: 0};
}
}
_maybeMeasureCell() {
const {
cache,
columnIndex = 0,
parent,
rowIndex = this.props.index || 0,
} = this.props;
// 如果缓存中没有数据
if (!cache.has(rowIndex, columnIndex)) {
// 则计算对应元素的大小
const {height, width} = this._getCellMeasurements();
// 缓存元素的大小
cache.set(rowIndex, columnIndex, width, height);
// 通过上一篇文章的分析,可以得知 parent 是 Grid 组件
// 更新 Grid 组件的 _deferredInvalidate[Column|Row]Index,使其在挂载或更新的时候 re-render
if (
parent &&
typeof parent.invalidateCellSizeAfterRender === 'function'
) {
parent.invalidateCellSizeAfterRender({
columnIndex,
rowIndex,
});
}
}
}
// ...
```
>`_maybeMeasureCell` 方法最后会调用 `invalidateCellSizeAfterRender`,从方法的[源代码](https://github.com/bvaughn/react-virtualized/blob/9.20.1/source/Grid/Grid.js#L496-L512)上看,它只是更新了组件的 `_deferredInvalidateColumnIndex` 和 `_deferredInvalidateRowIndex` 的值,那调用它为什么会触发 Grid 的 re-render 呢?因为这两个值被用到的地方是在 `_handleInvalidatedGridSize` 方法中,从其[源代码](https://github.com/bvaughn/react-virtualized/blob/9.20.1/source/Grid/Grid.js#L1292-L1309)上看,它调用了 `recomputeGridSize` 方法(后文会提到这个方法)。而 `_handleInvalidatedGridSize` 方法是在组件的 `componentDidMount` 和 `componentDidUpdate` 的时候均会调用。
从上文可以知道,如果子组件是函数,则调用的时候还会传递 `measure` 参数,其值是 `_measure`,实现如下:
```js
// source/CellMeasurer/CellMeasurer.js
// ...
_measure = () => {
const {
cache,
columnIndex = 0,
parent,
rowIndex = this.props.index || 0,
} = this.props;
// 计算对应元素的大小
const {height, width} = this._getCellMeasurements();
// 对比缓存中的数据
if (
height !== cache.getHeight(rowIndex, columnIndex) ||
width !== cache.getWidth(rowIndex, columnIndex)
) {
// 如果不相等,则重置缓存
cache.set(rowIndex, columnIndex, width, height);
// 并通知父组件,即 Grid 组件强制 re-render
if (parent && typeof parent.recomputeGridSize === 'function') {
parent.recomputeGridSize({
columnIndex,
rowIndex,
});
}
}
};
// ...
```
[`recomputeGridSize`](https://github.com/bvaughn/react-virtualized/blob/master/docs/Grid.md#recomputegridsize--columnindex-number-rowindex-number-) 方法时 Grid 组件的一个公开方法,用于重新计算元素的大小,并通过 `forceUpdate` 强制 re-render,其实现比较简单,如果你有兴趣了解,可以去查看下其[源代码](https://github.com/bvaughn/react-virtualized/blob/9.20.1/source/Grid/Grid.js#L530-L562)。
至此,`CellMeasurer` 组件的实现就分析完结了。如上文所说,`CellMeasurer` 组件要和 `CellMeasurerCache` 组件搭配使用,因而接下来我们快速看下 `CellMeasurerCache` 组件的实现:
```js
// source/CellMeasurer/CellMeasurerCache.js
// ...
// KeyMapper 是一个函数,根据行索引和列索引返回对应数据的唯一 ID
// 这个 ID 会作为 Cache 的 key
// 默认的唯一标识是 `${rowIndex}-${columnIndex}`,见下文的 defaultKeyMapper
type KeyMapper = (rowIndex: number, columnIndex: number) => any;
export const DEFAULT_HEIGHT = 30;
export const DEFAULT_WIDTH = 100;
// ...
type Cache = {
[key: any]: number,
};
// ...
_cellHeightCache: Cache = {};
_cellWidthCache: Cache = {};
_columnWidthCache: Cache = {};
_rowHeightCache: Cache = {};
_columnCount = 0;
_rowCount = 0;
// ...
constructor(params: CellMeasurerCacheParams = {}) {
const {
defaultHeight,
defaultWidth,
fixedHeight,
fixedWidth,
keyMapper,
minHeight,
minWidth,
} = params;
// 保存相关值或标记位
this._hasFixedHeight = fixedHeight === true;
this._hasFixedWidth = fixedWidth === true;
this._minHeight = minHeight || 0;
this._minWidth = minWidth || 0;
this._keyMapper = keyMapper || defaultKeyMapper;
// 获取默认的高宽
this._defaultHeight = Math.max(
this._minHeight,
typeof defaultHeight === 'number' ? defaultHeight : DEFAULT_HEIGHT,
);
this._defaultWidth = Math.max(
this._minWidth,
typeof defaultWidth === 'number' ? defaultWidth : DEFAULT_WIDTH,
);
// ...
}
// ...
hasFixedHeight(): boolean {
return this._hasFixedHeight;
}
hasFixedWidth(): boolean {
return this._hasFixedWidth;
}
// ...
// 根据索引获取对应的列宽
// 可用于 Grid 组件的 columnWidth 属性
columnWidth = ({index}: IndexParam) => {
const key = this._keyMapper(0, index);
return this._columnWidthCache.hasOwnProperty(key)
? this._columnWidthCache[key]
: this._defaultWidth;
};
// ...
// 根据行索引和列索引获取对应 cell 元素的高度
getHeight(rowIndex: number, columnIndex: number = 0): number {
if (this._hasFixedHeight) {
return this._defaultHeight;
} else {
const key = this._keyMapper(rowIndex, columnIndex);
return this._cellHeightCache.hasOwnProperty(key)
? Math.max(this._minHeight, this._cellHeightCache[key])
: this._defaultHeight;
}
}
// 根据行索引和列索引获取对应 cell 元素的宽度
getWidth(rowIndex: number, columnIndex: number = 0): number {
if (this._hasFixedWidth) {
return this._defaultWidth;
} else {
const key = this._keyMapper(rowIndex, columnIndex);
return this._cellWidthCache.hasOwnProperty(key)
? Math.max(this._minWidth, this._cellWidthCache[key])
: this._defaultWidth;
}
}
// 是否有缓存数据
has(rowIndex: number, columnIndex: number = 0): boolean {
const key = this._keyMapper(rowIndex, columnIndex);
return this._cellHeightCache.hasOwnProperty(key);
}
// 根据索引获取对应的行高
// 可用于 List/Grid 组件的 rowHeight 属性
rowHeight = ({index}: IndexParam) => {
const key = this._keyMapper(index, 0);
return this._rowHeightCache.hasOwnProperty(key)
? this._rowHeightCache[key]
: this._defaultHeight;
};
// 缓存元素的大小
set(
rowIndex: number,
columnIndex: number,
width: number,
height: number,
): void {
const key = this._keyMapper(rowIndex, columnIndex);
if (columnIndex >= this._columnCount) {
this._columnCount = columnIndex + 1;
}
if (rowIndex >= this._rowCount) {
this._rowCount = rowIndex + 1;
}
// 缓存单个 cell 元素的高宽
this._cellHeightCache[key] = height;
this._cellWidthCache[key] = width;
// 更新列宽或行高的缓存
this._updateCachedColumnAndRowSizes(rowIndex, columnIndex);
}
// 更新列宽或行高的缓存,用于纠正预估值的计算
_updateCachedColumnAndRowSizes(rowIndex: number, columnIndex: number) {
if (!this._hasFixedWidth) {
let columnWidth = 0;
for (let i = 0; i < this._rowCount; i++) {
columnWidth = Math.max(columnWidth, this.getWidth(i, columnIndex));
}
const columnKey = this._keyMapper(0, columnIndex);
this._columnWidthCache[columnKey] = columnWidth;
}
if (!this._hasFixedHeight) {
let rowHeight = 0;
for (let i = 0; i < this._columnCount; i++) {
rowHeight = Math.max(rowHeight, this.getHeight(rowIndex, i));
}
const rowKey = this._keyMapper(rowIndex, 0);
this._rowHeightCache[rowKey] = rowHeight;
}
}
// ...
function defaultKeyMapper(rowIndex: number, columnIndex: number) {
return `${rowIndex}-${columnIndex}`;
}
```
对于 `_updateCachedColumnAndRowSizes` 方法需要补充说明一点的是,通过上一篇文章的分析,我们知道在组件内不仅需要去计算总的列宽和行高的(`CellSizeAndPositionManager#getTotalSize` 方法) ,而且需要计算 cell 元素的大小(`CellSizeAndPositionManager#_cellSizeGetter` 方法)。在 cell 元素被渲染之前,用的是预估的列宽值或者行高值计算的,此时的值未必就是精确的,而当 cell 元素渲染之后,就能获取到其真实的大小,因而缓存其真实的大小之后,在组件的下次 re-render 的时候就能对原先预估值的计算进行纠正,得到更精确的值。
>demo的完整代码戳此:[ReactVirtualizedList](https://github.com/dwqs/react-demos/blob/master/src/general/list/ReactVirtualizedList/index.jsx)
### 总结
`List` 组件通过和 `AutoSizer` 组件以及 `CellMeasurer` 组件的组合使用,很好的优化了 `List` 组件自身对元素动态高度的支持。但从上文分析可知,`CellMeasurer` 组件会在其初次挂载(`mount`)和更新(`update`)的时候通过 `_maybeMeasureCell` 方法去更新自身的大小,如果 cell 元素只是渲染纯文本,这是可以满足需求的,但 cell 元素是渲染图文呢?
因为图片存在网络请求,因而在组件挂载和更新时,图片未必就一定加载完成了,因而此时获取到的节点大小是不准确的,就有可能导致内容重叠:

这种情况下,我们可以根据项目的实际情况做一些布局上的处理,比如去掉 `border`,适当增加 cell 元素的 `padding` 或者 `margin` 等(:blush::blush::blush:),这是有点取巧的方式,那不取的方式是**将 `CellMeasurer` 的子组件换成函数**。
上文已经说过,如果子组件是函数,则调用的时候会传递一个函数 `measure` 作为参数,这个函数所做的事情就是重新计算对应 cell 元素的大小,并使 `Grid` 组件 re-render。因而,我们可以将这个参数绑定到 `img` 的 `onLoad` 事件中,当图片加载完成时,就会重新计算对应 cell 元素的大小,此时,获取到的节点大小就是比较精确的值了:
```
// ...
renderRow({ index, key, style, parent }) {
// 上一篇分析过,List 是 columnCount 为 1 的 Grid 组件,
// 因而 columnIndex 是固定的 0
return (
{
({measure}) => (
)
}
);
}
// ...
```
>渲染图文demo的完整代码戳此:[ReactVirtualizedList with image](https://github.com/dwqs/react-demos/blob/master/src/general/list/ReactVirtualizedList/withImage.jsx)
<本文完>
### 参考
* [Rendering large lists with React Virtualized](https://blog.logrocket.com/rendering-large-lists-with-react-virtualized-82741907a6b3)
Contributor guide
No contributing guide indexed for this repository
Research direction
The issue is a Chinese analysis article covering source/AutoSizer/AutoSizer.js, source/CellMeasurer/CellMeasurer.js, source/CellMeasurer/CellMeasurerCache.js, source/List/List.js, and source/Grid/Grid.js. Start by reviewing the linked react-virtualized documentation and source references, but no concrete change or completion criterion is stated in the issue.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, react
- Domain
- content, documentation
- Issue type
- Documentation
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 20/100