📜 ⬆️ ⬇️

Optimize React application to display a list of items

Displaying a list (set) of elements on a page is a standard task for almost any web application. In this post I would like to share some tips on improving performance.

For the test example, I will create a small application that draws a lot of “goals” (circles) on the canvas element. I will use redux as a data store, but these tips will work for many other ways to store state.
Also, these optimizations can be applied with react-redux , but for ease of description, I will not use this library.

These tips can improve application performance by 20 times.
')


Let's start with a description of the state:

function generateTargets() { return _.times(1000, (i) => { return { id: i, x: Math.random() * window.innerWidth, y: Math.random() * window.innerHeight, radius: 2 + Math.random() * 5, color: Konva.Util.getRandomColor() }; }); } //       //    "UPDATE",     function appReducer(state, action) { if (action.type === 'UPDATE') { const i = _.findIndex(state.targets, (t) => t.id === action.id); const updatedTarget = { ...state.targets[i], radius: action.radius }; state = { targets: [ ...state.targets.slice(0, i), updatedTarget, ...state.targets.slice(i + 1) ] } } return state; } const initialState = { targets: generateTargets() }; //   const store = Redux.createStore(appReducer, initialState); 


Now let's write the application drawing. I will use react-konva for drawing on canvas.

 function Target(props) { const {x, y, color, radius} = props.target; return ( <Group x={x} y={y}> <Circle radius={radius} fill={color} /> <Circle radius={radius * 1 / 2} fill="black" /> <Circle radius={radius * 1 / 4} fill="white" /> </Group> ); } //      class App extends React.Component { constructor(...args) { super(...args); this.state = store.getState(); // subscibe to all state updates store.subscribe(() => { this.setState(store.getState()); }); } render() { const targets = this.state.targets.map((target) => { return <Target key={target.id} target={target}/>; }); const width = window.innerWidth; const height = window.innerHeight; return ( <Stage width={width} height={height}> <Layer hitGraphEnabled={false}> {targets} </Layer> </Stage> ); } } 


Full demo: http://codepen.io/lavrton/pen/GZXzGm

Now let's write a simple test that will update one “goal”.

 const N_OF_RUNS = 500; const start = performance.now(); _.times(N_OF_RUNS, () => { const id = 1; let oldRadius = store.getState().targets[id].radius; //  redux  store.dispatch({type: 'UPDATE', id, radius: oldRadius + 0.5}); }); const end = performance.now(); console.log('sum time', end - start); console.log('average time', (end - start) / N_OF_RUNS); 


Now we run the tests without any optimizations. On my machine, one update takes about 21ms.

image

This time does not include the process of drawing on the canvas element. Only react and redux code, because react-konva will draw on canvas only in the next animation tick (asynchronously). Now I will not consider drawing optimization on canvas. This is a topic for another article.

And so, 21ms for 1000 elements is quite good performance. If we update the elements infrequently enough, we can leave this code as it is.

But I had a situation when it was necessary to update the elements very often (with each mouse movement during drag & drop). In order to get 60FPS, one update should take no more than 16ms. So 21ms is not so cool (remember that drawing on canvas will occur later).

And so what can be done?

1. Do not update items that have not changed



This is the first and most obvious rule to improve performance. All we need to do is implement shouldComponentUpdate for the Target component:

 class Target extends React.Component { shouldComponentUpdate(newProps) { return this.props.target !== newProps.target; } render() { const {x, y, color, radius} = this.props.target; return ( <Group x={x} y={y}> <Circle radius={radius} fill={color} /> <Circle radius={radius * 1 / 2} fill="black" /> <Circle radius={radius * 1 / 4} fill="white" /> </Group> ); } } 


The result of this add-on ( http://codepen.io/lavrton/pen/XdPGqj ):

image

Super! 4ms is already much better than 21ms. But is it better? In my real application, even after this optimization, the performance was not very good.

Take a look at the App component's render function. The thing I don’t really like is that the render function code will be executed with EVERY update. That is, we have 1000 outbound React.createElement for each “target”. For this example, it works quickly, but in a real application, everything can be sad.

Why should we redraw the entire list if we know that only one item has been updated? Is it possible to directly update this one item?

2 Making children smart



The idea is very simple:

1. Do not update the App component if the list has the same number of elements and their order has not changed.

2. Child elements must update themselves if the data has changed.

So, the Target component should listen for changes in state and apply changes:

 class Target extends React.Component { constructor(...args) { super(...args); this.state = { target: store.getState().targets[this.props.index] }; // subscibe to all state updates this.unsubscribe = store.subscribe(() => { const newTarget = store.getState().targets[this.props.index]; if (newTarget !== this.state.target) { this.setState({ target: newTarget }); } }); } shouldComponentUpdate(newProps, newState) { return this.state.target !== newState.target; } componentWillUnmount() { this.unsubscribe(); } render() { const {x, y, color, radius} = this.state.target; return ( <Group x={x} y={y}> <Circle radius={radius} fill={color} /> <Circle radius={radius * 1 / 2} fill="black" /> <Circle radius={radius * 1 / 4} fill="white" /> </Group> ); } } 


We also need to implement shouldComponentUpdate for the App component:

 shouldComponentUpdate(newProps, newState) { //     -    //    id  ,     ""  const changed = newState.targets.find((target, i) => { return this.state.targets[i].id !== target.id; }); return changed; } 


Result after these changes ( http://codepen.io/lavrton/pen/bpxZjy ):

image

0.25ms for one update is already much better.

Bonus Tip



Use https://github.com/mobxjs/mobx so as not to write the code for all these change subscriptions and checks. The same application, only written using mobx ( http://codepen.io/lavrton/pen/WwPaeV ):

image

It works about 1.5 times faster than the previous result (the difference will be more noticeable for a larger number of elements). And the code is much simpler:

 const {Stage, Layer, Circle, Group} = ReactKonva; const {observable, computed} = mobx; const {observer} = mobxReact; class TargetModel { id = Math.random(); @observable x = 0; @observable y = 0; @observable radius = 0; @observable color = null; constructor(attrs) { _.assign(this, attrs); } } class State { @observable targets = []; } function generateTargets() { _.times(1000, (i) => { state.targets.push(new TargetModel({ id: i, x: Math.random() * window.innerWidth, y: Math.random() * window.innerHeight, radius: 2 + Math.random() * 5, color: Konva.Util.getRandomColor() })); }); } const state = new State(); generateTargets(); @observer class Target extends React.Component { render() { const {x, y, color, radius} = this.props.target; return ( <Group x={x} y={y}> <Circle radius={radius} fill={color} /> <Circle radius={radius * 1 / 2} fill="black" /> <Circle radius={radius * 1 / 4} fill="white" /> </Group> ); } } @observer class App extends React.Component { render() { const targets = state.targets.map((target) => { return <Target key={target.id} target={target}/>; }); const width = window.innerWidth; const height = window.innerHeight; return ( <Stage width={width} height={height}> <Layer hitGraphEnabled={false}> {targets} </Layer> </Stage> ); } } ReactDOM.render( <App/>, document.getElementById('container') ); 

Source: https://habr.com/ru/post/304340/


All Articles