Pull to refresh

Изоморфное Приложение с React и Redux

Reading time 15 min
Views 99K
Original author: Milo Mordaunt
Итак, я знаю что ты любишь Todo списки, то есть, что тебе очень нравится писать Todo списки, поэтому мне хочется, чтобы ты создал один из них, используя новый, восхитительный и питательный («nutritious» прим. пер.) Flux фреймворк, Redux! Я желаю тебе только лучшего.

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

  • Цельнозерновой рендеринг на сервере
  • Расширенный роутинг, богатый Omega-3
  • Маслянистая асинхронная загрузка данных
  • Гладкое функциональное послевкусие


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

Хочу тебе сказать… Это будет не супер маленький туториал, так что пристегнись и приготовься к тернистому пути, не высовывай руки и ноги и все такое…

Стоп, подожди, а что такое Redux?


Ох, я рад что ты спросил!

Redux это новый Flux фреймворк от Данила Абрамова, который убирает много лишних сложностей. Ты можешь почитать почему этот фреймворк был создан здесь или посмотреть, в общем TL: DR Redux держит состояние твоего приложения в одном месте, и определяет минимальный, но достаточно мощный способ взаимодействия с этим состоянием (state ориг.).

Если ты знаком с традиционными Flux фреймворками, тогда самая большая разница, которую ты заметишь, это отсутствие Хранилищ (Stores ориг.), и присутствие «Редюсеров» (Reducers ориг.).
В Redux, все состояние приложения живет в одном месте (экземпляр Redux), вместо разделения на отдельные хранилища (которые могут немного противится изоморфизму).
«Редюсер» — это описание того как состояние будет изменятся, он ничего не меняет по сути, и выглядит примерно так:
function exampleReducer(state, action) {
  return state.changedBasedOn(action)
}

Как? Ты посмотришь чуть позже.

Ты вероятно думаешь что Redux больше похоже на решение чем на фреймворк. Это минимальная база, которая содержит все лучшие идеи Flux. Эта статья я надеюсь научит тебя успешно это использовать, процветать и идти с минимальными мутациями.

Делаем себе комфортно


Мы будем использовать Webpack и Babel, чтобы связать вместе наше приложение, потому что мы классные, умные и радостные, и потому что они дают нам возможность перезагрузки кода налету и самые свежие ES6/7 фишки.

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

Вот package.json который я заранее приготовил:
package.json
{
  "name": "isomorphic-redux",
  "version": "1.0.0",
  "description": "Basic isomorphic redux application",
  "main": "index.js",
  "scripts": {
    "start": "NODE_PATH=$NODE_PATH:./shared node .",
    "dev": "npm run start & webpack-dev-server --progress --color"
  },
  "author": "<your-name> <<your-email>>",
  "license": "MIT",
  "dependencies": {
    "axios": "^0.5.4",
    "express": "^4.13.2",
    "immutable": "^3.7.4",
    "object-assign": "^3.0.0",
    "react": "^0.13.3",
    "react-redux": "^0.2.2",
    "react-router": "^1.0.0-beta3",
    "redux": "^1.0.0-rc"
  },
  "devDependencies": {
    "babel": "^5.8.20",
    "babel-eslint": "^4.0.5",
    "babel-loader": "^5.3.2",
    "eslint": "^1.0.0",
    "eslint-plugin-react": "^3.1.0",
    "react-hot-loader": "^1.2.8",
    "webpack": "^1.10.5",
    "webpack-dev-server": "^1.10.1"
  }
}


а также
webpack.config.js
var path    = require('path');
var webpack = require('webpack');

