Пожалуйста, авторизуйтесь


Это третья и заключительная часть статьи про разработку изоморфного React.js приложения с нуля. Части первая и вторая.


В этой части мы:


  • добавим redux-dev-tools;
  • добавим запросы к API;
  • реализуем авторизацию;
  • реализуем выполнение запросов к API в процессе Server-Side Rendering.

1. Добавляем redux-dev-tools


Это очень удобная библиотека, упрощающая процесс разработки. С ее помощью вы сможете в режиме реального времени видеть содержимое глобального состояния, а также его изменения. Дополнительно redux-dev-tools позволяет "откатывать" последние изменения глобального состояния, что удобно в процессе тестирования и отладки. Нам же она добавит наглядности и сделает процесс обучения более интерактивным и прозрачным.


1.1. Устанавливаем необходимые пакеты


npm i --save-dev redux-devtools redux-devtools-log-monitor redux-devtools-dock-monitor

1.2. Реализуем компонент, отвечающий за рендеринг панели redux-dev-tools


src/DevTools/DevTools.jsx


import React from 'react';
import { createDevTools } from 'redux-devtools';
import LogMonitor from 'redux-devtools-log-monitor';
import DockMonitor from 'redux-devtools-dock-monitor';

export default createDevTools(
  <DockMonitor toggleVisibilityKey='ctrl-h' changePositionKey='ctrl-q'>
    <LogMonitor />
  </DockMonitor>
);

src/DevTools/index.js


import DevTools from './DevTools';

export default DevTools;

1.3. Добавим панель redux-dev-tools в наше приложение


Наилучшее место для этого — компонент App.jsx. Он корневой, поэтому панель будет доступна на каждой странице нашего приложения.


Заметим, что панель нам понадобится только в процессе разработки, и мы не хотим, чтобы она была видна конечным пользователям или попала в сборку клиентского JavaScript.


  1. Переименуем App.jsx –> App.prod.jsx
  2. Создадим App.dev.jsx, который будет состоять из App.prod.jsx и панели redux-dev-tools
  3. Создадим новый App.js, который в зависимости от системного ландшафта будет рендерить либо App.prod.jsx, либо App.dev.jsx.

mv src/components/App.jsx src/components/App.prod.jsx

src/components/App/App.dev.jsx


import React, { Component, PropTypes } from 'react';
import AppProd from './App.prod';
import DevTools from '../DevTools';

const propTypes = {
  children: PropTypes.node
};

class App extends Component {
  render() {
    return (
      <AppProd>
        <div>
          {this.props.children}
          <DevTools />
        </div>
      </AppProd>
    );
  }
}

App.propTypes = propTypes;

export default App;

src/App.js


if (process.env.NODE_ENV === 'production') {
  module.exports = require('./App.prod');
} else {
  module.exports = require('./App.dev');
}

1.4. "Причешем" rootReducer


Во второй части мы поместили создание корневого редьюсера в configureStore, что не совсем правильно, так как это не его зона ответственности. Сделаем небольшой рефакторинг и перенесем его в redux/reducers/index.js.


redux/reducers/index.js


import { combineReducers } from 'redux';
import counterReducer from './counterReducer';

export default combineReducers({
  counter: counterReducer
});

Из документации redux-dev-tools следует, что нам необходимо внести изменения в configureStore. Вспомним, что инструменты redux-dev-tools нам нужны только для разработки, поэтому повторим маневр, описанный ранее:


  1. переименуем configureStore.js в configureStore.prod.js;
  2. реализуем configureStore.dev.js;
  3. реализуем configureStore.js, который в зависимости от системного ландшафта использует либо configureStore.prod.js, либо configureStore.dev.js.

mv redux/configureStore.js redux/configureStore.prod.js

src/redux/configureStore.prod.js


import { applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';

export default function (initialState = {}) {
  return createStore(rootReducer, initialState, applyMiddleware(thunk));
}

Реализация configureStore.dev.js с DevTools и поддержкой hot-reload.


src/redux/configureStore.dev.js


import { applyMiddleware, createStore, compose } from 'redux';
import thunk from 'redux-thunk';
import DevTools from 'components/DevTools';
import rootReducer from './reducers';

export default function (initialState = {}) {
  const store = createStore(rootReducer, initialState, compose(
      applyMiddleware(thunk),
      DevTools.instrument()
    )
  );

  if (module.hot) {
    module.hot.accept('./reducers', () =>
      store.replaceReducer(require('./reducers').default)
    );
  }

  return store;
}

Точка входа configureStore


src/redux/configureStore.js


if (process.env.NODE_ENV === 'production') {
  module.exports = require('./configureStore.prod');
} else {
  module.exports = require('./configureStore.dev');
}

Все готово! Открываем браузер и видим, что справа появилась панель, которая отражает содержимое глобального состояния. Теперь откроем страницу со счетчиками и понажимаем на ReduxCounter. Одновременно с каждым кликом мы видим, как в очередь redux поступают действия и глобальное состояние изменяется. Нажав на Revert, мы сможем отменить последнее действие, а нажав на Commit — утвердить все действия и очистить текущую очередь команд.


Примечание: после добавления redux-dev-tools, возможно, вы увидите сообщение в консоли: "React attempted to reuse markup in a container but the checksum was invalid...". Это означает, что серверная и клиентская часть приложения рендерят неодинаковый контент. Это очень плохо, и в своих приложениях таких ситуаций следует избегать. Однако, в данном случае виновником является redux-dev-tools, который мы все равно в продуктиве использовать не будем, поэтому можно сделать исключение и спокойно проигнорировать сообщение о проблеме.


2. Добавляем новую функциональность


Реализуем следующий сценарий


  1. Пользователь нажимает на кнопку "Запросить время".
  2. Показываем индикатор загрузки, кнопка становится неактивной, чтобы избежать нежелательных повторных запросов.
  3. Приложение делает запрос к API.
  4. Приложение получает ответ от API и сохраняет полученные данные в глобальное состояние.
  5. Индикатор загрузки исчезает, кнопка снова становится активной; отображаем пользователю полученные данные.

Это достаточно объемная задача. Чтобы сфокусироваться на отдельных ее частях, сначала реализуем пункты 1,2 и 5, а для 3 и 4 сделаем заглушку.


2.1. Добавляем действия


После клика по кнопке "Запросить время" мы должны последовательно:


  1. изменить значение loading с false на true;
  2. сделать запрос;
  3. получив ответ, вернуть значение loading с true на false обратно и сохранить либо полученные данные, либо информацию об ошибках.

export const TIME_REQUEST_STARTED = 'TIME_REQUEST_STARTED';
export const TIME_REQUEST_FINISHED = 'TIME_REQUEST_FINISHED';
export const TIME_REQUEST_ERROR = 'TIME_REQUEST_ERROR';

function timeRequestStarted() {
  return { type: TIME_REQUEST_STARTED };
}

function timeRequestFinished(time) {
  return { type: TIME_REQUEST_FINISHED, time };
}

function timeRequestError(errors) {
  return { type: TIME_REQUEST_ERROR, errors };
}

export function timeRequest() {
  return (dispatch) => {
    dispatch(timeRequestStarted());

    return setTimeout(() => dispatch(timeRequestFinished(Date.now()), 1000)); // Изображаем network latency :)
  };
}

Здесь каждое действие мы оформляем в виде небольшой функции, которая будет изменять глобальное состояние, а timeRequest — комбинация этих функций, которая целиком описывает наш сценарий. Именно ее мы и будем вызывать из нашего компонента.


2.2. Обновляем код страницы со временем


Добавим кнопку react-bootstrap-button-loader с поддержкой индикатора загрузки на страницу TimePage и научим ее вызывать функцию timeRequest по клику.


Устанавливаем пакет react-bootstrap-button-loader


npm i --save react-bootstrap-button-loader

Добавляем кнопку и обработчик кликов


src/components/TimePage/TimePage.jsx


import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import PageHeader from 'react-bootstrap/lib/PageHeader';
import Button from 'react-bootstrap-button-loader';
import { timeRequest } from 'redux/actions/timeActions';

const propTypes = {
  dispatch: PropTypes.func.isRequired
};

class TimePage extends Component {
  constructor() {
    super();

    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.props.dispatch(timeRequest());
  }

  render() {
    return (
      <div>
        <PageHeader>Timestamp</PageHeader>
        <Button onClick={this.handleClick}>Запросить!</Button>
      </div>
    );
  }
}

TimePage.propTypes = propTypes;

export default connect()(TimePage);

Заметим, что нам пришлось использовать connect из react-redux, чтобы у нашей кнопки был доступ к функции dispatch для изменения глобального состояния.


Самое время посмотреть на результаты трудов: откроем страницу "Время" в браузере, нажмем на кнопку "Запросить". Интерфейс пока еще ничего не делает, но в redux-dev-tools мы теперь видим, как запускаются actions, которые мы совсем недавно реализовали.


Настало время оживить интерфейс. Начнем с реализации логики для обновления глобального состояния


2.3. Реализуем редьюсер


src/redux/reducers/timerReducer.js


import { TIME_REQUEST_STARTED, TIME_REQUEST_FINISHED, TIME_REQUEST_ERROR } from 'redux/actions/timeActions';

const initialState = {
  time: null,
  errors: null,
  loading: false
};

export default function (state = initialState, action) {
  switch (action.type) {
    case TIME_REQUEST_STARTED:
      return Object.assign({}, state, { loading: true, errors: null });
    case TIME_REQUEST_FINISHED:
      return {
        loading: false,
        errors: null,
        time: action.time
      };
    case TIME_REQUEST_ERROR:
      return Object.assign({}, state, { loading: false, errors: action.errors });
    default:
      return state;
  }
}

Важный момент, о котором нельзя забывать: по спецификации redux мы не имеем права изменять переданное нам состояние и обязаны возвращать либо его же, либо новый объект. Для формирования нового объекта я использую Object.assign, который берет исходный объект и применяет к нему нужные мне изменения.


Хорошо, теперь добавим новый редьюсер в корневой редьюсер.


src/redux/reducers/index.js


+++ import timeReducer from './timeReducer';

export default combineReducers({
  counter: counterReducer,
+++  time: timeReducer
});

Снова откроем браузер и, предварительно очистив очередь redux-dev-tools, покликаем по кнопке "Запросить". Интерфейс все еще не обновляется, но теперь наши actions изменяют глобальное состояние согласно коду нашего редьюсера, а это значит, что "под капотом" вся логика работает как надо. Дело за малым — "оживим" интерфейс.


2.4. Обновляем код страницы "Время"


src/components/TimePage/TimePage.jsx


const propTypes = {
  dispatch: PropTypes.func.isRequired,
+++  loading: PropTypes.bool.isRequired,
+++  time: PropTypes.any
};

class TimePage extends Component {
...
  render() {
+++    const { loading, time } = this.props;
...
---        <Button onClick={this.handleClick}>Запросить!</Button>
+++        <Button loading={loading} onClick={this.handleClick}>Запросить!</Button>
+++        {time && <div>Time: {time}</div>}
      </div>
    );
  }
}

