Третий год наблюдаю за развитием React и его окружением, которое меняет со скоростью света. Ещё тогда пробовал писать свои тесты на производительность и они не впечатляли. Время шло, но хайп только усиливался, вскоре появился Redux, который вывел React в бесспорные лидеры.
Неделю назад, я подумал, а почему бы мне собрать текущий проект на Redux и проверить, насколько лучше стало.
Вселенная React/Redux гипер изменчива, поэтому любая документации как запустить всё это (с использованием того же Webpack), статьи и скринкасты устаревают на глазах, а то, что месяц назад было best practicts, вполне возможно уже не так.
Поэтому, чтобы окончательно не запутаться и не терять время (а поверьте, часа 4 были безвозвратно потеряны, в попытках с нуля поднять инфраструктуру) решил использовать один из популярных Redux Boilerplate.
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>; } #!- };
{ 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, #!- });
#!+{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") #!- );
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>; #!- } #!- }
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} // какая-то полезная нагрузка #!- });
#! 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() { // ... } #!- }
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... но это простая функция, кот орая возвращает объект?! Как быть?
import {LOGIN_REQUEST} from "../constants/auth"; export const login = (email, password) => ({ type: LOGIN_REQUEST, // обязательное поле payload: {email, password} // какая-то полезная нагрузка });
export default function logger(store) { return function wrapDispatchToAddLogging(next) => { return function dispatchAndLog(action) => { console.log(action); return next(action); }; }; };
export default ({dispatch, getState}) => next => action => { if (typeof action === "function") { return action(dispatch, getState); } return next(action); };
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
export const login = (email, password) => ({ api: USER_LOGIN, // вызываемый метод API types: [LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAIL], data: {email, password}, });
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}); #!- }); };
15 файлов
Получившаяся версия только авторизует и получает в ответ токен, поэтому нам нужно доработать действие 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";
+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; } };
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)); #!- } #!- };
@connect( state => state ) export default class App extends Component { render() { const {auth} = this.props; return !auth.state ? <AuthForm/> : <h1>Привет, {auth.email}</h1>; } }
{ auth: {..}, folders: [{folder}, ..], // reducers/folders.js threads: { // reducers/threads.js current: { // текущий список тредов по папки folder_id: [{thread}, ..], }, }, }
В Почте мы не используем отдельный «конца» для получения списка папок и тредов, это не эффективно. Для этого используется threads/status который сразу возвращает треды и папки, оба редьюсера будут реагировать на действие THREADS_STATUS_SUCCESS.
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; } };
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; } };
@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/>; #!- } } }
Или точнее как вызвать действие? На какое событие?
@connect(...) @fetchData( // Будет вызывать действия при измениях маршрута и свойств #! ({auth: email}, {folder}) => ({email, folder}), #!+ ({email, folder}, actions) #!- => email && actions.fetchStatus(folder) ) export default class App extends Component { // ... }
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} />; } #!- }; };
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 { // ... }
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; #!- } } #!- } }
Основная проблема, что при переходе между Папками, сначала список тредов пустой и только после ответа сервера он обновляется. Надо сделать так, чтобы переключение папки и обновление списка происходило только, если есть данные.
#!+ 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; } #!- // ... } #!- }
* — для 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");
Может проблема всё же во мне? А я её не вижу?
Нужно найти готовый пример и проверить.
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);
import ReactFeatureFlags from "react/lib/ReactFeatureFlags"; #! ReactFeatureFlags.logTopLevelRenders = true;
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 с однообразным кодом не очень то хочется, нужна универсальная реализация.
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} />; } }; };
// 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"** // просто добавляет элементы />
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. Это исправляет ситуацию, но порождает другую,
наверно даже более серьезную, прокрутка такого списка начинает тормозить.
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} // тред