module.exports = {
  entry:  [
    'webpack-dev-server/client?http://localhost:8080/',
    'webpack/hot/only-dev-server',
    './client'
  ],
  output: {
    path:     path.join(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  resolve: {
    modulesDirectories: ['node_modules', 'shared'],
    extensions:        ['', '.js', '.jsx']
  },
  module: {
    loaders: [
      {
        test:    /\.jsx?$/,
        exclude: /node_modules/,
        loaders: ['react-hot', 'babel']
      }
    ]
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin()
  ],
  devtool: 'inline-source-map',
  devServer: {
    hot: true,
    proxy: {
      '*': 'http://localhost:' + (process.env.PORT || 3000)
    }
  }
};


и .babelrc (для 'ES7' сахарка)
{
  "optional": ["es7.decorators", "es7.classProperties", "es7.objectRestSpread"]
}

Признаться, эти файлы не так примечательны, просто создали себе разумное dev окружение.
Хорошо, теперь нужно запустить npm i, чтобы скачать все зависимые модули, и можно начинать.

Подайте мне Seymour


Базовая структура приложения будет выглядеть так:
client/
shared/
index.js
server.jsx

Все главные части кода будут лежать в shared директории, но нужен некоторый склеивающий код для отделения клиенсткой и серверной части.

index.js
'use strict';

require('babel/register')({});

var server = require('./server');

const PORT = process.env.PORT || 3000;

server.listen(PORT, function () {
  console.log('Server listening on', PORT);
});


просто файл для запуска server.jsx, итак теперь мы можем использовать ES6/JSX.

Функции сервера будет выполнять Express, потому что это проще, и есть шанс что ты уже его знаешь.
server.jsx
import express from 'express';

const app = express();

app.use((req, res) => {
  const HTML = `
  <DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <title>Isomorphic Redux Demo</title>
    </head>
    <body>
      <div id="react-view"></div>
      <script type="application/javascript" src="/bundle.js"></script>
    </body>
  </html>
  `;
  
  res.end(HTML);
});

export default app;


Довольно стандартная фигня. Мы настроили Express сервер с глобальным middleware, но ничего не обрабатываем, просто пустая веб страничка, которая поможет нам заглянуть в пустоту существования. Давайте исправим это.

Роутим как про


Ты наверно думаешь, что с Express роутингом и шаблонизатором это будет легко. К сожалению ты ошибаешься, поскольку мы хотим расшарить как можно больше логики между сервером и клиентом. Мы будем использовать React Router, так как он может роутить на клиенте и на сервере.

Итак, у нас есть root компонент shared/components/index.jsx, в который встроится наш React Router. Таким способом мы можем добавить эстетики всему приложению (шапку и подвал, например), хорошая архитектура для блестящего SPA.
shared/components/index.jsx
import React from 'react';
export default class AppView extends React.Component {
  render() {
    return (
      <div id="app-view">
        <h1>Todos</h1>
        <hr />
        {this.props.children}
      </div>
    );
  }
}


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

Далее нам нужно определить роут в
shared/routes.jsx
import React     from 'react';
import { Route } from 'react-router';
import App from 'components';

export default (
  <Route name="app" component={App} path="/">
  </Route>
);


Здесь мы просто говорим React Router'у отображать наш components/index по пути '/'. Звучит неплохо!
Теперь тоже самое сделаем на сервере.
server.jsx
import React      from 'react';
import { Router } from 'react-router';
import Location   from 'react-router/lib/Location';
import routes     from 'routes';

app.use((req, res) => {
  const location = new Location(req.path, req.query);

  Router.run(routes, location, (err, routeState) => {
    if (err) return console.error(err);
    
    const InitialComponent = (
      <Router {...routeState} />
    );
    const componentHTML = React.renderToString(InitialComponent);
    const HTML = `...`;
    
    res.end(HTML);
  });
});


Здесь мы импортируем пару новых игрушек и говорим роутеру направить запрос в руки express. Надеюсь мы вернемся к routeState переменной и сможем отобразить тот маршрут который с нас спрашивают. Затем мы можем использовать ловкий метод renderToString из React, который выведет наш компонент в HTML строку, которую мы отдадим клиенту в react-view div написанный нами ранее.

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

Если мы запустим npm start то увидим здесь http://localhost:3000/, что роут вставился в HTML.
Todos route
Ты заметишь, что появилось пару ошибок в консоле, о том что роутеру нужно несколько значений. Это потому что клиент пытается загрузить bundle.js, но мы еще не задали webpack'у точку входа, поэтому такая фигня.

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

Так что вперед, откроем client/index.jsx и напишем что нибудь:
client/index.jsx
import React       from 'react';
import { Router }  from 'react-router';
import { history } from 'react-router/lib/BrowserHistory';
import routes      from 'routes';

React.render(
  <Router children={routes} history={history} />,
  document.getElementById('react-view')
);



Мы сказали React'у вставить компонент Router'a в react-view div, и передали ему соответствующие параметры. History объект, который мы не видели в серверной части, необходимая часть конфигурации React Router'a (когда отображаем напрямую), и описывает как выглядит URL. А мы хотим хороший и чистый объект! Поэтому будем использовать HTML5 History API с BrowserHistory, хотя для старых браузеров мы могли бы использовать HashHistory и получать /# URL из адреса.

Теперь мы можем запустить наше приложение npm run dev и Webpack будет обрабатывать наш bundle.js. Выглядит не так уж интересно, но зайдя на http://localhost:8080/ должно все работать без ошибок. С роутингом покончено, и мы готовы к Redux экшену.

Reduce, Reuse, Redux


Redux внешне очень похож на Flux за исключением того, как я говорил ранее, что используются редюсеры вместо хранилищ. Во-первых мы напишем несколько простых действий для изменения Todo листа.
shared/actions/TodoActions.js
export function createTodo(text) {
  return {
    type: 'CREATE_TODO',
    text,
    date: Date.now()
  }
}

export function editTodo(id, text) {
  return {
    type: 'EDIT_TODO',
    id,
    text,
    date: Date.now()
  };
}

export function deleteTodo(id) {
  return {
    type: 'DELETE_TODO',
    id
  };
}


Как ты можешь видеть, в Redux создатели действий (action creators ориг.), это просто функции, которые возвращают последовательно отформатированные объекты. Никакой магии, далее нам нужны редюсеры, чтобы обрабатывать их.
shared/reducers/TodoReducer.js
import { List } from 'immutable';

const defaultState = new List();

export default function todoReducer(state = defaultState, action) {
  switch(action.type) {
    case 'CREATE_TODO':
      return state.concat(action.text);
    case 'EDIT_TODO':
      return state.set(action.id, action.text);
    case 'DELETE_TODO':
      return state.delete(action.id);
    default:
      return state;
  }
}


Опять все просто. Здесь мы можем использовать Immutable List object, чтобы хранить неизменяемое состояние в хранилище (хотя в более больших приложениях может быть все посложнее), и возвращать новую версию состояния в зависимости от действия.

Redux не такой уж упрямый, у него всего два ожидания от своих редюсеров.
  1. Он должен иметь сигнатуру (state, action) => newState.
  2. Редюсер не изменяет передаваемое ему состояние, но возвращает его новую версию.


Как ты можешь увидеть, последнее неплохо вяжется с Immuatable.js

Здесь у нас используется простая switch конструкция, но если тебе это не нравится, не стесняйся написать болванку, которая привнесет немного абстракции.

Redux не ограничивается использованием одного редюсера, для удобства их получения, можно создать reducers/index.js:
export { default as todos } from './TodoReducer';

Так как у нас всего он один, нам это не особо нужно, но понадобится в будущем.

Ииииии… Экшн!


Разговоры про редюсеры и действия, это хорошо, но наше приложение ничего не знает об этом! Пора это изменить.

Нам нужно пробросить экземпляр Redux через дерево компонентов, чтобы начать все это обрабатывать и связать все эти штуки вместе!
У NPM пакета react-redux есть несколько примочек, которые помогут нам в этом.

server.jsx
import { createStore, combineReducers } from 'redux';
import { Provider }                     from 'react-redux';
import * as reducers                    from 'reducers';

app.use((req, res, next) => {
  const location = new Location(req.path, req.query);
  const reducer  = combineReducers(reducers);
  const store    = createStore(reducer);
  
  Router.run(routes, location, (err, routeState) => {
    if (err) return console.error(err);
    
    const InitialView = (
      <Provider store={store}>
        {() =>
          <Router {...routeState} />
        }
      </Provider>
    );




Мы создаем экземпляр компонента-хранилища Redux на каждый запрос и пробрасываем через все дерево компонентов (доступен как <component>.context.redux, если вам когда либо понадобится к нему доступ), обернув root компонент в Provider.
Так же нам нужно отдать клиенту начальное состояние, чтобы он смог гидрировать свои хранилища.

Просто спросим состояние у Redux:
const initialState = store.getState();


И добавим пару строк в наш HTML шаблон:
<title>Redux Demo</title>
    
<script type="application/javascript">
  window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};
</script>

После этого у нас будет доступ к состоянию на клиенте через window.__INITIAL_STATE__, неплохо да?
Что нам теперь остается сделать, это трансформировать все в Immutable.js коллекции. И отдать их Redux когда мы инстанцируем новое хранилище.

client/index.jsx
import { createStore, combineReducers } from 'redux';
import { Provider }                     from 'react-redux';
import * as reducers                    from 'reducers';
import { fromJS }                       from 'immutable';

let initialState = window.__INITIAL_STATE__;

// Transform into Immutable.js collections,
// but leave top level keys untouched for Redux
Object
  .keys(initialState)
  .forEach(key => {
    initialState[key] = fromJS(initialState[key]);
   });
const reducer = combineReducers(reducers);
const store   = createStore(reducer, initialState);

React.render(
  <Provider store={store}>
    {() =>
      <Router children={routes} history={history} />
    }
  </Provider>,
  document.getElementById('react-view');
);


Это идентично инициализации состояния на сервере, за исключением того, что мы гидрируем хранилище состоянием, переданным нам от сервера.

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

Соединяем все точки


Мы будем использовать три компонента для отображения информации, которая может показаться немного излишней (скорее всего так и есть), но это покажет различия в Redux между «умными» и «тупыми» компонентами, что очень важно в больших приложениях.

Умные компоненты подписываются на события Redux хранилища (например используя @connector синтаксис декоратора), и пробрасывают его вниз по дереву к другим компонентам через свойства. Которые могут быть в любой точке дерева, но при разработке более сложных приложений, обычно доходит до самых нижних слоев.

Здесь мы будем использовать только один
shared/components/Home.jsx
import React                  from 'react';
import TodosView              from 'components/TodosView';
import TodosForm              from 'components/TodosForm';
import { bindActionCreators } from 'redux';
import * as TodoActions       from 'actions/TodoActions';
import { connect }            from 'react-redux';

@connect(state => ({ todos: state.todos }))
export default class Home extends React.Component {
  render() {
    const { todos, dispatch } = this.props;
    
    return (
      <div id="todo-list">
        <TodosView todos={todos} 
          {...bindActionCreators(TodoActions, dispatch)} />
        <TodosForm
          {...bindActionCreators(TodoActions, dispatch)} />
      </div>
    );
  }
}



Далее напишем два «тупых» компонента, но сначала давайте посмотрим, что здесь происходит.

Если вы не знакомы с декораторами (@connector секция), то лучший способ это понять, думать, что это тоже самое что и миксины в компонентах. Ты наверное встречал похожие конструкции в других языках, в Python например.

Если нет, в javascript это просто функции, которые в некотором роде модифицируют другие функции (здесь «класс»).

Декоратор @connect оборачивает наш класс другим компонентом (<Connector>), дающим доступ к запрашиваемым частям состояния, как к свойствам компонента, следовательно мы можем использовать todos, что мы и делаем. Он так же дает доступ к dispatch функции из Redux, с помощью которой мы можем обрабатывать наши действия, вот так:
dispatch(actionCreator());

Наконец, мы используем функцию bindActionCreators из Redux, чтобы пробросить связанные создатели действий.
Это значит, что в дочерних компонентах, мы сможем вызывать создателей действий напрямую, без оборачивания их в dispatch() функцию.

Смотрим
components/TodosView.jsx
import React from 'react';

export default class TodosView extends React.Component {
  handleDelete = (e) => {
    const id = Number(e.target.dataset.id);
    
    // Equivalent to `dispatch(deleteTodo())`
    this.props.deleteTodo(id);
  }
  handleEdit = (e) => {
    const id  = Number(e.target.dataset.id);
    const val = this.props.todos.get(id).text
    
    // For cutting edge UX
    let newVal = window.prompt('', val);
    this.props.editTodo(id, newVal);
  }
 
  render() {
    return (
      <div id="todo-list">
        {
          this.props.todos.map( (todo, index) => {
            return (
              <div key={index}>
                <span>{todo}</span>
              
                <button data-id={index} onClick={this.handleDelete}>
                  X
                </button>
                <button data-id={index} onClick={this.handleEdit}>
                  Edit
                </button>
              </div>
            );
          })
        }
      </div>
    );
  }
}


Здесь мы отображаем каждый элемент todo в хранилище, рядом с кнопками удаления и изменения, которые связаны с нашими создателями действий.

Так же, прошу заметить, что мы используем «arrow» функции в определении класса, контекст которых связан с конструктором класса (с тех пор как эти функции наследуют контекст от исполнителя). Если мы используем обычные функции ES6 класса (как render), тогда мы должны связать их с контекстом самостоятельно, что иногда утомительно.

Обратите внимание, вы так же можете использовать React.createClass, чтобы избежать проблем, и использовать миксины, хотя я предпочитаю использовать ES6 классы для чистоты и консистенции.


Наконец, определим
components/TodosForm.jsx
import React from 'react';

export default class TodosForm extends React.Component {
  handleSubmit = () => {
    let node = this.refs['todo-input'].getDOMNode();
    
    this.props.createTodo(node.value);
    
    node.value = '';
  }
  
  render() {
    return (
      <div id="todo-form">
        <input type="text" placeholder="type todo" ref="todo-input" />
        <input type="submit" value="OK!" onClick={this.handleSubmit} />
      </div>
    );
  }
}


Это так же «тупой» компонент, который позволяет просто добавить todo в хранилище.

Теперь нам остается определить роут в

shared/routes.jsx
import Home from 'components/Home';
export default (
  <Route name="app" component={App} path="/">
    <Route component={Home} path="home" />
  </Route>
);



Todo App

И voilà, заходим на http://localhost:8080/home и смотрим на работающее приложение

Последний рубеж: асинхронные действия


Я знаю о чем ты думаешь.

Это невозможно.

А я говорю возможно!

Еще одна хорошая возможность у Redux, это определить свой middleware у диспетчера, который позволит изменять ваши действия (асинхронно). Я уверен, ты заметил из темы про Redux, что он работает с функциями с определенными сигнатурами.

Мы собираемся использовать свой Redux middleware, чтобы сделать наши действия в приложении более проще, и сделать наши создатели действий синхронными, что даст нам возможность использовать вкусные и блестящие ES6 промисы.
shared/lib/promiseMiddleware.js
export default function promiseMiddleware() {
  return next => action => {
    const { promise, type, ...rest } = action;
   
    if (!promise) return next(action);
   
    const SUCCESS = type;
    const REQUEST = type + '_REQUEST';
    const FAILURE = type + '_FAILURE';
    next({ ...rest, type: REQUEST });
    return promise
      .then(res = > {
        next({ ...rest, res, type: SUCCESS });
        
        return true;
      })
      .catch(error => {
        next({ ...rest, error, type: FAILURE });
        
        // Another benefit is being able to log all failures here 
        console.log(error);
        return false;
      });
   };
}


Это значит, что мы можем просто определить 'promise' ключ у нашего действия, чтобы оно автоматически пришло к состоянию resolved или rejected.
Мы также можем опционально отслеживать редюсеры для авто сгенерированных <TYPE>_REQUEST и <TYPE>_FAILURE, если нам надо проследить мутацию состояния.

И для их использования нам нужно поменять пару строчек в client/index.jsx и server.jsx

...
import { applyMiddleware } from 'redux';
import promiseMiddleware   from 'lib/promiseMiddleware';
...
const store = applyMiddleware(promiseMiddleware)(createStore)(reducer);


Так же не забываем пробрасывать initialState наряду с reducer

И теперь мы можем написать магическое действие createTodo для нашего создателя действий, например

import request from 'axios';

const BACKEND_URL = 'https://webtask.it.auth0.com/api/run/wt-milomord-gmail_com-0/redux-tutorial-backend?webtask_no_cache=1';

export function createTodo(text) {
  return {
    type: 'CREATE_TODO',
    promise: request.post(BACKEND_URL, { text })
  }
}


После небольшого изменения редюсера.
return state.concat(action.res.data.text);


Todo App with async actions

Todo теперь сохраняются в мою внешнюю базу данных. Если мы хотим чтобы они загружались на старте нашего приложения, просто добавим getTodos создатель для действия.

export function getTodos() {
  return {
    type: 'GET_TODOS',
    promise: request.get(BACKEND_URL)
  }
}


Ловим его в редюсере
case 'GET_TODOS':
  return state.concat(action.res.data);


И мы можем вызвать его когда TodosView инициализируется
componentDidMount() {
  this.props.getTodos();
}


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

Постой… А мы не сломали регидрацию состояния?


Да. Давайте это исправим!

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

Ну, это зависит от многих факторов. Основная выгода серверного рендеринга, что мы можем гарантировать хорошее соединение с нашим бэкэндом (который может быть в том же датацентре!). Если пользователь пытается загрузить наш сайт через пропадающее мобильное соединение, например, намного лучше, чтобы он ждал наш сервер для получения начального состояния, нежели чтоб он получал его из разных ресурсов самостоятельно.

Решение этой проблемы в текущей ситуации не так уж и сложно. Ты можешь сделать это несколькими путями, путь которым я делаю, далек от идиеала:

Мы определяем, какие данные нужны компоненту в виде массива создателей действий. Мы можем использовать статическое свойство в определении класса:
static needs = [
  TodoActions.getTodos
]


Также нужна функция, которая поймает все promise вызовы, соберет данные и отправит их
shared/lib/fetchComponentData.js
export default function fetchComponentData(dispatch, components, params) {
  const needs = components.reduce( (prev, current) => {
    return (current.needs || [])
      .concat((current.WrappedComponent ? current.WrappedComponent.needs : []) || [])
      .concat(prev);
    }, []);
    
    const promises = needs.map(need => dispatch(need(params)));
    return Promise.all(promises);
}


Обрати внимание, что мы также должны проверить ключ WrappedComponent, так как вышеупомянутые «умные» компоненты будут обернуты в Connector компонент.

Теперь настраиваем сервер, чтобы он отвечал только тогда, когда у него есть все данные.
import fetchComponentData from 'lib/fetchComponentData';

Router.run(routes, location, (err, routeState) => {
  if (err) return console.error(err);
  
  function renderView() {
    // ... Rest of the old code goes here
    return HTML;  
  }
  
  // Check this is rendering *something*, for safety
  if(routeState)
    fetchComponentData(store.dispatch, routeState.components, routeState.params)
      .then(renderView)
      .then(html => res.end(html))
      .catch(err => res.end(err.message));
});


Wait for server async data complete

Убедись, что удалил создатель действия из onComponentMount, чтобы избежать повторных вызовов, и перезапусти npm run dev чтобы обновить изменения на сервере.

Что мы узнали?


Что мир прекрасен!

Многое еще может быть сделано и многое сделано. В приложении с большим количеством роутов, тебе захочется наверное использовать React Router onLeave обработчик, чтобы загружать все данные нужные компоненту (как например тут), и ловить другие действия с асинхронным API.

Несмотря на все это, я надеюсь ты позволил себе насладится эпическим квестом в большое функциональное будущее.

Ты так же можешь посмотреть конечный результат на Github и почитать побольше про Redux здесь
Tags:
Hubs:
+9
Comments 12
Comments Comments 12

Articles