React.js: собираем с нуля изоморфное / универсальное приложение. Часть 3: добавляем авторизацию и обмен данными с API

yury-dymov 28 сентября 2016 в 12:36 27,4k

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


Это третья и заключительная часть статьи про разработку изоморфного 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/components/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/components/DevTools/index.js


import DevTools from './DevTools';

export default DevTools;

1.3. "Причешем" 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');
}

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


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


Update: спасибо пользователям gialdeyn и Lerayne, починить SSR с redux-dev-tools можно следующим способом


src/server.js


<div id="react-view">${componentHTML}</div>
+++ <div id="dev-tools"></div>

src/client.js


+++ import DevTools from './components/DevTools';
...
ReactDOM.render(component, document.getElementById('react-view'));
+++ ReactDOM.render(<DevTools store={store} />, document.getElementById('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/timeReducer.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.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. Если в тексте присутствуют ошибки или неточности, пожалуйста, напишите мне сначала в личные сообщения. Заранее спасибо!

Проголосовать:
+19
Сохранить: