Пожалуйста, авторизуйтесь
Это третья и заключительная часть статьи про разработку изоморфного 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.
- Переименуем App.jsx –> App.prod.jsx
- Создадим App.dev.jsx, который будет состоять из App.prod.jsx и панели redux-dev-tools
- Создадим новый 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 нам нужны только для разработки, поэтому повторим маневр, описанный ранее:
- переименуем configureStore.js в configureStore.prod.js;
- реализуем configureStore.dev.js;
- реализуем 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. Добавляем новую функциональность
Реализуем следующий сценарий
- Пользователь нажимает на кнопку "Запросить время".
- Показываем индикатор загрузки, кнопка становится неактивной, чтобы избежать нежелательных повторных запросов.
- Приложение делает запрос к API.
- Приложение получает ответ от API и сохраняет полученные данные в глобальное состояние.
- Индикатор загрузки исчезает, кнопка снова становится активной; отображаем пользователю полученные данные.
Это достаточно объемная задача. Чтобы сфокусироваться на отдельных ее частях, сначала реализуем пункты 1,2 и 5, а для 3 и 4 сделаем заглушку.
2.1. Добавляем действия
После клика по кнопке "Запросить время" мы должны последовательно:
- изменить значение loading с false на true;
- сделать запрос;
- получив ответ, вернуть значение 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.
Со стороны клиентской части нам многое предстоит сделать:
- С предыдущей статьи у меня остался небольшой долг: глобальное состояние после Server Side Rendering не передается и не используется клиентом. Мы сейчас это исправим.
- Добавим в проект библиотеку redux-oauth, которая отвечает за авторизацию со стороны frontend, и настроим ее для изоморфного сценария.
- Заменим заглушку на код, который будет в действительности выполнять запросы к API.
- Добавим кнопки "Войти в систему" и "Выйти из системы".
3.1. Передаем глобальное состояние
Механизм очень простой:
- После того, как сервер выполнил всю работу и сформировал контент для клиента, мы вызываем функцию getState, которая возвращает актуальное глобальное состояние. Далее мы передаем контент и глобальное состояние в наш HTML-шаблон и отдаем полученную страницу клиенту.
- Клиентский 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));
}));
Здесь происходит много интересного:
- Мы должны вызвать функцию initialize из redux-oauth, которой передадим текущий URL, cookies и конфигурацию: адрес API и используемые OAuth-провайдеры.
- Если в переданных cookie будет найден авторизационный token, то библиотека проверит его валидность у backend и в случае успеха сохранит информацию о пользователе в глобальном состоянии. Обратите внимание, что дальнейший код приложения выполнится только после того, как отработает initialize.
- Перед тем, как отправить 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. "Шлифуем" приложение
Итак, что можно улучшить:
- Ссылки на страницу "Время" должны отображаться только для авторизованных пользователей.
- Если пользователь ввел адрес защищенной страницы в браузере, мы перенаправим его на страницу с авторизацией (в нашем случае — HelloWorldPage).
- Если пользователь вышел из системы, мы должны удалить из глобального состояния его данные.
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>
);
}
Тестируем:
- Убедимся, что мы залогинены;
- Введем в адресную строку браузера http://localhost:3001/time, нажмем "Enter" и ожидаемо увидим страницу "Время";
- Выйдем из системы;
- Еще раз введем в адресную строку браузера 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)
yah
28.09.2016 18:18На сервере вы используете ES6 при помощи require('babel-core/register'); как это ведет себя в продакшене?
yury-dymov
28.09.2016 19:54В продакшене ведет нормально. Я ленюсь, по-хорошему надо сделать отдельный конфиг webpack и сделать ES5-сборку для node-окружения.
Я не то, чтобы долго искал, но мне кажется, что эта статья может помочь: http://jlongster.com/Backend-Apps-with-Webpack--Part-I
Надо копать в сторону webpack-конфига с target "node"
yah
28.09.2016 21:00В своем boilerplate настроил webpack для работы с под node, но теперь не могу разобраться с CI/CD
yury-dymov
28.09.2016 21:21Давайте такие вопросы в личку :) Я посмотрел Ваш package.json и .travis.yml и не совсем понял, что Вы хотите получить в итоге. Постараюсь помочь. Единственное — отвечать буду с некоторым latency.
SPAHI4
29.09.2016 07:28по-хорошему надо сделать отдельный конфиг webpack и сделать ES5-сборку для node-окружения.
А зачем? ведь ES2015 почти полностью поддерживается с Node.
И, даже если у вас стоит старая версия, то разве в этом есть проблема производительности? Ведь транспиляция производится всего один раз при запуске. Если нет, поправьте.yury-dymov
29.09.2016 07:33Вы все верно пишите, поэтому я и ленюсь. Node поддерживает многое, но не все. Babel может больше :) Да, трансляция производится только при запуске, но этот шаг занимает определенное время, хоть и в пределах 30 секунд для проектов средних размеров, которые можно "убрать"
DarkLynx91
28.09.2016 22:37Огромное спасибо за статьи.
Только начинаю изучать React, и Ваши статьи очень помогли!
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
?yury-dymov
29.09.2016 14:01Ну, никто нам не запрещает делать все через Object.assign.
Redux требует, чтобы мы вернули новый объект, а не меняли старый. То есть так писать нельзя:
state.time = action.time
В примере, который вы привели, мне старый state и его значение вообще не нужны, поэтому я формирую новый объект с нуля.
G-M-A-X
Задаю вопрос повторно:
Объясните, пожалуйста, почему в последнее время стало модно говорить, что DOM тормозит?
Что вы такого с ним делаете? :)
А всякие AngularJS и ReactJS, якобы борясь с DOM тормозами, сами тормозят еще больше :)
Вся эта возня — псевдопрограммирование, когда вместо решения текущей задачи занимаются решением большего количества проблем от ее псевдорешения.
yury-dymov
Я, пожалуй, уклонюсь от дискуссии с Вами в этот раз. Вы приглашаете меня попытаться "продать" Вам то, что Вам не нужно, чтобы красиво мне сообщить, что оно Вам в общем-то и не нужно :)
И я, если честно, прекрасно Вас понимаю. Позвольте предположить, что Вы участвуете в разработке веба, используете привычный Вам стек и всем довольны: код работает, он аккуратный и легок в поддержке, — все хорошо, птички поют, а жизнь прекрасна. На хабре тем временем выходят одна статья за другой, где люди делают примерно тоже самое, что и Вы только сложнее, запутаннее, а главное Вы искренне не понимаете, зачем все это, может быть им всем просто скучно?
Если я не угадал, и этот портрет на Вас не очень похож, то я все же продолжу — ведь это я некоторое время назад. Я смотрел RailsCasts с примерами на Backbone, Angular.JS и т.п. и не понимал, зачем мне дублировать модели в backend и JS, зачем иметь два роутинга, ну и так далее. Если честно, я вообще 5 из 6 лет развития Node.js воспринимал этот стек очень скептически — нет, ну скучно людям, не хотят они кроме JS ничего учить, ладно, но мне тогда это все зачем?
А потом в одном проекте у меня "заболело". Слишком много интерактивности и динамики в интерфейсе — JS код на базе jQuery было очень страшно открывать — не то, что править. И эта не проблема меня, как плохого программиста, просто я использовал не тот инструмент. jQuery и прямая работа с DOM'ом подходит не всем проектам.
Мораль истории такова: если у Вас "не болит" и все хорошо — Вам оно не надо. Но если так случится, что заболит, то надеюсь, что мой цикл окажется Вам полезным. Я сами на практике далеко не везде использую React и Node.js стек, но там где использую — это действительно оправдано
G-M-A-X
Возможно оно мне и не нужно.
Я просто не понимаю, где же улучшение, а особенно скорости, если все наоборот. :)
>Слишком много интерактивности и динамики в интерфейсе — JS код на базе jQuery было очень страшно открывать — не то, что править.
Это ж не проблема медленного DOM :)
Нужно было просто отрефакторить код на jquery, так как со временем могли действительно навешать кучу событий и все превратилось в мусор.
У меня на проектах тоже достался вермишелеподобный код во некоторых местах.
Но при желании его можно легко отрефакторить и ускорить.
>jQuery и прямая работа с DOM'ом подходит не всем проектам.
А не всегда и нужно работать прямо.
Есть куча оптимизаций. Самые простые:
1. Не дергать 100500 раз $('selector').some_operationsN(), а сохранить $('selector') в переменную и работать с ней.
2. Обновлять DOM не кусочками, а допустим сразу аккумулировать в переменную таблицу и вставить ее в DOM.
У меня тоже бывали тормоза, когда нужно было скрывать/показывать определенные столбцы довольно большой таблицы. Тормоза только в ИЕ (сейчас проверил на меньшем наборе данных (уменьшилось кол-во строк), ИЕ6 не тормозит). Но я не думаю, что React быстрее это сделал бы. :)
Против Node.js ничего не имею, просто не использую.
Это немного другой стиль разработки, более сложный.
Ну и мой весь код на php.
inoyakaigor
Собственно это и есть sort of Virtual DOM
G-M-A-X
То есть вся эта мода расчитана на людей, которые не умеют программировать сами? :)
Таки да.
Большинство знают инструмент поверхностно.
А такие оптимизации нужны крайне редко.
Но при этом зп на фреймворках (особенно модных) выше.
Но программист ни за что не отвечает.
Кивнул в сторону фреймворка и свободен.
Это jquery | DOM | React.js тормозит и свободен.
Печально, что кто-то принимает решение строить приложение на монструозной архитектуре.
Это некомпетентность или что? У меня на прошлой работе начальник хотел, чтобы у него было больше программистов в подчинении, типа он ими руководит и ему положена большая зп. :)
Нельзя все тянуть под одну гребенку.
Универсальный инструмент не даст выигрыш по всем пунктам.
Где-то выиграли, а где-то проиграли.
Нужно с умом использовать текущий инструмент, если есть серое вещество в голове, а не шарахаться с технологии на технологию, потому что так все делают. :)
Перепиши/отрефакторь существующее решение, если с ним есть трудности и будет тебе профит (в подавляющем большинстве случаев, если текущая технология не такой же буллшит :) ).
П.С.
Это касается фреймворков не только JS.
П.П.С.
Я также противник мейнстримовых фреймворков PHP. :)
yury-dymov
Еще один момент, Вы пишите, что "задаете вопрос повторно", но проблема в том, что я не помню от Вас такого вопроса.
Я открыл все свои статьи и его нигде нет. Оказывается, Вы этот вопрос задавали другому человеку и в другой статье.
Вероятно Вы это и имели в виду, но у меня, как и большинства читателей, сложилось другое впечатление, что я игнорирую Ваши вопросы, потому что они могут казаться неудобными. Это не так и, мягко говоря, это не очень приятно.
G-M-A-X
Действительно, я не Вам задавал вопрос раньше, а в других темах. :)
А сейчас тоже не только Вам.
Просто это не первый мой вопрос, на который не было ответов (правильных) в нескольких темах.
Да, стоило бы в конце первого предложения добавить «всем».
VolCh
DOM тормозит при больших изменениях в дереве прежде всего. Иногда без них не обойтись, но чаще из-за отсутствия времени/желания делать только минимально необходимые изменения. Библиотеки подобные React решают эту проблему путём автоматического вычисления и применения минимально необходимого набора операций по изменению дерева.
G-M-A-X
Собственно ответ дан в комментах:
https://habrahabr.ru/post/310952/#comment_9832094
https://habrahabr.ru/post/310952/#comment_9832244
Расчитано, грубо говоря, на дебилов, которые не осилили имеющиеся технологии.
За таких программистов скоро будут толченую картошку жевать. :)
Нужно больше абстрактных фабрик по производству фабрик.
j_wayne
Слова ничто, покажите нам ваш искусный и оптимизированный код. Я серьезно.
G-M-A-X
А смысл?
Если Вы этого не поняли:
«Есть куча оптимизаций. Самые простые:
1. Не дергать 100500 раз $('selector').some_operationsN(), а сохранить $('selector') в переменную и работать с ней.
2. Обновлять DOM не кусочками, а допустим сразу аккумулировать в переменную таблицу и вставить ее в DOM.»
То я Вам ничем больше не помогу.
j_wayne
> Нужно было просто отрефакторить код на jquery, так как со временем могли действительно навешать кучу событий и все превратилось в мусор.
Интересно было посмотреть, каких размеров ваши проекты, какой сложности UI и как вы боретесь со стейтом.
Нет так нет) Ничто так ничто)
G-M-A-X
>Интересно было посмотреть, каких размеров ваши проекты
Вы сначала хотели код.
Так вот, какие оптимизации возможны для медленного DOM я сказал, причем 2 раза.
У Вас еще другие проблемы есть?
>какой сложности UI
1. Сложность UI не стоит связывать с тормозами DOM.
2. Очень сложный.
Каша на jquery местами. :)
Рефакторить никто не спешит, рефакторинг проводится частично только при внесении изменений, но не не глобальный рефакторинг.
Задумываются над редизайном (но мне старшие коллеги (уволившиеся еще до того, как я пришел :) ) говорят, что это уже давно :) ).
В личном проекте каши нет, там и кода мало, и логика не сильно сложная.
А также нету тормозов DOM (это очень редкие проекты их имеют, но мыши побежали топиться в озеро под сладкую музыку, что React.js (любой другой) решит все их проблемы).
>и как вы боретесь со стейтом.
1. Что это такое?
2. Оно вряд ли связано с тормозами DOM? :)
Strate
Например мы используем реакт не за его оптимизации над DOM. А из-за того, что на нём очень просто описывать мутации состояния интерфейса. Если быть точным — их не надо описывать вообще, просто описываешь один раз как рендерится стейт и меняешь его в процессе жизни приложения. На jQuery все такие мутации надо описывать вручную — типа а вот сейчас надо добавить строку в таблицу, для этого надо взять tr, напихать в него td, и так далее, и сделать добавление в конец tbody. В реакте этого делать не нужно.
G-M-A-X
Так и на jquery можно писать в таком извращенном стиле :)
Strate
Конечно можно — можно на каждый чих целиком рендерить страницу и заменять её. Реакт в общем то делает тоже самое, за одним искчлюением — он не заменяет дом-дерево каждый раз на новое, он его точечно обновляет, за счёт чего в общем то и достигается скорость.
Кстати очень хотелось бы послушать аргументы, почему именно такой стиль является "извращённым".