dwqs / dwqs/blog

react-virtualized 组件的虚拟列表优化分析

Open
#73 1 comment 2 reactions 0 assignees View on GitHub
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 元素是渲染图文呢?

因为图片存在网络请求,因而在组件挂载和更新时,图片未必就一定加载完成了,因而此时获取到的节点大小是不准确的,就有可能导致内容重叠:

![内容重叠](https://user-images.githubusercontent.com/7871813/47128808-6afa9f00-d2c5-11e8-857b-e30baa056fbf.png)

这种情况下,我们可以根据项目的实际情况做一些布局上的处理,比如去掉 `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}) => (


{`${text}`}



)
}

);
}

// ...
```

>渲染图文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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.