- PVSM.RU - https://www.pvsm.ru -
Разработка изоморфного приложения глазами моей жены
Это продолжение статьи про разработку изоморфного приложения с нуля на React.js [1]. В этой части мы добавим несколько страниц, bootstrap, роутинг, концепцию Flux и ее популярную реализацию Redux.
1) Собираем базовый стек изоморфного приложения [1]
2) Делаем простое приложение с роутингом и bootstrap
3) Реализуем взаимодействие с API и авторизацию
Итак, в первой части [1] мы закончили на том, что разработали простой компонент HelloWorld и собрали окружение для сборки и контроля качества кода. Настало время сделать полноценный сайт, а это значит, что мы добавим еще несколько страниц, свяжем их ссылками и реализуем изоморфный роутинг.
Это очень популярная библиотека, которая позволяет использовать элементы bootstrap в стиле React.
Например, вместо конструкций вида
<div className="nav navbar">
мы сможем писать
<Nav navbar>
Также не придется использовать JavaScript-код оригинального bootstrap, ведь он уже реализован в компонентах react-bootstrap.
npm i --save react-bootstrap
Выделим виджет HelloWorld из App.jsx в отдельный компонент. Напоминаю, что App.jsx — это точка входа в изоморфную часть приложения, и мы ее скоро перепишем в виде layout'а, внутри которого будут отображаться запрошенные пользователем страницы.
--- import './App.css';
+++ import './HelloWorldPage.css';
import HelloWorldPage from './HelloWorldPage';
export default HelloWorldPage;
Этот шаг позволит нам импортировать наш компонент так
import HelloWorldPage from 'components/HelloWorldPage';
вместо
import HelloWorldPage from 'components/HelloWorldPage/HelloWorldPage';
Это аккуратнее и упрощает сопровождение исходного кода приложения.
mkdir src/components/HelloWorldPage
mv src/components/App.jsx src/components/HelloWorld/HelloWorldPage.jsx
mv src/components/App.css src/components/HelloWorld/HelloWorldPage.css
mkdir src/components/App
import App from './App';
export default App;
import React, { Component } from 'react';
import Grid from 'react-bootstrap/lib/Grid';
import Nav from 'react-bootstrap/lib/Nav';
import Navbar from 'react-bootstrap/lib/Navbar';
import NavItem from 'react-bootstrap/lib/NavItem';
import HelloWorldPage from 'components/HelloWorldPage';
class App extends Component {
render() {
return (
<div>
<Navbar>
<Navbar.Header>
<Navbar.Brand>
<span>Hello World</span>
</Navbar.Brand>
<Navbar.Toggle />
</Navbar.Header>
<Navbar.Collapse>
<Nav navbar>
<NavItem>Время</NavItem>
<NavItem>Счетчики</NavItem>
</Nav>
</Navbar.Collapse>
</Navbar>
<Grid>
<HelloWorldPage />
</Grid>
</div>
);
}
}
export default App;
Важное примечание: обратите внимание, что я явно указываю, какие компоненты react-bootstrap я импортирую. Это поможет webpack в процессе сборки включить только используемую в проекте часть react-bootstrap, а не всю библиотеку целиком, как случилось бы, если бы я написал
import { Grid, Nav, Navbar, NavItem } from 'react-bootstrap';
Важно отметить, что этот маневр работает только в тех случаях, когда используемая библиотека поддерживает модульность. Например, react-bootstrap и lodash к таким относятся, а jquery и momentjs — нет.
Как видно из кода, приведенный выше компонент не работает со state и не использует component workflow callbacks (например, componentWillMount и componentDidMount). Это означает, что его можно переписать в виде так называемого Pure Sateless Function Component.
В будущем компоненты, написанные таким образом, будут иметь преимущество в производительности (спасибо теории функционального программирования и концепции pure functions), а чем более производителен каждый компонент в отдельности, тем более производительное приложение у нас получится в итоге.
Пока же реакт оборачивает подобные компоненты в обычные ES6-классы, но с одним приятным бонусом:
По умолчанию компонент обновляется всегда при получении новых значений props и/или state даже в тех случаях, когда они полностью совпадают с предыдущими. Это не всегда необходимо. У разработчика есть возможность самостоятельно реализовать метод shouldComponentUpdate(nextProps, nextState), который возвращает либо true, либо false. С помощью него вы сами можете явно указать Реакту, в каких случаях вы хотите, чтобы компонент перерисовался, а в каких — нет.
Если же компонент реализован как Pure Stateless Function Component, то Реакт сам в состоянии определить необходимость обновления внешнего вида компонента без явной реализации shouldComponentUpdate, то есть мы получаем больший профит, приложив меньше усилий.
Примечание: код ниже является учебным примером такого компонента. Так как в будущем мы внесем изменения в App.jsx, и он перестанет быть pure stateless компонентом, не следует переносить этот пример в наш проект.
Примечание 2: в нашем проекте я буду реализовывать все компоненты в виде ES6-классов, даже там, где возможно и правильно было бы реализовать их в виде Pure Stateless Functions Components, чтобы не усложнять содержание статьи.
import React from 'react';
import Grid from 'react-bootstrap/lib/Grid';
import Nav from 'react-bootstrap/lib/Nav';
import Navbar from 'react-bootstrap/lib/Navbar';
import NavItem from 'react-bootstrap/lib/NavItem';
import HelloWorldPage from './HelloWorldPage';
function App() {
return (
<div>
<Navbar>
<Navbar.Header>
<Navbar.Brand>
<span>Hello World</span>
</Navbar.Brand>
<Navbar.Toggle />
</Navbar.Header>
<Navbar.Collapse>
<Nav navbar>
<NavItem>Время</NavItem>
<NavItem>Счетчики</NavItem>
</Nav>
</Navbar.Collapse>
</Navbar>
<Grid>
<HelloWorldPage />
</Grid>
</div>
);
}
export default App;
Самое время посмотреть, что изменилось в браузере. И… да, у bootstrap нет стилей. Разработчики react-bootstrap сознательно не включили их в дистрибутив, так как все равно вы будете использовать собственную тему. Поэтому идем на любой сайт с темами для bootstrap, например bootswatch.com [2], и скачиваем понравившуюся. Сохраним ее в src/components/App/bootstrap.css. Я рекомендую сохранить именно полноценную версию, так как ее проще кастомизировать, а минификацию потом все равно сделает webpack.
Примечание: можно скачать мою тему с репозитория на github [3].
Внесем изменение в App.jsx
+++ import './bootstrap.css';
Я не хочу сейчас акцентировать внимание на настройке работы с glyphicons, тем более, что мы не будем использовать их в проекте, поэтому просто удалим их из стилей.
--- @font-face {
--- font-family: 'Glyphicons Halflings';
--- src: url('../fonts/glyphicons-halflings-regular.eot');
src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');
--- }
Возвращаемся в браузер, и теперь все должно выглядеть хорошо.
Примечание: если при перезагрузке страницы вас раздражает, что сначала появляется старая версия страницы, а новая лишь спустя пару секунд — просто перезапустите nodemon.
import CounterPage from './CounterPage';
export default CounterPage;
import React, { Component } from 'react';
class CounterPage extends Component {
render() {
return <div>Заглушка для счетчиков</div>;
}
}
export default CounterPage;
import TimePage from './TimePage';
export default TimePage;
import React, { Component } from 'react';
class TimePage extends Component {
render() {
return <div>Заглушка для времени</div>;
}
}
export default TimePage;
Для роутинга мы будем использовать библиотеку react-router.
npm i --save react-router
Чтобы она заработала, необходимо внести в наш проект следующие изменения:
import React from 'react';
import { IndexRoute, Route } from 'react-router';
import App from 'components/App';
import CounterPage from 'components/CounterPage';
import HelloWorldPage from 'components/HelloWorldPage';
import TimePage from 'components/TimePage';
export default (
<Route component={App} path='/'>
<IndexRoute component={HelloWorldPage} />
<Route component={CounterPage} path='counters' />
<Route component={TimePage} path='time' />
</Route>
);
Обратите внимание, что мы по факту экспортируем компонент Реакта. IndexRoute является аналогом index.html или index.php в вебе: если часть пути опущена, то будет выбрана именно она.
Примечание: компоненты Route и IndexRoute могут быть вложены в другие Route сколько угодно раз. В нашем примере мы ограничимся двумя уровнями.
URL "/" => компонент вида <HelloWorldPage />
URL "/counter" => <CounterPage />
URL "/time" => <TimePage />
В нашем приложении компонент App должен играть роль лейаута, поэтому необходимо "научить" его рендерить вложенные (children) компоненты.
--- import React, { Component } from 'react';
+++ import React, { Component, PropTypes } from 'react';
import Grid from 'react-bootstrap/lib/Grid';
import Nav from 'react-bootstrap/lib/Nav';
import Navbar from 'react-bootstrap/lib/Navbar';
import NavItem from 'react-bootstrap/lib/NavItem';
--- import HelloWorldPage from 'components/HelloWorldPage';
import './bootstrap.css';
+++ const propTypes = {
+++ children: PropTypes.node
+++ };
class App extends Component {
render() {
return (
<div>
<Navbar>
<Navbar.Header>
<Navbar.Brand>
<span>Hello World</span>
</Navbar.Brand>
<Navbar.Toggle />
</Navbar.Header>
<Navbar.Collapse>
<Nav navbar>
<NavItem>Время</NavItem>
<NavItem>Счетчики</NavItem>
</Nav>
</Navbar.Collapse>
</Navbar>
<Grid>
+++ {this.props.children}
--- <HelloWorldPage />
</Grid>
</div>
);
}
}
+++ App.propTypes = propTypes;
export default App;
--- import App from 'components/App';
+++ import { match, RouterContext } from 'react-router';
+++ import routes from './routes';
app.use((req, res) => {
--- const componentHTML = ReactDom.renderToString(<App />);
+++ match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
+++ if (redirectLocation) { // Если необходимо сделать redirect
+++ return res.redirect(301, redirectLocation.pathname + redirectLocation.search);
+++ }
+++ if (error) { // Произошла ошибка любого рода
+++ return res.status(500).send(error.message);
+++ }
+++ if (!renderProps) { // Мы не определили путь, который бы подошел для URL
+++ return res.status(404).send('Not found');
+++ }
+++ const componentHTML = ReactDom.renderToString(<RouterContext {...renderProps} />);
+++ return res.end(renderHTML(componentHTML));
+++ });
--- return res.end(renderHTML(componentHTML));
});
Примечание: функция match принимает в качестве первого параметра JavaScript объект с ключами routes и location. Я использую shorthand notation ES6, полный вариант выглядел бы так
{ routes: routes, location: req.url},
где routes мы импортируем из файла routes.jsx. В качестве второго параметра match принимает callback функцию, которая и отвечает за рендеринг.
Самое время посмотреть на результаты нашего труда в браузере — наша страница выглядит все также, хотя мы и избавились от явного вложения компонента HelloWorldPage в контейнер App. Двигаемся дальше.
import { Link } from 'react-router';
<Link to="/my-fancy-path">Link text</Link>
Однако, нам надо оформить в виде ссылок компоненты NavItem. Для этого воспользуемся библиотекой react-router-bootstrap.
npm i --save react-router-bootstrap
+++ import { Link } from 'react-router';
+++ import LinkContainer from 'react-router-bootstrap/lib/LinkContainer';
--- <span>Hello World</span>
+++ <Link to="/">Hello World</Link>
--- <NavItem>Время</NavItem>
+++ <LinkContainer to="/time">
+++ <NavItem>Время</NavItem>
+++ </LinkContainer>
--- <NavItem>Счетчики</NavItem>
+++ <LinkContainer to="/counters">
+++ <NavItem>Счетчики</NavItem>
+++ </LinkContainer>
Протестируем серверный роутинг. Для этого временно отключим клиентский JavaScript
// ReactDOM.render(<App />, document.getElementById('react-view'));
Перезапустим nodemon. В браузере откроем Developer Tools, вкладку Network.
Теперь можно оценить результаты нашего труда и покликать на ссылки в меню навигации. Заметим, что запросы уходят на сервер, где обрабатываются express. Он, в свою очередь, рендерит и возвращает браузеру HTML-код запрошенной страницы. Сейчас наше приложение работает в точности как классическое веб-приложение.
В случае, если клиентский JavaScript не успеет загрузиться и инициализироваться, либо в нем окажется ошибка, наше приложение будет все еще успешно работать, в чем мы только что смогли убедиться.
--- import App from 'components/App';
+++ import { browserHistory, Router } from 'react-router';
+++ import routes from './routes';
--- // ReactDOM.render(<App />, document.getElementById('react-view'));
+++ const component = (
+++ <Router history={browserHistory}>
+++ {routes}
+++ </Router>
+++ );
+++ ReactDOM.render(component, document.getElementById('react-view'));
Примечание: обратите внимание, что теперь компонент Router стал корневым компонентом нашего приложения. Он отслеживает изменения URL и формирует контент страницы на основе настроенных нами routes.
Вернемся в браузер и еще раз покликаем по ссылкам, внимательно наблюдая за вкладкой Network инструмента Developer Tools. На этот раз страница не перезагружается, запросы к серверу не уходят, а клиентский JavaScript раз за разом рендерит запрошенную страницу. Все работает!
Мы добавили несколько страниц и успешно настроили клиентский и серверный роутинг, убедившись, что они корректно работают для всех сценариев.
Сначала реализуем страницу со "счетчиками", чтобы разговор о Flux и Redux оказался максимально приближенным к практике.
Создадим два новых компонента: Counter.jsx и StateCounter.jsx.
Counter будет отображать переданное ему значение и кнопку "плюс", отвечающую за изменение этого значения.
StateCounter — компонент-родитель компонента Counter. Он будет хранить текущее значение Counter в собственном хранилище state и содержать бизнес-логику обновления этого значения при клике по кнопке "плюс".
Я сознательно выбрал такую реализацию, чтобы явно разделить интерфейс и бизнес-логику.
Этот прием очень часто используется на практике, так как подобный код проще:
В частности в нашем проекте сразу несколько компонентов будут использовать Counter.
import React, { Component, PropTypes } from 'react';
import Button from 'react-bootstrap/lib/Button';
import './Counter.css';
const propTypes = {
onClick: PropTypes.func,
value: PropTypes.number
};
const defaultProps = {
onClick: () => {},
value: 0
};
class Counter extends Component {
render() {
const { onClick, value } = this.props;
return (
<div>
<div className='counter-label'>
Value: {value}
</div>
<Button onClick={onClick}>+</Button>
</div>
);
}
}
Counter.propTypes = propTypes;
Counter.defaultProps = defaultProps;
export default Counter;
.counter-label {
display: inline-block;
margin-right: 20px;
}
import React, { Component } from 'react';
import Counter from './Counter';
class StateCounter extends Component {
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.state = { value: 0 };
}
handleClick() {
this.setState({ value: this.state.value + 1 });
}
render() {
return <Counter value={this.state.value} onClick={this.handleClick} />;
}
}
export default StateCounter;
+++ import PageHeader from 'react-bootstrap/lib/PageHeader';
+++ import StateCounter from './StateCounter';
render() {
--- return <div>Заглушка для счетчиков</div>;
+++ return (
+++ <div>
+++ <PageHeader>Counters</PageHeader>
+++ <h3>State Counter</h3>
+++ <StateCounter />
+++ </div>
+++ );
}
Самое время протестировать обновленный код. В браузере перейдем на вкладку "Счетчики" и нажмем на кнопку "+". Значение изменилось с 0 на 1. Отлично! Теперь перейдем на любую другую вкладку, а потом вернемся обратно. Значение счетчика снова стало "0". Это в высшей степени ожидаемо, но не всегда соответствует тому, что мы хотели бы видеть.
Настало время обсудить концепцию "Flux".
Примечание: Flux — это именно концепция, а не библиотека. На сегодняшний день существует множество различных библиотек, которые ее реализуют.
Компоненты не содержат бизнес-логику, а отвечают лишь за рендеринг интерфейса.
В приложении существует один объект, который хранит состояние всего приложения. Я буду называть его "глобальным состоянием", хотя не совсем уверен, что это наиболее удачный термин. Некоторые компоненты по желанию разработчика "подписываются" на интересующую их часть глобального состояния. С течением времени глобальное состояние может изменяться, а все подписанные на него компоненты получают обновления автоматически.
Важное примечание: глобальное состояние описывает лишь состояние вашего front-end приложения в отдельной вкладке и хранится исключительно в оперативной памяти браузера. Таким образом, оно будет утеряно, если пользователь нажмет F5, что абсолютно нормально, ожидаемо и by design. Я остановлюсь на этой теме более обстоятельно в третьей части.
Допустим, у нас есть сайт интернет-магазина: в центре страницы мы увидим список товаров, в панели навигации — иконку корзины с количеством товаров и общей их стоимостью, а где-то справа — блок с детализацией товаров, добавленных в корзину. Одним словом, достаточно распространенный сценарий.
Если бы мы писали этот сценарий на jQuery, то пришлось бы написать много кода для работы с DOM'ом. В процессе реализации все новых требований заказчика код становился бы все запутаннее, и, с большой долей вероятности, что-нибудь в итоге сломалось бы, а сложность и стоимость поддержки постоянно увеличивалась бы с течением времени и новых "хотелок".
Примечание: компоненты "Добавить в корзину", "Уведомления", "Корзина" и "Детализация Корзины" подписаны на глобальное состояние.
loading
равное true, что делает ее выключенной, а ее иконка меняется на индикатор загрузки согласно исходному коду этого компонента.message
, что сделает его видимым для пользователя, компонент "Корзина" получит значения prop count
с новым количеством товаров и prop value
с суммой заказа, компонент "Детализация корзины" получит значение prop items
— обновленный список объектов, соответствующих всем товарам, добавленным в корзину. Если в будущем заказчик пожелает, чтобы на странице происходило что-то еще, мы легко сможем воплотить это в жизнь, не меняя ни код других компонентов, ни функцию, которая выполняет бизнес-логику. Нам достаточно лишь реализовать новый компонент и в нем же указать, какая часть глобального состояния нас интересует.message
и покажет пользователю информационное сообщение.loading
равное false. Кнопка снова вернется в свое первоначальное состояние.function addItemToCart(itemId) {
return (dispatch) => {
dispatch(addItemToCartStarted(itemId));
addItemToCartAPICall(itemId)
.then(
(data) => {
dispatch(itemToCardAdded(data));
dispatch(addItemToCartFinished(data));
}
)
.catch(error => dispatch(addItemToCartFailed(itemId, error)));
}
}
Упрощенно, хоть и не совсем корректно: в этом примере функция dispatch отвечает за обновление глобального состояния. Мы берем функцию, которая содержит бизнес-логику обновления глобального состояния, и передаем ее в качестве первого аргумента функции dispatch.
В результате общий процесс выглядит следующим образом и всегда работает однонаправленно.
<Button onClick={() => dispatch(addItemToCart(3))} />
В процессе выполнения эта функция изменяет один или несколько раз глобальное состояние.
От теории к практике!
Коротко: это одна из наиболее популярных реализаций концепции Flux.
Плюсы:
Минусы:
Подбадривающее примечание: в процессе выполнения этих шагов вам могут прийти мысли вроде: "Зачем все это?" или "Ну почему все так сложно?". Ничего! Глаза боятся, а руки делают. Если вы дошли до этого места, то вспомните первую часть статьи. Она же огромная, я-то знаю, я же ее писал! Да, в первый раз может уйти много времени, чтобы проделать эти шаги, но в последующие разы это будет занимать совсем немного времени, обещаю!
npm i --save redux react-redux redux-thunk
3.2.2.1 Создаем папки src/redux, src/redux/actions и src/redux/reducers соответственно.
3.2.2.2 Создаем файл counterActions.js. В нем будут описаны функции, которые будут вызываться из наших компонентов.
export const INCREMENT_COUNTER = 'INCREMENT_COUNTER';
export function incrementCounter() {
return { type: INCREMENT_COUNTER };
}
3.2.2.3 Создаем файл counterReducer.js. В нем мы опишем логику обновления глобального состояния.
import { INCREMENT_COUNTER } from 'redux/actions/counterActions';
const initialState = { value: 0 };
export default function(state = initialState, action) {
switch (action.type) {
case INCREMENT_COUNTER:
return { value: state.value + 1 };
default:
return state;
}
}
Примечание: очень важно не забыть про третий шаг, так как в процессе инициализации redux все подобные функции (их еще называют "редьюсеры") будут вызваны с действием типа @@INIT, и мы должны корректно вернуть начальное значение.
Создаем файл configureStore.js
import { applyMiddleware, combineReducers, createStore } from 'redux';
import thunk from 'redux-thunk';
import counterReducer from './reducers/counterReducer';
export default function (initialState = {}) {
const rootReducer = combineReducers({
counter: counterReducer
});
return createStore(rootReducer, initialState, applyMiddleware(thunk));
}
+++ import { Provider } from 'react-redux'
+++ import configureStore from './redux/configureStore';
app.use((req, res) => {
+++ const store = configureStore();
...
--- const componentHTML = ReactDom.renderToString(<RouterContext {...renderProps} />);
+++ const componentHTML = ReactDom.renderToString(
+++ <Provider store={store}>
+++ <RouterContext {...renderProps} />
+++ </Provider>
+++ );
Контекст — это props с глобальной областью видимости. То есть вы в своем компоненте явно указываете соответствующие props, которые будут доступны во всех его child компонентах и их child компонентах и так далее. Важно помнить, что это экспериментальная фича, а значит, есть большой шанс, что ее API в будущем изменится и, соответственно, явно использовать в своих приложениях ее не рекомендуется.
Другое дело, когда речь заходит о библиотеках, где контекст использовать приемлемо. Если API контекста поменяется, то вам будет достаточно обновить версии библиотек, которые его используют, не внося ни строчки изменений в собственный код.
В нашем примере мы используем компонент Provider из библиотеки react-redux и передаем ему хранилище глобального состояния store. Этот компонент хранит состояние в виде контекста, а так как это корневой элемент приложения, то доступ к глобальному состоянию в теории может быть обеспечен для абсолютно любого компонента нашего приложения, чем мы и будем с удовольствием пользоваться.
+++ import { Provider } from 'react-redux';
+++ import configureStore from './redux/configureStore';
+++ const store = configureStore();
const component = (
+++ <Provider store={store}>
<Router history={browserHistory}>
{routes}
</Router>
+++ </Provider>
);
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import Counter from './Counter';
import { incrementCounter } from 'redux/actions/counterActions';
const propTypes = {
dispatch: PropTypes.func.isRequired,
value: PropTypes.number.isRequired
};
class ReduxCounter extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.props.dispatch(incrementCounter());
}
render() {
return <Counter value={this.props.value} onClick={this.handleClick} />;
}
}
ReduxCounter.propTypes = propTypes;
function mapStateToProps(state) {
const { value } = state.counter;
return { value };
}
export default connect(mapStateToProps)(ReduxCounter);
Функция connect соединяет наш компонент с глобальным состоянием. Она очень интересная и делает следующее:
Примечание: такие функции как connect называются High Order Components или сокращенно HOCs.
Концепция High Order Component работает следующим образом: у вас есть функция, которая принимает в качестве параметра один компонент, а возвращает другой, который, как правило, содержит в себе исходный с расширенным набором props.
connect как раз и является примером такой функции. Упрощенно она выглядит следующим образом
function connect(mapStateToProps) {
function dispatch(...) {
...
}
const injectedProps = mapStateToProps(globalState);
return (WrappedComponent) => {
class HighOrderComponent extends Component {
render() {
<WrappedComponent {...this.props} {...injectedProps} dispatch={dispatch} />;
}
};
return HighOrderComponent;
}
}
+++ import ReduxCounter from './ReduxCounter';
<StateCounter />
+++ <h3>Redux Counter</h3>
+++ <ReduxCounter />
Мы проделали большую работу, и теперь наш проект напоминает полноценный сайт, поздравляю!
Приложение из статьи на github — https://github.com/yury-dymov/habr-app/tree/v2 [5].
P.s. Если в тексте присутствуют ошибки или неточности, пожалуйста, напишите мне сначала в личные сообщения. Заранее спасибо!
Автор: yury-dymov
Источник [10]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/190837
Ссылки в тексте:
[1] разработку изоморфного приложения с нуля на React.js: https://habrahabr.ru/post/309958/
[2] bootswatch.com: http://bootswatch.com/
[3] репозитория на github: https://github.com/yury-dymov/habr-app/blob/v2/src/components/App/bootstrap.css
[4] Даниил Абрамов: https://twitter.com/dan_abramov
[5] https://github.com/yury-dymov/habr-app/tree/v2: https://github.com/yury-dymov/habr-app/tree/v2
[6] Документация и галерея компонентов react-bootstrap: https://react-bootstrap.github.io
[7] Документация react-router: https://github.com/ReactTraining/react-router/tree/master/docs
[8] Описание Flux: https://facebook.github.io/flux/
[9] Роскошный бесплатный видеокурс о redux от его создателя: https://egghead.io/courses/getting-started-with-redux
[10] Источник: https://habrahabr.ru/post/310284/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.