React, где скорость?

Предыстория

Третий год наблюдаю за развитием React и его окружением, которое меняет со скоростью света. Ещё тогда пробовал писать свои тесты на производительность и они не впечатляли. Время шло, но хайп только усиливался, вскоре появился Redux, который вывел React в бесспорные лидеры.

Неделю назад, я подумал, а почему бы мне собрать текущий проект на Redux и проверить, насколько лучше стало.

Условия эксперимента

Первые проблемы

Первые проблемы

Вселенная React/Redux гипер изменчива, поэтому любая документации как запустить всё это (с использованием того же Webpack), статьи и скринкасты устаревают на глазах, а то, что месяц назад было best practicts, вполне возможно уже не так.

Поэтому, чтобы окончательно не запутаться и не терять время (а поверьте, часа 4 были безвозвратно потеряны, в попытках с нуля поднять инфраструктуру) решил использовать один из популярных Redux Boilerplate.

Redux Boilerplate

  1. erikras/react-redux-universal-hot-example
  2. tj/frontend-boilerplate

erikras/react-redux-universal-hot-example

tj/frontend-boilerplate мой выбор

Redux

Redux structure

Redux structure

MyRedux structure

Приложение

Основные компоненты

Button.js

			import React, {Component} from "react";
			#! import cx from "classnames"; // хелпер
			#! import Icon from "./Icon"; // Блок иконки
			#!+ export default class Button extends Component {
				render() {
					#! const {text, short, ico, big, pressed, borderless, type, size, disabled, onTap} = this.props;
					#!+ const classes = cx("button", {
						"button_short": short || !text,
						"button_has-ico": ico,
						"button_big": big,
						"button_pressed": pressed,
						"button_borderless": borderless,
						"button_primary": type == "submit",
						[`button_size_${size}`]: size,
					#!- });
					#!+ return <button className={classes} type={type} disabled={disabled} onClick={onTap}>
						{ico ? (
							<span className={classNames({"button__ico": true, [`button__ico_size_${size}`]: size})}>
								<Icon name={ico} size={size}/>
							</span>
						) : null}
						{!short && text ? <span className="button__txt">{text}</span> : null}
					#!- </button>;
				}
			#!- };
		

Store

			{
				auth: {
					state: false, // сотояние авторизации
					error: false, // ошибка при авторизации
					busy: false, // идет авторизация
					email: null, // активный email
				},
				// ...
			}
		

Важно!

			// Store, он же state формируют редьюсеры
			// redcuers/index.js
			import {combineReducers} from "redux";
			#! import auth from "./auth";
			#! import other from "./other";
			#!+ export default combineReducers({
				#! auth,
				#! other,
			#!- });
		

