Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize VirtualizedList #31327

Closed
wants to merge 1 commit into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 148 additions & 78 deletions Libraries/Lists/VirtualizedList.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,12 @@ type State = {
last: number,
};

type CachedCell = {
component: any,
deps: Array<any>,
used?: boolean,
};

/**
* Default Props Helper Functions
* Use the following helper functions for default values
Expand Down Expand Up @@ -784,8 +790,51 @@ class VirtualizedList extends React.PureComponent<Props, State> {
};
}

_cachedCells: Map<string, CachedCell> = new Map<string, CachedCell>();
_cells = [];

_markCellsAsUnused() {
this._cachedCells.forEach(cell => {
cell.used = false;
});
}

_deleteUnusedCells() {
this._cachedCells.forEach((cell, key) => {
if (!cell.used) {
this._cachedCells.delete(key);
}
});
}

_pushCell(key, deps, creator) {
//$FlowFixMe[incompatible-type]
let cell: CachedCell = this._cachedCells.get(key);
let depsChanged = false;
if (!cell || cell.deps.length !== deps.length) {
depsChanged = true;
} else {
for (let i = 0; i < deps.length; i++) {
if (cell.deps[i] !== deps[i]) {
depsChanged = true;
break;
}
}
}
if (depsChanged) {
this._cachedCells.set(
key,
(cell = {
component: creator(),
deps,
}),
);
}
cell.used = true;
this._cells.push(cell.component);
}

_pushCells(
cells: Array<Object>,
stickyHeaderIndices: Array<number>,
stickyIndicesFromProps: Set<number>,
first: number,
Expand All @@ -809,9 +858,19 @@ class VirtualizedList extends React.PureComponent<Props, State> {
const key = this._keyExtractor(item, ii);
this._indicesToKeys.set(ii, key);
if (stickyIndicesFromProps.has(ii + stickyOffset)) {
stickyHeaderIndices.push(cells.length);
stickyHeaderIndices.push(this._cells.length);
}
cells.push(
const deps = [
CellRendererComponent,
ItemSeparatorComponent,
horizontal,
ii,
inversionStyle,
item,
prevCellKey,
this.props,
];
this._pushCell(key, deps, () => (
<CellRenderer
CellRendererComponent={CellRendererComponent}
ItemSeparatorComponent={ii < end ? ItemSeparatorComponent : undefined}
Expand All @@ -830,8 +889,8 @@ class VirtualizedList extends React.PureComponent<Props, State> {
ref={ref => {
this._cellRefs[key] = ref;
}}
/>,
);
/>
));
prevCellKey = key;
}
}
Expand Down Expand Up @@ -895,37 +954,46 @@ class VirtualizedList extends React.PureComponent<Props, State> {
? styles.horizontallyInverted
: styles.verticallyInverted
: null;
const cells = [];
this._cells = [];
this._markCellsAsUnused();
const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices);
const stickyHeaderIndices = [];
if (ListHeaderComponent) {
if (stickyIndicesFromProps.has(0)) {
stickyHeaderIndices.push(0);
}
const element = React.isValidElement(ListHeaderComponent) ? (
ListHeaderComponent
) : (
// $FlowFixMe[not-a-component]
// $FlowFixMe[incompatible-type-arg]
<ListHeaderComponent />
);
cells.push(
<VirtualizedListCellContextProvider
cellKey={this._getCellKey() + '-header'}
key="$header">
<View
onLayout={this._onLayoutHeader}
style={StyleSheet.compose(
inversionStyle,
this.props.ListHeaderComponentStyle,
)}>
{
// $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors
element
}
</View>
</VirtualizedListCellContextProvider>,
);
const deps = [
inversionStyle,
ListHeaderComponent,
this.props.extraData,
this.props.ListHeaderComponentStyle,
];
this._pushCell('$header', deps, () => {
const element = React.isValidElement(ListHeaderComponent) ? (
ListHeaderComponent
) : (
// $FlowFixMe[not-a-component]
// $FlowFixMe[incompatible-type-arg]
<ListHeaderComponent />
);
return (
<VirtualizedListCellContextProvider
cellKey={this._getCellKey() + '-header'}
key="$header">
<View
onLayout={this._onLayoutHeader}
style={StyleSheet.compose(
inversionStyle,
this.props.ListHeaderComponentStyle,
)}>
{
// $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors
element
}
</View>
</VirtualizedListCellContextProvider>
);
});
}
const itemCount = this.props.getItemCount(data);
if (itemCount > 0) {
Expand All @@ -937,7 +1005,6 @@ class VirtualizedList extends React.PureComponent<Props, State> {
: initialNumToRenderOrDefault(this.props.initialNumToRender) - 1;
const {first, last} = this.state;
this._pushCells(
cells,
stickyHeaderIndices,
stickyIndicesFromProps,
0,
Expand All @@ -958,11 +1025,10 @@ class VirtualizedList extends React.PureComponent<Props, State> {
stickyBlock.offset -
initBlock.offset -
(this.props.initialScrollIndex ? 0 : initBlock.length);
cells.push(
this._cells.push(
<View key="$sticky_lead" style={{[spacerKey]: leadSpace}} />,
);
this._pushCells(
cells,
stickyHeaderIndices,
stickyIndicesFromProps,
ii,
Expand All @@ -972,7 +1038,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
const trailSpace =
this._getFrameMetricsApprox(first).offset -
(stickyBlock.offset + stickyBlock.length);
cells.push(
this._cells.push(
<View key="$sticky_trail" style={{[spacerKey]: trailSpace}} />,
);
insertedStickySpacer = true;
Expand All @@ -985,13 +1051,12 @@ class VirtualizedList extends React.PureComponent<Props, State> {
const firstSpace =
this._getFrameMetricsApprox(first).offset -
(initBlock.offset + initBlock.length);
cells.push(
this._cells.push(
<View key="$lead_spacer" style={{[spacerKey]: firstSpace}} />,
);
}
}
this._pushCells(
cells,
stickyHeaderIndices,
stickyIndicesFromProps,
firstAfterInitial,
Expand Down Expand Up @@ -1019,7 +1084,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
endFrame.offset +
endFrame.length -
(lastFrame.offset + lastFrame.length);
cells.push(
this._cells.push(
<View key="$tail_spacer" style={{[spacerKey]: tailSpacerLength}} />,
);
}
Expand All @@ -1033,7 +1098,8 @@ class VirtualizedList extends React.PureComponent<Props, State> {
// $FlowFixMe[incompatible-type-arg]
<ListEmptyComponent />
)): any);
cells.push(

this._cells.push(
React.cloneElement(element, {
key: '$empty',
onLayout: event => {
Expand All @@ -1047,30 +1113,38 @@ class VirtualizedList extends React.PureComponent<Props, State> {
);
}
if (ListFooterComponent) {
const element = React.isValidElement(ListFooterComponent) ? (
ListFooterComponent
) : (
// $FlowFixMe[not-a-component]
// $FlowFixMe[incompatible-type-arg]
<ListFooterComponent />
);
cells.push(
<VirtualizedListCellContextProvider
cellKey={this._getFooterCellKey()}
key="$footer">
<View
onLayout={this._onLayoutFooter}
style={StyleSheet.compose(
inversionStyle,
this.props.ListFooterComponentStyle,
)}>
{
// $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors
element
}
</View>
</VirtualizedListCellContextProvider>,
);
const deps = [
inversionStyle,
ListFooterComponent,
this.props.extraData,
this.props.ListFooterComponentStyle,
];
this._pushCell('$footer', deps, () => {
const element = React.isValidElement(ListFooterComponent) ? (
ListFooterComponent
) : (
// $FlowFixMe[not-a-component]
// $FlowFixMe[incompatible-type-arg]
<ListFooterComponent />
);
return (
<VirtualizedListCellContextProvider
cellKey={this._getFooterCellKey()}
key="$footer">
<View
onLayout={this._onLayoutFooter}
style={StyleSheet.compose(
inversionStyle,
this.props.ListFooterComponentStyle,
)}>
{
// $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors
element
}
</View>
</VirtualizedListCellContextProvider>
);
});
}
const scrollProps = {
...this.props,
Expand Down Expand Up @@ -1117,11 +1191,12 @@ class VirtualizedList extends React.PureComponent<Props, State> {
{
ref: this._captureScrollRef,
},
cells,
this._cells,
)}
</VirtualizedListContextProvider>
);
let ret = innerRet;
this._deleteUnusedCells();
if (__DEV__) {
ret = (
<ScrollView.Context.Consumer>
Expand Down Expand Up @@ -1931,33 +2006,27 @@ type CellRendererProps = {
};

type CellRendererState = {
separatorProps: $ReadOnly<{|
highlighted: boolean,
leadingItem: ?Item,
|}>,
highlighted: boolean,
leadingItem: ?Item,
...
};

class CellRenderer extends React.Component<
class CellRenderer extends React.PureComponent<
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component accepts props which changes every render, so I think PureComponent checks will be false every time:

  • onLayout - can be fixed
  • parentProps - can't be fixed
  • Maybe more?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onLayout doesn't seem to cause extra renders in my manual tests. It's input value is given by the creator passed to _pushCell() function, which is only called when needed. So caching already is applied to onLayout too.

I think maybe for some use-cases there can be more optimization, for example not always everyone needs all the fields in parentProps, and sometimes the order and number of items in data doesn't change the internal of an item component, so maybe ii can be removed from deps too. I am interested to see other improvements done too.

CellRendererProps,
CellRendererState,
> {
state = {
separatorProps: {
highlighted: false,
leadingItem: this.props.item,
},
highlighted: false,
leadingItem: this.props.item,
};

static getDerivedStateFromProps(
props: CellRendererProps,
prevState: CellRendererState,
): ?CellRendererState {
return {
separatorProps: {
...prevState.separatorProps,
leadingItem: props.item,
},
...prevState,
leadingItem: props.item,
};
}

Expand Down Expand Up @@ -1987,7 +2056,8 @@ class CellRenderer extends React.Component<

updateSeparatorProps(newProps: Object) {
this.setState(state => ({
separatorProps: {...state.separatorProps, ...newProps},
...state,
...newProps,
}));
}

Expand Down Expand Up @@ -2060,7 +2130,7 @@ class CellRenderer extends React.Component<
// NOTE: that when this is a sticky header, `onLayout` will get automatically extracted and
// called explicitly by `ScrollViewStickyHeader`.
const itemSeparator = ItemSeparatorComponent && (
<ItemSeparatorComponent {...this.state.separatorProps} />
<ItemSeparatorComponent {...this.state} />
);
const cellStyle = inversionStyle
? horizontal
Expand Down