СоХабр закрыт.
С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.
Добавляем авторизацию
Это третья и заключительная часть статьи про разработку изоморфного React.js приложения с нуля. Части первая и вторая.
В этой части мы:
Это очень удобная библиотека, упрощающая процесс разработки. С ее помощью вы сможете в режиме реального времени видеть содержимое глобального состояния, а также его изменения. Дополнительно redux-dev-tools позволяет "откатывать" последние изменения глобального состояния, что удобно в процессе тестирования и отладки. Нам же она добавит наглядности и сделает процесс обучения более интерактивным и прозрачным.
npm i --save-dev redux-devtools redux-devtools-log-monitor redux-devtools-dock-monitor
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>
);
import DevTools from './DevTools';
export default DevTools;
Наилучшее место для этого — компонент App.jsx. Он корневой, поэтому панель будет доступна на каждой странице нашего приложения.
Заметим, что панель нам понадобится только в процессе разработки, и мы не хотим, чтобы она была видна конечным пользователям или попала в сборку клиентского JavaScript.
mv src/components/App.jsx src/components/App.prod.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;
if (process.env.NODE_ENV === 'production') {
module.exports = require('./App.prod');
} else {
module.exports = require('./App.dev');
}
Во второй части мы поместили создание корневого редьюсера в configureStore, что не совсем правильно, так как это не его зона ответственности. Сделаем небольшой рефакторинг и перенесем его в redux/reducers/index.js.
import { combineReducers } from 'redux';
import counterReducer from './counterReducer';
export default combineReducers({
counter: counterReducer
});
Из документации redux-dev-tools следует, что нам необходимо внести изменения в configureStore. Вспомним, что инструменты redux-dev-tools нам нужны только для разработки, поэтому повторим маневр, описанный ранее:
mv redux/configureStore.js 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.
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
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, который мы все равно в продуктиве использовать не будем, поэтому можно сделать исключение и спокойно проигнорировать сообщение о проблеме.
Реализуем следующий сценарий
Это достаточно объемная задача. Чтобы сфокусироваться на отдельных ее частях, сначала реализуем пункты 1,2 и 5, а для 3 и 4 сделаем заглушку.
После клика по кнопке "Запросить время" мы должны последовательно:
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 — комбинация этих функций, которая целиком описывает наш сценарий. Именно ее мы и будем вызывать из нашего компонента.
Добавим кнопку react-bootstrap-button-loader с поддержкой индикатора загрузки на страницу TimePage и научим ее вызывать функцию timeRequest по клику.
npm i --save react-bootstrap-button-loader
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, которые мы совсем недавно реализовали.
Настало время оживить интерфейс. Начнем с реализации логики для обновления глобального состояния
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, который берет исходный объект и применяет к нему нужные мне изменения.
Хорошо, теперь добавим новый редьюсер в корневой редьюсер.
+++ import timeReducer from './timeReducer';
export default combineReducers({
counter: counterReducer,
+++ time: timeReducer
});
Снова откроем браузер и, предварительно очистив очередь redux-dev-tools, покликаем по кнопке "Запросить". Интерфейс все еще не обновляется, но теперь наши actions изменяют глобальное состояние согласно коду нашего редьюсера, а это значит, что "под капотом" вся логика работает как надо. Дело за малым — "оживим" интерфейс.
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.
Примечание: для этого примера я использую очень простой 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.
Со стороны клиентской части нам многое предстоит сделать:
Механизм очень простой:
+++ 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>
+++ const initialState = window.REDUX_INITIAL_STATE || {};
--- const store = configureStore();
+++ const store = configureStore(initialState);
Как видно из кода, глобальное состояние я передаю в переменной REDUX_INITIAL_STATE.
Устанавливаем redux-oauth
Примечание: мы используем redux-oauth для изоморфного сценария, но она также поддерживает и client-side only. Примеры конфигурации для различных случаев и демо можно найти на сайте библиотеки.
Примечание 2: redux-oauth использует cookie для авторизации, так как механизм local storage не подходит для изоморфного сценария.
npm i --save redux-oauth cookie-parser
Активируем плагин cookieParser для express
+++ import cookieParser from 'cookie-parser';
const app = express();
+++ app.use(cookieParser());
Настраиваем redux-oauth для серверной части приложения
+++ 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));
}));
Здесь происходит много интересного:
Согласно документации, нам также необходимо добавить редьюсер redux-oauth в корневой редьюсер.
+++ import { authStateReducer } from 'redux-oauth';
export default combineReducers({
+++ auth: authStateReducer,
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 нам что-то вернул.
Как правило, авторизованный пользователь имеет больше возможностей по работе с системой, чем гость, иначе какой же смысл в авторизации?
С технической точки зрения это означает, что многие компоненты могут выглядеть и вести себя по-разному в зависимости от того, зашел пользователь в систему или нет.
Я являюсь ярым сторонником принципа DRY (don't repeat yourself), поэтому напишем небольшой хелпер.
export function isUserSignedIn(state) {
return state.auth.getIn(['user', 'isSignedIn']);
}
Реализуем кнопку "Войти в систему"
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);
Эта кнопка будет отображаться, только если пользователь еще не вошел в систему.
Реализуем кнопку "Выйти из системы"
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);
Эта кнопка будет отображаться, только если пользователь уже вошел в систему.
import OAuthButton from './OAuthButton';
import SignOutButton from './SignOutButton';
export { OAuthButton, SignOutButton };
Я добавлю авторизацию на страницу HelloWorldPage.
+++ import { OAuthButton, SignOutButton } from 'components/AuthButtons';
+++ <h2>Авторизация</h2>
+++ <OAuthButton provider='github' />
+++ <SignOutButton />
Настало время насладиться результатами нашего труда. Нажимаем на кнопку "Войти", используем свой github аккаунт для авторизации и… мы в системе! Кнопка "Войти" исчезла, зато появилась кнопка "Выйти". Проверим, что сессия сохраняется, для этого перезагрузим страницу. Кнопка "Выйти" не исчезла, а в redux-dev-tools можно найти информацию о пользователе. Отлично! Пока все работает. Переходим на страницу "Время", нажимаем на кнопку "Запросить" и видим, что timestamp отобразился — это сервер вернул нам данные.
На этом можно было бы закончить, но нам нужно "отшлифовать" наше приложение.
Итак, что можно улучшить:
+++ 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, нажимаем на кнопку "Выйти" — и ссылка пропала.
Как мы помним, за соответствие между URL и страницей, которую нужно отрендерить отвечает библиотека react-router, а конфигурация путей находится в файле routes.jsx. Нам нужно добавить следующую логику: если пользователь неавторизован и запросил защищенную страницу, то перенаправим его на HelloWorldPage.
Для получения информации о пользователе нам необходимо передать в routes.jsx ссылку на хранилище глобального состояния.
--- .then(() => match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
+++ .then(() => match({ routes: routes(store), location: req.url }, (error, redirectLocation, renderProps) => {
<Router history={browserHistory}>
--- {routes}
+++ {routes(store)}
</Router>
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>
);
}
Тестируем:
Примечание: в функции requireAuth используется setTimeout с нулевой задержкой, что на первый взгляд лишено смысла. Это сделано специально, так как позволяет обойти баг в одном из популярных браузеров.
+++ import { SIGN_OUT } from 'redux-oauth';
+++ case SIGN_OUT:
+++ return initialState;
default:
return state;
Если поступит action SIGN_OUT, то все данные редьюсера timeReducer будут заменены на initialState, то есть на значения по умолчанию. Этот же прием необходимо реализовать для всех других редьюсеров, которые содержат пользовательские данные.
Библиотека redux-oauth поддерживает Server Side API requests, то есть в процессе рендеринга сервер может сам обратиться к API за данными. Это имеет множество преимуществ:
Примечание: да, поисковики не будут авторизовываться, но некоторые сервисы API смогут возвращать данные и для неавторизованных пользователей с некоторыми ограничениями. redux-oauth подойдет и для таких сценариев.
Реализуем небольшой Proof of Concept.
Добавим запрос к API в серверную часть нашего приложения
+++ 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 только в том случае, если пользователь авторизован.
--- return (dispatch) => {
+++ return (dispatch, getState) => {
+++ if (!isUserSignedIn(getState())) {
+++ return Promise.resolve();
+++ }
Как мы видим, внутри возвращаемой функции мы можем получить доступ к актуальному глобальному состоянию посредством вызова функции getState, которая будет передана вторым аргументом. Об этом далеко не все знают, а это очень полезная возможность.
Вот и подошел к концу цикл статей о веб-приложении на React.js с нуля. Искренне надеюсь, что он был вам полезен!
С удовольствием отвечу на ваши вопросы в комментариях, а также принимаю запросы на темы для следующих статей.
P.s. Если в тексте присутствуют ошибки или неточности, пожалуйста, напишите мне сначала в личные сообщения. Заранее спасибо!
комментарии (27)