+++ function mapStateToProps(state) {
+++  const { loading, time } = state.time;

+++  return { loading, time };
+++ }

--- export default connect()(TimePage);
+++ export default connect(mapStateToProps)(TimePage);

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


Настало время заменить заглушку на настощий backend.


3. Добавляем взаимодействие с backend и авторизацию


Примечание: для этого примера я использую очень простой backend, разработанный мной на rails. Он доступен по ссылке https://redux-oauth-backend.herokuapp.com и содержит только один метод /test/test, возвращающий серверный timestamp, если пользователь авторизован, иначе — 401 ошибку. Исходный код backend'а можно найти тут: https://github.com/yury-dymov/redux-oauth-backend-demo. Там я использую gem devise для авторизации, который де-факто является стандартом для решения подобных задач для rails и gem devise_token_auth, добавляющий devise механизм авторизации Bearer Token-based Authentication. В наши дни этот механизм чаще всего используется при разработке защищенных API.


Со стороны клиентской части нам многое предстоит сделать:


  1. С предыдущей статьи у меня остался небольшой долг: глобальное состояние после Server Side Rendering не передается и не используется клиентом. Мы сейчас это исправим.
  2. Добавим в проект библиотеку redux-oauth, которая отвечает за авторизацию со стороны frontend, и настроим ее для изоморфного сценария.
  3. Заменим заглушку на код, который будет в действительности выполнять запросы к API.
  4. Добавим кнопки "Войти в систему" и "Выйти из системы".

3.1. Передаем глобальное состояние


Механизм очень простой:


  1. После того, как сервер выполнил всю работу и сформировал контент для клиента, мы вызываем функцию getState, которая возвращает актуальное глобальное состояние. Далее мы передаем контент и глобальное состояние в наш HTML-шаблон и отдаем полученную страницу клиенту.
  2. Клиентский JavaScript считывает глобальное состояние прямо из глобального объекта window и передает его в configureStore в качестве initialState.

src/server.js


+++ const state = store.getState();

--- return res.end(renderHTML(componentHTML));
+++ return res.end(renderHTML(componentHTML, state));

...

