Отображение списка (множества) элементов на странице — это стандартная задача для практически любого 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мс.
Это время не включает в себя процесс рисования на 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):
Супер! 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):
0.25мс на одно обновление это уже намного лучше.
Бонусный совет
Используйте https://github.com/mobxjs/mobx чтобы не писать код всех этих подписок на изменения и проверок. То же приложение, только написанное с помощью mobx (http://codepen.io/lavrton/pen/WwPaeV):
Работает примерно в 1.5 раза быстрее, чем предыдущий результат (разница будет более заметная для большего кол-ва элементов). И код намного проще:
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')
);
Автор: lavrton