Оптимізуємо React додаток для відображення списку елементів

Відображення списку (безлічі) елементів на сторінці — це стандартна завдання для практично будь-якого web-додатки. У цьому пості я хотів би поділитися деякими порадами щодо підвищення продуктивності.

Для тестового прикладу я створю невеликий додаток, яке малює безліч «цілей» (кіл) на елементі canvas. Я буду використовувати redux як сховище даних, але ці поради підійдуть і для багатьох інших способів зберігання стану.
Так само ці оптимізації можна застосовувати з react-redux, але для простоти опису я не буду використовувати цю бібліотеку.

Дані поради можуть підвищити продуктивність програми в 20 разів.



Почнемо з опису стану:

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);


Тепер напишемо малювання додатки. Я буду використовувати react-konva для малювання на 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>
);
}
}


Повне демо: http://codepen.io/lavrton/pen/GZXzGm

Тепер давайте напишемо простий тест, який буде оновлювати одну «мета».

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);


Тепер запускаємо тести без будь-яких оптимізацій. На моїй машині одне оновлення займає приблизно 21мс.

image

Це час не включає в себе процес малювання на елемент canvas. Тільки react і redux код, тому що react-konva буде малювати на canvas тільки в наступному тику анімації (асинхронно). Зараз я не буду розглядати оптимізацію малювання на canvas. Це тема для іншої статті.

І так, 21мс для 1000 элеметнов це досить хороша продуктивність. Якщо ми оновлюємо елементи досить рідко ми може залишити цей код як є.

Але у мене була ситуація коли оновлювати елементи потрібно було дуже часто (при кожному русі миші під час drag&drop). Для того, щоб отримати 60FPS потрібно щоб одне оновлення займало не більше 16мс. Так що 21мс це вже не так здорово (пам'ятаєте що ще потім буде відбуватися малювання на canvas).

І так що ж можна зробити?

1. Не оновлювати елементи, які не змінилися

Собсвено це перше і очевидне правило для підвищення продуктивності. Все що нам потрібно зробити це реалізувати shouldComponentUpdate для компонента Target:

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>
);
}
}


Результат такого доповнення (http://codepen.io/lavrton/pen/XdPGqj):

image

Супер! 4мс це вже набагато краще ніж 21мс. Але чи краще? У моєму реальному додатку навіть після такої оптимізації продуктивність була не дуже.

Погляньте на функцію render компонента App. Штука, яка мені не дуже подобається — це те, що код функції render буде виконуватись при КОЖНОМУ оновленні. Тобто ми маємо 1000 вывозов React.createElement для кожної «мети». Для даного прикладу це працює швидко, але в реальному додатку все може бути сумно.

Чому ми повинні перемальовувати весь список, якщо ми знаємо, що оновився тільки один елемент? Можна безпосередньо оновити цей один елемент?

2 Робимо дочірні елементи «розумними»

Ідея дуже проста:

1. Не оновлювати компонент App якщо список має таку ж кількість елементів і їх порядок не змінився.

2. Дочірні елементи повинні оновити самі себе, якщо дані змінилися.

Отже, компонент Target повинен слухати зміни в стані і застосовувати зміни:

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>
);
}
}


Так само нам потрібно реалізувати shouldComponentUpdate для компонента App:

shouldComponentUpdate(newProps, newState) {
// перевіряємо що порядок і кількість елементів залишилися колишніми
// тобто якщо id залишилися колишніми, значить у нас немає "великих" змін
const changed = newState.targets.find((target, i) => {
return this.state.targets[i].id !== target.id;
});
return changed;
}


Результат після даних змін (http://codepen.io/lavrton/pen/bpxZjy):

image

0.25 мс на одне оновлення це вже набагато краще.

Бонусний рада

Використовуйте https://github.com/mobxjs/mobx щоб не писати код всіх цих підписок на зміни та перевірок. Те саме додаток, тільки написане за допомогою mobx http://codepen.io/lavrton/pen/WwPaeV):

image

Працює приблизно в 1.5 рази швидше, ніж попередній результат (різниця буде більш помітна для більшого кол-ва елементів). Код набагато простіше:

const {Stage, Layer, Circle, Group} = ReactKonva;
const {вами, computed} = mobx;
const {observer} = mobxReact;

class TargetModel {
id = Math.random();
@вами x = 0;
@вами y = 0;
@вами radius = 0;
@вами color = null;
constructor(attrs) {
_.assign(this, attrs);
}
}

class State {
@вами 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')
);

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.