index.js

			#!+ 
{
import {Router, Route, IndexRedirect, browserHistory} from "react-router"; import {syncHistoryWithStore} from "react-router-redux"; import {Provider } from "react-redux"; import ReactDOM from "react-dom"; import React from "react"; import App from "./components/App"; #!- import configure from "./store"; #! const initialState = {}; // или {auth: {..initial..}} #! const store = configure(initialState); #! const history = syncHistoryWithStore(browserHistory, store); #!+ ReactDOM.render( <Provider store={store}> <Router history={history}> <Route path="/"> **<IndexRedirect to="/0/"/>** </Route> <Route path="/:folder" component={App}/> </Router> </Provider>, document.getElementById("root") #!- );

/components/App.js

			import React, {Component} from "react";
			import {connect} from "react-redux";
			import AuthForm from "./AuthForm";
			#!+ // Декорируем класса соединяя его с Redux
			@connect(
				state => state // Это и есть map to props
			)
			export default class App extends Component {
				#!+ render() {
					const {auth} = this.props;
					return !auth.state ? <AuthForm/> : <h1>Привет>/h1>;
				#!- }
			#!- }
		

/components/AuthForm.js

			import React, {Component} from "react";
			import Button from "./Button";
			export class AuthForm extends Component {
				render() {
					const {error, busy} = this.props;
					return (
						<form className="well auth-form" **onSubmit={::this.handleSubmit}**>
							<h2>Авторизуйся, смерд!</h2>
							**{error && <b>Ошибка</b>}**
							<input ref="email" className="input" placeholder="Email" name="email"/>
							<p><input ref="pass" className="input" placeholder="Password" name="pass" type="password"/></p>
							<Button text="Войти" type="submit" **disabled={busy}**/>
						</form>
					);
				}
				#!+ handleSubmit(evt) {
					// ????
				#!- }
			}
		

Первое действие

Что дальше?

			// constants/login.js
			export const LOGIN_REQUEST = "LOGIN_REQUEST";

			#!+ // actions/auth.js
			import {LOGIN_REQUEST} from "../constants/auth";
			export const login = (email, password) => ({
				#! type: LOGIN_REQUEST, // обязательное поле
				#! payload: {email, password} // какая-то полезная нагрузка
			#!- });
		

AuthForm.js

			#! import * as **actions** from "../actions/auth";
			#! import {connect} from "react-redux";
			#! import {bindActionCreators} from "redux";
			#!+ @connect(
				(state) => state, // mapStateToProps
				#!+ (dispatch) => ({ // mapDispatchToProps
					#! **authActions**: bindActionCreators(actions, dispatch)
				#!- })
			)
			export class AuthForm extends Component {
				handleSubmit(evt) {
					#! const {email, pass} = this.refs;
					#! this.props.**authActions.login**(email.value, pass.value);
					#! evt.preventDefault();
				}
				render() {
					// ...
				}
			#!- }
		

reducers/auth.js

			import {LOGIN_REQUEST} from "../constants/auth";
			#!+ const initialState = {
				state: true,
				email: null,
				error: false,
				busy: true
			#!- };
			#!+ export default (state = **initialState**, action) => {
				#!+ switch (action.type) {
					#!+ case LOGIN_REQUEST:
						return {...state, busy: true}; // создаем новый `state`

					default:
					#!-	return state;
				#!- }
			#!- };
		

Что дальше?

Что дальше?

А вот теперь самое интересное, как же сделать запрос и обработать результат?

Логично предположить, что это должен сделать actions/login... но это простая функция, кот орая возвращает объект?! Как быть?

actions/login.js

			import {LOGIN_REQUEST} from "../constants/auth";
			export const login = (email, password) => ({
				type: LOGIN_REQUEST, // обязательное поле
				payload: {email, password} // какая-то полезная нагрузка
			});
		

MIDDLEWARE

MIDDLEWARE / logger

			export default function logger(store) {
				return function wrapDispatchToAddLogging(next) => {
					return function dispatchAndLog(action) => {
						console.log(action);
						return next(action);
					};
				};
			};
		

MIDDLEWARE / redux-thunk

			export default ({dispatch, getState}) => next => action => {
				if (typeof action === "function") {
					return action(dispatch, getState);
				}

				return next(action);
			};
		

actions/login.js

			export const login = (dispatch) => (email, password) => {
				#!+ dispatch({
					type: LOGIN_REQUEST, // обязательное поле
					payload: {email, password} // какая-то полезная нагрузка
				#!- });
				#! fetch("api/login", {body: `email=${email}&password=${password}`})
				#!	.then(response => response.json())
				#!	.then(body => dispatch({type: LOGIN_SUCCESS, body})
				#!	.catch(error => dispatch({type: LOGIN_FAIL, error});
			};
			#! // И так далее, для других action
		

Мдя, копипаста

MIDDLEWARE / API

			export const login = (email, password) => ({
				api: USER_LOGIN, // вызываемый метод API
				types: [LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAIL],
				data: {email, password},
			});
		

MIDDLEWARE / API

			export default ({dispatch, getState}) => next => action => {
				#! const {api, types, data, ...rest} = action;
				#!+ if (!(api && types)) {
					return next(action);
				#!- }
				#! const [REQUEST, SUCCESS, FAIL] = types;
				#! next({...rest, type: REQUEST, api, data});

				#!+ return call(api, data, getState().auth).then(
						(result) => next({...rest, result, type: SUCCESS, api, data}),
						(error) => next({...rest, error, type: FAIL, api, data})
					).catch((error) => {
						console.error('MIDDLEWARE ERROR:', error, api, data);
						next({...rest, error, type: FAIL});
				#!-	});
			};
		

git add .

git status

15 файлов

redux-octavius v0.1.0

Юзер

Юзер

Получившаяся версия только авторизует и получает в ответ токен, поэтому нам нужно доработать действие login, чтобы после авторизации сразу получить данные юзера.

Данные юзера (новые константы)

			// constants/api.js
			export const USER_SHORT_METHOD = "user/short";

			// constants/user.js
			export const USER_FETCH = "USER_FETCH";
			export const USER_FETCH_SUCCESS = "USER_FETCH_SUCCESS";
			export const USER_FETCH_FAIL = "USER_FETCH_FAIL";
		

Модифицыруем reducers/auth.js

			+import {LOGIN_REQUEST, LOGIN_FAIL} from "../constants/auth";
			+import {USER_FETCH_SUCCESS, USER_FETCH_FAIL} from "../constants/user";
			const initialState = {..};
			export default (state = initialState, action) => {
				switch (action.type) {
					case LOGIN_REQUEST:
						return {...state, busy: true};
			+		case USER_FETCH_SUCCESS:
			+			return {...state, ...action.result, state: true, busy: false};
					case LOGIN_FAIL:
			+		case USER_FETCH_FAIL:
						return {...state, error: true, busy: false};
					default:
						return state;
				}
			};
		

Самое интересное actions/auth.js

			import {USER_LOGIN_MEETHOD} from "../constants/api";
			import {LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAIL} from "../constants/auth";
			#! import {fetchUser} from "./user";
			#!+ export const login = (email, password) => **async** (dispatch) => {
				#!+ const response = **await** dispatch({
					api: USER_LOGIN_MEETHOD,
					data: {email, password},
					types: [LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAIL],
				#!- });
				#!+ if (!response.error) {
					return dispatch(fetchUser(email));
				#!- }
			#!- };
		

/components/App.js

			@connect(
				state => state
			)
			export default class App extends Component {
				render() {
					const {auth} = this.props;
					return !auth.state ? <AuthForm/> : <h1>Привет, {auth.email}</h1>;
				}
			}
		

v0.2.0 / DIFF

Папки и треды

Store += Папки и треды

			{
				auth: {..},
				folders: [{folder}, ..], // reducers/folders.js
				threads: {  // reducers/threads.js
					current: { // текущий список тредов по папки
						folder_id: [{thread}, ..],
					},
				},
			}
		

Но есть нюанс

Но есть нюанс

В Почте мы не используем отдельный «конца» для получения списка папок и тредов, это не эффективно. Для этого используется threads/status который сразу возвращает треды и папки, оба редьюсера будут реагировать на действие THREADS_STATUS_SUCCESS.

Пять минут спустя

reducers/folders.js

			import {STATUS_FETCH_SUCCESS} from "../constants/status";
			export default (state = [], action) => {
				switch (action.type) {
					case STATUS_FETCH_SUCCESS:
						return action.result.folders;
					default:
						return state;
				}
			};
		

reducers/threads.js

			import {STATUS_FETCH_SUCCESS} from "../constants/status";
			const initialState = {current: {}};
			export default (state = initialState, action) => {
				switch (action.type) {
					case STATUS_FETCH_SUCCESS:
						const {folder} = action.data;
						const {threads} = action.result;
						return {
							...state,
							current: {
								...state.current, // нужно больше деструктуризации
								[folder]: threads
							}
						};
					default:
						return state;
				}
			};
		

Собираем App

Собираем App

			@connect(
				#!+ ({auth, folders, threads}, {params}) => ({
					auth,
					folders,
					threads: threads.current[params.folder | 0] || []
				#!- })
			)
			export default class App extends Component {
				render() {
					#! const {auth, folders, threads, params} = this.props;
					#! const folderId = params.folder | 0;
					#!+ if (auth.state) {
						return <div>
							<Headline/>
							<PortalMenu/>
							<Layout
								bordered
							#!	left={<Scrollable content={**<Folders models={folders} active={folderId}/>**}/>}
							#!	main={<Scrollable content={**<Letters models={threads}/>**}/>} />
						</div>;
					} else {
						return <AuthForm/>;
					#!- }
				}
			}
		

Как загрузить данные?

Как загрузить данные?

Или точнее как вызвать действие? На какое событие?

@fetchData(getter, executor)

@fetchData

			@connect(...)
			@fetchData( // Будет вызывать действия при измениях маршрута и свойств
				#! ({auth: email}, {folder}) => ({email, folder}),
				#!+ ({email, folder}, actions)
				#!-		=> email && actions.fetchStatus(folder)
			)
			export default class App extends Component {
				// ...
			}
		

@fetchData

			import React, {Component} from "react";
			import shallowEqual from "react-redux/lib/utils/shallowEqual";
			export default function fetchData(getter, executor) {
				#!+ return (DecoratedComponent) => class FetchDataDecorator extends Component {
					#!+ componentWillMount() {
						executor(getter(this.props, this.props.params), this.props.actions);
					#!- }

					#!+ componentDidUpdate(prevProps) {
						#! const params = getter(this.props, this.props.params);
						#! const prevParams = getter(prevProps, prevProps.params);
						#!+ !shallowEqual(params, prevParams)
						#!-		&& executor(params, this.props.actions);
					#!- }

					render() {
						return <DecoratedComponent {...this.props} />;
					}
				#!- };
			};
		

Привязываем Действия к App

			import {bindActionCreators} from "redux";
			import {routerActions} from "react-router-redux";
			import {fetchStatus} from "../actions/status";

			@connect(
				(...) => (...),
				(dispatch) => ({ // mapDispatchToProps
					#! actions: bindActionCreators({fetchStatus}, dispatch),
					#! routerActions: bindActionCreators({...routerActions}, dispatch),
				})
			)
			@fetchData(...)
			export default class App extends Component {
				// ...
			}
		

v0.3.0 / DIFF

Навигация

Навигация

			export default class App extends Component {
				render() {
					// ...
					if (auth.state) {
						// ...
						return <div **ref="clickable"** onClick={(evt) => **this.handleGlobalClick(evt)**}>
							// ...
						</div>;
					}
				}
				#!+ handleGlobalClick(evt) {
					if (!evt.defaultPrevented) {
						let el = evt.target;
						#!+ while (el.parentNode && el !== this.refs.clickable) {
							if (el.tagName === "A" && el.target !== "_blank") {
								evt.preventDefault();
								#!+ this.props.**routerActions.push**(el.pathname);
								#!- return;
							}
							el = el.parentNode;
						#!- }
					}
				#!- }
			}
		

v0.4.0 / DIFF

Проблема

Проблема

Основная проблема, что при переходе между Папками, сначала список тредов пустой и только после ответа сервера он обновляется. Надо сделать так, чтобы переключение папки и обновление списка происходило только, если есть данные.

Решение в лоб / v0.4.1 / DIFF

			#!+ constructor(...args) {
				super(...args);
				this.activeFolderId = null;
				this.activeThreads = [];
			#!- }
			#!+ render() {
				const {auth} = this.props;
				if (auth.state) {
					let {folders, threads, params} = this.props;
					let folderId = params.folder | 0;
					#!+ if (threads == null) { // тредов нет, возьмем активный список
						#!+ folderId = this.activeFolderId == null ? folderId : this.activeFolderId;
						#!- threads = this.activeThreads;
					} else {
						#!+ this.activeFolderId = folderId;
						#!- this.activeThreads = threads;
					}
					#!- // ...
				}
			#!- }
		

Выбор писем

Выбор / v0.5.0 / DIFF

Тут всё как и раньше: contstants, action, reducer, connect.

И...

Тормозитсильно

Отключаем все DevTools*

* — для React и Redux

Замеряем время клика

			console.time("click");
			document.querySelectorAll(".nav a")[1].click();
			console.timeEnd("click");
		

Замеряем время клика

			console.time("click");
			document.querySelectorAll(".nav a")[1].click();
			console.timeEnd("click");
		

Замеряем время клика

Не может быть!

574.06ms

Может 💩

Может тормозит мой код?

Performance Tools
react-addons-perf

react-addons-perf

react-addons-perf

70.66ms

40.9ms

2.09ms

Хмммм

Может проблема всё же во мне? А я её не вижу?
Нужно найти готовый пример и проверить.

Facebook

173.86ms

+ Breakpoint

F5 / Патчим ReactDOM.render

			var DOM = c("ReactDOM-upstream");
			var originalRender = DOM.render;
			DOM.render = function perfRender(data, el) {
			   console.time(el.className);
			   var retVal = originalRender.apply(this, arguments);
			   console.timeEnd(el.className);
			   return retVal;
			};
		

Проверяем

Сколько сообщений в списке?

			const conversation = document.querySelector(".conversation")
			const items = conversation.firstChild.firstChild.children;
			console.log(items.length);
		

Измеряем

Выводы

ЭТО НОРМА!

На самом деле нет!

Как же всё таки полчить честные цифры?

ReactFeatureFlags

ReactFeatureFlags

			import ReactFeatureFlags from "react/lib/ReactFeatureFlags";
			#! ReactFeatureFlags.logTopLevelRenders = true;
		

ReactFeatureFlags

Проверяем / Переход между папок

			console.time("click");
			document.querySelectorAll(".nav a")[0].click();
			console.timeEnd("click");
			#!+ // React update: Router: **348.028ms**
			// React update: Connect(FetchDataDecorator): 0.007ms
			#!- // React update: Connect(Letters): 0.002ms
			#! // click: **362.845ms**
			#!+ // React update: Connect(FetchDataDecorator): 144.643ms
			#!- // React update: Connect(Letters): 135.463ms
		

Проверяем / Выбор одного письма

			console.time("click");
			document.querySelector(".dataset__avatar").click();
			console.timeEnd("click");
			// React update: Connect(FetchDataDecorator): 0.229ms
			// React update: Connect(ProtalMenu): 3.695ms
			// React update: Connect(Letters): 162.009ms
			// click: 170.524ms
		

Проверяем / Выбор всех писем

			console.time("click");
			document.querySelector(".ico_toolbar_select-all").click();
			console.timeEnd("click");
			// React update: Connect(FetchDataDecorator): 0.201ms
			// React update: Connect(ProtalMenu): 3.547ms
			// React update: Connect(Letters): 170.261ms
			// click: 179.362ms
		

Оптимизация

Оптимизация

			// Это метод вызывает при получении новых
			// данных и в этот момент можно решить,
			// обновлять или нет компонент.
			shouldComponentUpdate: function (nextProps, nextState) {
				return true; // по умолчанию
			}
		

Оптимизируем выбор письма

			// components/LettersItems.js
			shouldComponentUpdate(nextProps) {
				return (
				#!	   (this.props.model !== nextProps.model)
				#!	|| (this.props.selected !== nextProps.selected)
				);
			}
		

Оптимизируем выбор письма

			console.time("click");
			document.querySelector(".dataset__avatar").click();
			console.timeEnd("click");
			// React update: Connect(FetchDataDecorator): 0.260ms
			// React update: Connect(ProtalMenu): 3.329ms
			// React update: Connect(Letters): 11.527ms
			// click: 15.730ms
		

Оптимизируем

Уже лучше, но писать везде shouldComponentUpdate с однообразным кодом не очень то хочется, нужна универсальная реализация.

@immutableComponent

@immutableComponent

			import React, {Component} from "react";
			import shallowEqual from "react-redux/lib/utils/shallowEqual";
			export default function immutableComponent(propKeys) {
				return (DecoratedComponent) => class ImmutableComponentDecorator extends Component {
					shouldComponentUpdate(nextProps) {
						if (propKeys) {
							return propKeys.some(name => this.props[name] !== nextProps[name]);
						} else {
							return !shallowEqual(this.props, nextProps);
						}
					}
					render() {
						return <DecoratedComponent {...this.props} />;
					}
				};
			};
		

@immutableComponent / v0.6.0 / DIFF

			import immutableComponent from "../decorators/immutableComponent";

			@immutableComponent(["model", "selected"])
			export default class LettersItems extends Component {
				// ...
			}
		

Что делать c
545.481ms

orgsync/react-list

orgsync/react-list

			// Letters.js
			<ReactList
				itemsRenderer={(items, ref) => <div ref={ref} className="dataset__items">{items}</div>}
				itemRenderer={(idx, key) => {
					const model = models[idx];
					return <LettersItem
						key={key}
						model={model}
						selected={selection[model.id]}
						onToggleSelect={(evt) => this.handleToggleSelect(evt, model)}
					/>
				}}
				length={models.length}
				**type="simple"** // просто добавляет элементы
			  />
		

Замеряем / v0.7.0 / DIFF

			console.time("click");
			document.querySelectorAll(".nav__item")[1].click();
			console.timeEnd("click");
			// React update: Router: 63.051ms
			// React update: Connect(FetchDataDecorator): 0.003ms
			// React update: Connect(Letters): 0.003ms
			// React update: ReactList: 0.004ms
			// click: 75.183ms
		

Итоги

Итоги

Сложно сказать, вы сами видели все мои шаги, код очень просто, но он тормозит, оптимизации которые были сделаны влияют только на статичную страницу, когда не происходит смена «Папок».

ReactList частично решил проблему, но в режиме simple, он только добавляет элементы, поэтому по мере подгрзуке писем (при прокрутке), производительность будет стремительно падать.

Конечно у ReactList есть другой режим, который позволяет держать только те элементы, которые умещаются в viewport. Это исправляет ситуацию, но порождает другую, наверно даже более серьезную, прокрутка такого списка начинает тормозить.

Как у нас?

Подумать над Store

			auth: object // авторизация
			folders: [object] // список папок
			threads:
				index: // индекс по всем тредам
					[id]
						...attrs // основные аттрибуты
						messages: [@message]
				active: {[folder_id]: [@thread]} // массив ссылок
			messages:
				index: // индекс по всем письмам
					[id]
						...attrs // основные аттрибуты
						attachments: [@attachment]
				active: {[folder_id]: [@message]} // если выключены треды
			attachments: {[id]: object} // индекс аттачей
			selection: // Выбор по папкам
				[folder_id]
					all: boolean, // выбраны все в папке
					models: {[id]: boolean} // тред
		

The End