--- function renderHTML(componentHTML, initialState) {
+++ function renderHTML(componentHTML, initialState) {
          <link rel="stylesheet" href="${assetUrl}/public/assets/styles.css">
+++       <script type="application/javascript">
+++         window.REDUX_INITIAL_STATE = ${JSON.stringify(initialState)};
+++       </script>
      </head>

src/client.js


+++ const initialState = window.REDUX_INITIAL_STATE || {};

--- const store = configureStore();
+++ const store = configureStore(initialState);

Как видно из кода, глобальное состояние я передаю в переменной REDUX_INITIAL_STATE.


3.2. Добавляем авторизацию


Устанавливаем redux-oauth


Примечание: мы используем redux-oauth для изоморфного сценария, но она также поддерживает и client-side only. Примеры конфигурации для различных случаев и демо можно найти на сайте библиотеки.


Примечание 2: redux-oauth использует cookie для авторизации, так как механизм local storage не подходит для изоморфного сценария.


npm i --save redux-oauth cookie-parser

Активируем плагин cookieParser для express


src/server.js


+++ import cookieParser from 'cookie-parser';

    const app = express();

+++ app.use(cookieParser());

Настраиваем redux-oauth для серверной части приложения


src/server.js


+++ import { getHeaders, initialize } from 'redux-oauth';

app.use((req, res) => {
  const store = configureStore();

+++  store.dispatch(initialize({
+++    backend: {
+++      apiUrl: 'https://redux-oauth-backend.herokuapp.com',
+++      authProviderPaths: {
+++        github: '/auth/github'
+++      },
+++      signOutPath:  null
+++    }
+++    currentLocation: req.url,
+++    cookies: req.cookies    
  })).then(() => match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
...
    const state = store.getState();

+++    res.cookie('authHeaders', JSON.stringify(getHeaders(state)), { maxAge: Date.now() + 14 * 24 * 3600 * 1000 });
    return res.end(renderHTML(componentHTML, state));
  }));

Здесь происходит много интересного:


  1. Мы должны вызвать функцию initialize из redux-oauth, которой передадим текущий URL, cookies и конфигурацию: адрес API и используемые OAuth-провайдеры.
  2. Если в переданных cookie будет найден авторизационный token, то библиотека проверит его валидность у backend и в случае успеха сохранит информацию о пользователе в глобальном состоянии. Обратите внимание, что дальнейший код приложения выполнится только после того, как отработает initialize.
  3. Перед тем, как отправить HTML клиенту, мы используем метод res.cookie. Этот метод сообщает express, что к HTTP-ответу необходимо добавить заголовок SetCookie, в котором нужно передать обновленный авторизационный токен. Это очень важный шаг: новый авторизационный токен сохранится в cookie браузера сразу же после того, как он получит ответ от сервера. Тем самым мы гарантируем, что авторизация не сломается даже в случаях, когда клиентский JavaScript не успел скачаться, инициализироваться или выполнился с ошибкой.

Согласно документации, нам также необходимо добавить редьюсер redux-oauth в корневой редьюсер.


src/redux/reducers/index.js


+++ import { authStateReducer } from 'redux-oauth';

export default combineReducers({
+++  auth: authStateReducer,

3.3. Заменяем заглушку в timeActions.js


src/redux/actions/timeActions.js


import { fetch, parseResponse } from 'redux-oauth';

export function timeRequest() {
  return (dispatch) => {
    dispatch(timeRequestStarted());

---    return setTimeout(() => dispatch(timeRequestFinished(Date.now()), 1000)); // Изображаем network latency :)
+++    return dispatch(fetch('https://redux-oauth-backend.herokuapp.com/test/test'))
+++      .then(parseResponse)
+++      .then(({ payload }) => dispatch(timeRequestFinished(payload.time)))
+++      .catch(({ errors }) => dispatch(timeRequestError(errors)));

  };
}

Функция fetch из redux-oauth — это расширенная функция из пакета isomorphic-fetch. Согласно документации, ее необходимо вызывать через dispatch, так как в этом случае у нее будет доступ к глобальному состоянию, из которого она сможет считать авторизационный токен и отправить его вместе с запросом. Если функцию fetch использовать для произвольного HTTP-запроса, а не запроса к API, то авторизационный токен использован не будет, то есть алгоритм ее выполнения на 100% совпадет с алгоритмом выполнения isomorphic-fetch.


Примечание: isomorphic-fetch — это библиотека, которая умеет делать HTTP-запросы как из браузера, так и из Node окружения.


Откроем браузер и еще раз нажмем на кнопку "Запросить" страницы "Время". Что ж, мы больше не видим текущий timestamp, зато в redux-dev-tools появилась информация о 401 ошибке. Неудивительно, ведь мы должны быть авторизованы, чтобы API нам что-то вернул.


3.4. Добавим кнопки "Войти" и "Выйти"


Как правило, авторизованный пользователь имеет больше возможностей по работе с системой, чем гость, иначе какой же смысл в авторизации?


С технической точки зрения это означает, что многие компоненты могут выглядеть и вести себя по-разному в зависимости от того, зашел пользователь в систему или нет.


Я являюсь ярым сторонником принципа DRY (don't repeat yourself), поэтому напишем небольшой хелпер.


src/redux/models/user.js


export function isUserSignedIn(state) {
  return state.auth.getIn(['user', 'isSignedIn']);
}

Реализуем кнопку "Войти в систему"


src/components/AuthButtons/OAuthButton.jsx


import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { oAuthSignIn } from 'redux-oauth';
import Button from 'react-bootstrap-button-loader';
import { isUserSignedIn } from 'redux/models/user';

const propTypes = {
  dispatch: PropTypes.func.isRequired,
  loading: PropTypes.bool.isRequired,
  provider: PropTypes.string.isRequired,
  userSignedIn: PropTypes.bool.isRequired
};

class OAuthButton extends Component {
  constructor(props) {
    super(props);

    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    const { dispatch, provider } = this.props;

    dispatch(oAuthSignIn({ provider }));
  }

  render() {
    const { loading, provider, userSignedIn } = this.props;

    if (userSignedIn) {
      return null;
    }

    return <Button loading={loading} onClick={this.handleClick}>{provider}</Button>;
  }
}

OAuthButton.propTypes = propTypes;

function mapStateToProps(state, ownProps) {
  const loading = state.auth.getIn(['oAuthSignIn', ownProps.provider, 'loading']) || false;

  return { userSignedIn: isUserSignedIn(state), loading };
}

export default connect(mapStateToProps)(OAuthButton);

Эта кнопка будет отображаться, только если пользователь еще не вошел в систему.


Реализуем кнопку "Выйти из системы"


src/components/AuthButtons/SignOutButton.jsx


import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { signOut } from 'redux-oauth';
import Button from 'react-bootstrap-button-loader';
import { isUserSignedIn } from 'redux/models/user';

const propTypes = {
  dispatch: PropTypes.func.isRequired,
  userSignedIn: PropTypes.bool.isRequired
};

class SignOutButton extends Component {
  constructor(props) {
    super(props);

    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    const { dispatch } = this.props;

    dispatch(signOut());
  }

  render() {
    if (!this.props.userSignedIn) {
      return null;
    }

    return <Button onClick={this.handleClick}>Выйти</Button>;
  }
}

SignOutButton.propTypes = propTypes;

function mapStateToProps(state) {
  return { userSignedIn: isUserSignedIn(state) };
}

export default connect(mapStateToProps)(SignOutButton);

Эта кнопка будет отображаться, только если пользователь уже вошел в систему.


src/components/AuthButtons/index.js


import OAuthButton from './OAuthButton';
import SignOutButton from './SignOutButton';

export { OAuthButton, SignOutButton };

Я добавлю авторизацию на страницу HelloWorldPage.


src/components/HelloWorldPage/HelloWorldPage.jsx



+++ import { OAuthButton, SignOutButton } from 'components/AuthButtons';

+++ <h2>Авторизация</h2>
+++ <OAuthButton provider='github' />
+++ <SignOutButton />

Настало время насладиться результатами нашего труда. Нажимаем на кнопку "Войти", используем свой github аккаунт для авторизации и… мы в системе! Кнопка "Войти" исчезла, зато появилась кнопка "Выйти". Проверим, что сессия сохраняется, для этого перезагрузим страницу. Кнопка "Выйти" не исчезла, а в redux-dev-tools можно найти информацию о пользователе. Отлично! Пока все работает. Переходим на страницу "Время", нажимаем на кнопку "Запросить" и видим, что timestamp отобразился — это сервер вернул нам данные.


На этом можно было бы закончить, но нам нужно "отшлифовать" наше приложение.


4. "Шлифуем" приложение


Итак, что можно улучшить:


  1. Ссылки на страницу "Время" должны отображаться только для авторизованных пользователей.
  2. Если пользователь ввел адрес защищенной страницы в браузере, мы перенаправим его на страницу с авторизацией (в нашем случае — HelloWorldPage).
  3. Если пользователь вышел из системы, мы должны удалить из глобального состояния его данные.

4.1. Убираем ссылки к недоступным страницам


src/components/App/App.prod.jsx


+++ import { connect } from 'react-redux';
+++ import { isUserSignedIn } from 'redux/models/user';

const propTypes = {
+++ userSignedIn: PropTypes.bool.isRequired,
...
};

...

+++ {this.props.userSignedIn && (
<LinkContainer to='/time'>
  <NavItem>Время</NavItem>
</LinkContainer>
+++ )}

...

+++ function mapStateToProps(state) {
+++  return { userSignedIn: isUserSignedIn(state) };
+++ }

--- export default App;
+++ export default connect(mapStateToProps)(App);

Открываем браузер и видим, что ссылка на страницу "Время" все еще доступна, переходим на страницу HelloWorldPage, нажимаем на кнопку "Выйти" — и ссылка пропала.


4.2. Ограничиваем доступ к защищенным страницам


Как мы помним, за соответствие между URL и страницей, которую нужно отрендерить отвечает библиотека react-router, а конфигурация путей находится в файле routes.jsx. Нам нужно добавить следующую логику: если пользователь неавторизован и запросил защищенную страницу, то перенаправим его на HelloWorldPage.


Для получения информации о пользователе нам необходимо передать в routes.jsx ссылку на хранилище глобального состояния.


src/server.js


--- .then(() => match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
+++ .then(() => match({ routes: routes(store), location: req.url }, (error, redirectLocation, renderProps) => {

src/client.js


<Router history={browserHistory}>
---  {routes}
+++  {routes(store)}
</Router>

src/routes.jsx


import { isUserSignedIn } from 'redux/models/user';

function requireAuth(nextState, transition, cb) {
  setTimeout(() => {
    if (!isUserSignedIn(store.getState())) {
      transition('/');
    }

    cb();
  }, 0);
}

let store;

export default function routes(storeRef) {
  store = storeRef;

  return (
    <Route component={App} path='/'>
      <IndexRoute component={HelloWorldPage} />
      <Route component={CounterPage} path='counters' />
      <Route component={TimePage} path='time' onEnter={requireAuth} />
    </Route>    
  );
}

Тестируем:


  1. Убедимся, что мы залогинены;
  2. Введем в адресную строку браузера http://localhost:3001/time, нажмем "Enter" и ожидаемо увидим страницу "Время";
  3. Выйдем из системы;
  4. Еще раз введем в адресную строку браузера http://localhost:3001/time и нажмем "Enter" — на этот раз нас перенаправили на страницу "HelloWorldPage" — все работает!

Примечание: в функции requireAuth используется setTimeout с нулевой задержкой, что на первый взгляд лишено смысла. Это сделано специально, так как позволяет обойти баг в одном из популярных браузеров.


4.3. Очищаем пользовательские данные из глобального состояния


src/redux/reducers/timeReducer.js


+++ import { SIGN_OUT } from 'redux-oauth';

+++ case SIGN_OUT:
+++  return initialState;
default:
  return state;

Если поступит action SIGN_OUT, то все данные редьюсера timeReducer будут заменены на initialState, то есть на значения по умолчанию. Этот же прием необходимо реализовать для всех других редьюсеров, которые содержат пользовательские данные.


5. Бонус: Server-Side API Requests


Библиотека redux-oauth поддерживает Server Side API requests, то есть в процессе рендеринга сервер может сам обратиться к API за данными. Это имеет множество преимуществ:


  • сервер находится гораздо ближе к API, а значит пользователь получит доступ к контенту быстрее;
  • для некачественного мобильного интернета уменьшение количества запросов имеет решающее значение в вопросах производительности из-за большого latency каждого запроса;
  • поисковики не умеют или плохо умеют JavaScript, а значит иначе они не получат доступ к полноценному контенту.

Примечание: да, поисковики не будут авторизовываться, но некоторые сервисы API смогут возвращать данные и для неавторизованных пользователей с некоторыми ограничениями. redux-oauth подойдет и для таких сценариев.


Реализуем небольшой Proof of Concept.


Добавим запрос к API в серверную часть нашего приложения


src/server.js


+++ import { timeRequest } from './redux/actions/timeActions';

...

return store.dispatch(initialize({
    backend: {
      apiUrl: 'https://redux-oauth-backend.herokuapp.com',
      authProviderPaths: {
        github: '/auth/github'
      },
      signOutPath: null
    },
    cookies: req.cookies,
    currentLocation: req.url,
  }))
+++  .then(() => store.dispatch(timeRequest()))
  .then(() => match({ routes: routes(store), location: req.url }, (error, redirectLocation, renderProps) => {

После того, как функция initialize из redux-oauth обратится к backend, проверит авторизационный токен и получит данные о пользователе, мы выполним запрос timeRequest на стороне сервера. После его выполнения мы отрендерим контент и отдадим ответ пользователю.


Откроем браузер, авторизуемся при необходимости, перейдем на страницу "Время" и нажмем F5. Мы должны увидеть timestamp, хотя кнопку "Запросить" никто не нажимал. Если открыть Dev Tools браузера, вкладку Network и повторить эксперимент, то мы увидим, что запроса к API из клиента не было. Это подтверждает, что вся работа была сделана на стороне сервера.


Внесем последнее небольшое улучшение в наш проект: будем делать запрос к API только в том случае, если пользователь авторизован.


src/redux/actions/timeActions.js


--- return (dispatch) => {
+++ return (dispatch, getState) => {
+++  if (!isUserSignedIn(getState())) {
+++    return Promise.resolve();
+++  }

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


6. Вместо заключения


Вот и подошел к концу цикл статей о веб-приложении на React.js с нуля. Искренне надеюсь, что он был вам полезен!


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


Ссылка на проект на github — https://github.com/yury-dymov/habr-app/tree/v3


P.s. Если в тексте присутствуют ошибки или неточности, пожалуйста, напишите мне сначала в личные сообщения. Заранее спасибо!

Поделиться с друзьями
-->

Комментарии (26)


  1. G-M-A-X
    28.09.2016 17:25

    Задаю вопрос повторно:
    Объясните, пожалуйста, почему в последнее время стало модно говорить, что DOM тормозит?
    Что вы такого с ним делаете? :)

    А всякие AngularJS и ReactJS, якобы борясь с DOM тормозами, сами тормозят еще больше :)

    Вся эта возня — псевдопрограммирование, когда вместо решения текущей задачи занимаются решением большего количества проблем от ее псевдорешения.


    1. yury-dymov
      28.09.2016 19:51

      Я, пожалуй, уклонюсь от дискуссии с Вами в этот раз. Вы приглашаете меня попытаться "продать" Вам то, что Вам не нужно, чтобы красиво мне сообщить, что оно Вам в общем-то и не нужно :)


      И я, если честно, прекрасно Вас понимаю. Позвольте предположить, что Вы участвуете в разработке веба, используете привычный Вам стек и всем довольны: код работает, он аккуратный и легок в поддержке, — все хорошо, птички поют, а жизнь прекрасна. На хабре тем временем выходят одна статья за другой, где люди делают примерно тоже самое, что и Вы только сложнее, запутаннее, а главное Вы искренне не понимаете, зачем все это, может быть им всем просто скучно?


      Если я не угадал, и этот портрет на Вас не очень похож, то я все же продолжу — ведь это я некоторое время назад. Я смотрел RailsCasts с примерами на Backbone, Angular.JS и т.п. и не понимал, зачем мне дублировать модели в backend и JS, зачем иметь два роутинга, ну и так далее. Если честно, я вообще 5 из 6 лет развития Node.js воспринимал этот стек очень скептически — нет, ну скучно людям, не хотят они кроме JS ничего учить, ладно, но мне тогда это все зачем?


      А потом в одном проекте у меня "заболело". Слишком много интерактивности и динамики в интерфейсе — JS код на базе jQuery было очень страшно открывать — не то, что править. И эта не проблема меня, как плохого программиста, просто я использовал не тот инструмент. jQuery и прямая работа с DOM'ом подходит не всем проектам.


      Мораль истории такова: если у Вас "не болит" и все хорошо — Вам оно не надо. Но если так случится, что заболит, то надеюсь, что мой цикл окажется Вам полезным. Я сами на практике далеко не везде использую React и Node.js стек, но там где использую — это действительно оправдано


      1. G-M-A-X
        28.09.2016 22:45

        Возможно оно мне и не нужно.

        Я просто не понимаю, где же улучшение, а особенно скорости, если все наоборот. :)

        >Слишком много интерактивности и динамики в интерфейсе — JS код на базе jQuery было очень страшно открывать — не то, что править.

        Это ж не проблема медленного DOM :)
        Нужно было просто отрефакторить код на jquery, так как со временем могли действительно навешать кучу событий и все превратилось в мусор.
        У меня на проектах тоже достался вермишелеподобный код во некоторых местах.
        Но при желании его можно легко отрефакторить и ускорить.

        >jQuery и прямая работа с DOM'ом подходит не всем проектам.

        А не всегда и нужно работать прямо.
        Есть куча оптимизаций. Самые простые:
        1. Не дергать 100500 раз $('selector').some_operationsN(), а сохранить $('selector') в переменную и работать с ней.
        2. Обновлять DOM не кусочками, а допустим сразу аккумулировать в переменную таблицу и вставить ее в DOM.

        У меня тоже бывали тормоза, когда нужно было скрывать/показывать определенные столбцы довольно большой таблицы. Тормоза только в ИЕ (сейчас проверил на меньшем наборе данных (уменьшилось кол-во строк), ИЕ6 не тормозит). Но я не думаю, что React быстрее это сделал бы. :)

        Против Node.js ничего не имею, просто не использую.
        Это немного другой стиль разработки, более сложный.
        Ну и мой весь код на php.


        1. inoyakaigor
          28.09.2016 23:47

          2. Обновлять DOM не кусочками, а допустим сразу аккумулировать в переменную таблицу и вставить ее в DOM.

          Собственно это и есть sort of Virtual DOM


          1. G-M-A-X
            29.09.2016 00:16

            То есть вся эта мода расчитана на людей, которые не умеют программировать сами? :)

            Таки да.
            Большинство знают инструмент поверхностно.
            А такие оптимизации нужны крайне редко.

            Но при этом зп на фреймворках (особенно модных) выше.
            Но программист ни за что не отвечает.
            Кивнул в сторону фреймворка и свободен.
            Это jquery | DOM | React.js тормозит и свободен.
            Печально, что кто-то принимает решение строить приложение на монструозной архитектуре.
            Это некомпетентность или что? У меня на прошлой работе начальник хотел, чтобы у него было больше программистов в подчинении, типа он ими руководит и ему положена большая зп. :)

            Нельзя все тянуть под одну гребенку.
            Универсальный инструмент не даст выигрыш по всем пунктам.
            Где-то выиграли, а где-то проиграли.
            Нужно с умом использовать текущий инструмент, если есть серое вещество в голове, а не шарахаться с технологии на технологию, потому что так все делают. :)
            Перепиши/отрефакторь существующее решение, если с ним есть трудности и будет тебе профит (в подавляющем большинстве случаев, если текущая технология не такой же буллшит :) ).

            П.С.
            Это касается фреймворков не только JS.
            П.П.С.
            Я также противник мейнстримовых фреймворков PHP. :)


    1. yury-dymov
      28.09.2016 20:04

      Еще один момент, Вы пишите, что "задаете вопрос повторно", но проблема в том, что я не помню от Вас такого вопроса.


      Я открыл все свои статьи и его нигде нет. Оказывается, Вы этот вопрос задавали другому человеку и в другой статье.


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


      1. G-M-A-X
        28.09.2016 22:26

        Действительно, я не Вам задавал вопрос раньше, а в других темах. :)
        А сейчас тоже не только Вам.

        Просто это не первый мой вопрос, на который не было ответов (правильных) в нескольких темах.

        Да, стоило бы в конце первого предложения добавить «всем».


    1. VolCh
      29.09.2016 05:46

      DOM тормозит при больших изменениях в дереве прежде всего. Иногда без них не обойтись, но чаще из-за отсутствия времени/желания делать только минимально необходимые изменения. Библиотеки подобные React решают эту проблему путём автоматического вычисления и применения минимально необходимого набора операций по изменению дерева.


      1. G-M-A-X
        29.09.2016 10:16

        Собственно ответ дан в комментах:
        https://habrahabr.ru/post/310952/#comment_9832094
        https://habrahabr.ru/post/310952/#comment_9832244

        Расчитано, грубо говоря, на дебилов, которые не осилили имеющиеся технологии.
        За таких программистов скоро будут толченую картошку жевать. :)

        Нужно больше абстрактных фабрик по производству фабрик.


        1. j_wayne
          30.09.2016 20:34

          Слова ничто, покажите нам ваш искусный и оптимизированный код. Я серьезно.


          1. G-M-A-X
            30.09.2016 20:43

            А смысл?

            Если Вы этого не поняли:
            «Есть куча оптимизаций. Самые простые:
            1. Не дергать 100500 раз $('selector').some_operationsN(), а сохранить $('selector') в переменную и работать с ней.
            2. Обновлять DOM не кусочками, а допустим сразу аккумулировать в переменную таблицу и вставить ее в DOM.»

            То я Вам ничем больше не помогу.


            1. j_wayne
              30.09.2016 21:09

              > Нужно было просто отрефакторить код на jquery, так как со временем могли действительно навешать кучу событий и все превратилось в мусор.

              Интересно было посмотреть, каких размеров ваши проекты, какой сложности UI и как вы боретесь со стейтом.
              Нет так нет) Ничто так ничто)


              1. G-M-A-X
                01.10.2016 15:32

                >Интересно было посмотреть, каких размеров ваши проекты

                Вы сначала хотели код.
                Так вот, какие оптимизации возможны для медленного DOM я сказал, причем 2 раза.
                У Вас еще другие проблемы есть?

                >какой сложности UI

                1. Сложность UI не стоит связывать с тормозами DOM.
                2. Очень сложный.
                Каша на jquery местами. :)
                Рефакторить никто не спешит, рефакторинг проводится частично только при внесении изменений, но не не глобальный рефакторинг.
                Задумываются над редизайном (но мне старшие коллеги (уволившиеся еще до того, как я пришел :) ) говорят, что это уже давно :) ).

                В личном проекте каши нет, там и кода мало, и логика не сильно сложная.
                А также нету тормозов DOM (это очень редкие проекты их имеют, но мыши побежали топиться в озеро под сладкую музыку, что React.js (любой другой) решит все их проблемы).

                >и как вы боретесь со стейтом.

                1. Что это такое?
                2. Оно вряд ли связано с тормозами DOM? :)


    1. Strate
      02.10.2016 17:53

      Например мы используем реакт не за его оптимизации над DOM. А из-за того, что на нём очень просто описывать мутации состояния интерфейса. Если быть точным — их не надо описывать вообще, просто описываешь один раз как рендерится стейт и меняешь его в процессе жизни приложения. На jQuery все такие мутации надо описывать вручную — типа а вот сейчас надо добавить строку в таблицу, для этого надо взять tr, напихать в него td, и так далее, и сделать добавление в конец tbody. В реакте этого делать не нужно.


      1. G-M-A-X
        04.10.2016 15:47

        Так и на jquery можно писать в таком извращенном стиле :)


        1. Strate
          04.10.2016 15:56

          Конечно можно — можно на каждый чих целиком рендерить страницу и заменять её. Реакт в общем то делает тоже самое, за одним искчлюением — он не заменяет дом-дерево каждый раз на новое, он его точечно обновляет, за счёт чего в общем то и достигается скорость.


          Кстати очень хотелось бы послушать аргументы, почему именно такой стиль является "извращённым".


  1. yah
    28.09.2016 18:18

    На сервере вы используете ES6 при помощи require('babel-core/register'); как это ведет себя в продакшене?


    1. yury-dymov
      28.09.2016 19:54

      В продакшене ведет нормально. Я ленюсь, по-хорошему надо сделать отдельный конфиг webpack и сделать ES5-сборку для node-окружения.


      Я не то, чтобы долго искал, но мне кажется, что эта статья может помочь: http://jlongster.com/Backend-Apps-with-Webpack--Part-I


      Надо копать в сторону webpack-конфига с target "node"


      1. yah
        28.09.2016 21:00

        В своем boilerplate настроил webpack для работы с под node, но теперь не могу разобраться с CI/CD


        1. yury-dymov
          28.09.2016 21:21

          Давайте такие вопросы в личку :) Я посмотрел Ваш package.json и .travis.yml и не совсем понял, что Вы хотите получить в итоге. Постараюсь помочь. Единственное — отвечать буду с некоторым latency.


      1. SPAHI4
        29.09.2016 07:28

        по-хорошему надо сделать отдельный конфиг webpack и сделать ES5-сборку для node-окружения.

        А зачем? ведь ES2015 почти полностью поддерживается с Node.
        И, даже если у вас стоит старая версия, то разве в этом есть проблема производительности? Ведь транспиляция производится всего один раз при запуске. Если нет, поправьте.


        1. yury-dymov
          29.09.2016 07:33

          Вы все верно пишите, поэтому я и ленюсь. Node поддерживает многое, но не все. Babel может больше :) Да, трансляция производится только при запуске, но этот шаг занимает определенное время, хоть и в пределах 30 секунд для проектов средних размеров, которые можно "убрать"


  1. DarkLynx91
    28.09.2016 22:37

    Огромное спасибо за статьи.
    Только начинаю изучать React, и Ваши статьи очень помогли!


  1. Dorialan
    29.09.2016 10:19

    Спасибо!


  1. Dorialan
    29.09.2016 11:54

    Кстати, а почему в этом случае


    case TIME_REQUEST_FINISHED:
          return {
            loading: false,
            errors: null,
            time: action.time
          };

    не используется Object.assign({}, state, {...}}, как в TIME_REQUEST_ERROR и TIME_REQUEST_STARTED?


    1. yury-dymov
      29.09.2016 14:01

      Ну, никто нам не запрещает делать все через Object.assign.


      Redux требует, чтобы мы вернули новый объект, а не меняли старый. То есть так писать нельзя:


      state.time = action.time

      В примере, который вы привели, мне старый state и его значение вообще не нужны, поэтому я формирую новый объект с нуля.