Pull to refresh

Создание изоморфного приложения на React и Flummox

Reading time 14 min
Views 20K
Сегодня я хочу показать, как используя React, Flummox и Express, создать полноценное изоморфное приложение.

Идея изоморфности давно витала в воздухе, но никто не знал этого слова, поэтому ничего особо не менялось, пока не пришел airbnb.

За полгода до этого появился React, позже появился Flux и множество его реализаций, одна не хуже другой. Но все эти реализации ориентировались только на работу на клиентской стороне, они работали через синглтоны и, зачастую, их нельзя было нормально использовать на сервере. Я успел попробовать несколько, но ни одна мне не понравилась. Пока два месяца назад я не наткнулся на Flummox. Как заявляет разработчик, Flummox создан как раз для изоморфных приложений, он не использует синглтоны, присущие другим реализациям, и максимально прост в использовании.

Ожидается, что вы имеете опыт работы с React и слышали про Flux. Итак, поехали…
Забрать готовый код можно тут.

Шаг 0: Определение идеи


Наша идея состоит в создании приложения для записи других идей. Это будет TODO-лист (как Todo MVC) с сохранением данных на сервере. Требования такие:
  • добавление задач;
  • пометка задачи как "выполнено";
  • удаление (выполненных) задач;

В качестве базы данных будет использоваться внутренняя память процесса, но будет эмулировать использование внешней БД (данные возвращаются через Promise).
Заодно мы узнаем как уже сейчас можно потрогать ES2015 и ES2016 (далее для краткости я буду называть их ES6/ES7) в своём приложении.

Шаг 1: Установка необходимых пакетов


Для сервера у нас будет использоваться Express чтобы не было головной боли с низкоуровненвыми компонентами, Flummox чтобы оперировать данными и React чтобы удобно работать с DOM-деревом, а чтобы это всё запустить нам нужен Babel.

На этом шаге мы проинициализируем наше Express приложение и установим базовые компоненты.

$ express
$ npm install react flummox isomorphic-fetch todomvc-app-css react-router --save
$ npm install babel webpack babel-core babel-loader brfs transform-loader --save-dev

Что же мы только что поставили, помимо React:
  • flummox — тот самый изоморфный Flux;
  • react-router — роутер клиентской части нашего приложения;
  • isomorphic-fetch — полифилл для нового метода fetch, который пришел на замену XMLHttpRequest.
  • todomvc-app-css — пакет со стандартными стилями для TODOMVC приложений;
  • babel, babel-core, babel-loader, brfs и transform-loader — транслятор ES6/ES7 в ES5 и прочие вспомогательные пакеты, необходимые при сборке клиентского приложения;
  • webpack — утилита для сборки клиентской части.

Для запуска мы будем использовать babel-node, т.к. он позволяет на лету транслировать ES6/ES7 код в ES5. Поэтому добавим команду запуска в package.json:

"scripts": {
  "start": "babel-node --stage 0 ./bin/www"
}

Шаг 2: Скелет приложения


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

.
├── bin
├── client
├── public
│   └── js
├── server
│   └── storages
├── shared
│   ├── actions
│   ├── components
│   ├── handlers
│   └── stores
└── utils

Теперь нужно определить структуру приложения и создать базовые компоненты: TodoList, TodoInput и TodoItem — список, поле ввода новой задачи и отдельный элемент списка (отдельная задача), соответственно. Компоненты будут лежать в папке shared/components, хранилища (stores) в папке shared/stores, а действия (actions) в папке shared/actions.
Логика приложения разделена на серверную, клиентскую и общую, и находится в папках server, client и shared, соответственно. Папка shared как раз и содержит все изоморфные компоненты, которые будут использовать на клиенте и сервере.

Код основных компонентов, нужных для отображения:

shared/components/TodoList.js
import React from 'react';
import TodoItem from './TodoItem';

class TodoList extends React.Component {
    onToggleStatus(id, completed) {
        this.props.onToggleStatus(id, completed);
    }

    onDeleteTask(id) {
        this.props.onDeleteTask(id);
    }

    render() {
        return (
            <ul className="todo-list">
                {this.props.tasks.map(task =>
                    <TodoItem key={task.id} task={task}
                              onToggleStatus={this.onToggleStatus.bind(this, task.id)}
                              onDeleteTask={this.onDeleteTask.bind(this, task.id)} />
                )}
            </ul>
        );
    }
}

export default TodoList;


shared/components/TodoItem.js
import React from 'react';

class TodoItem extends React.Component {
    constructor(props) {
        super(props);

        this.state = props.task;
    }

    handleToggleStatus() {
        let completed = this.refs.completed.getDOMNode().checked;
        this.props.onToggleStatus(completed);
        this.setState({completed});
    }

    handleDeleteTask() {
        this.props.onDeleteTask();
    }

    render() {
        return (
            <li className={this.state.completed ? 'completed' : ''}>
                <div className="view">
                    <input className="toggle"
                           type="checkbox"
                           defaultChecked={this.state.completed}
                           onChange={this.handleToggleStatus.bind(this)}
                           ref="completed" />
                    <label>{this.state.text}</label>
                    <button className="destroy"
                            onClick={this.handleDeleteTask.bind(this)} />
                </div>
                <input className="edit" defaultValue={this.state.text} />
            </li>
        );
    }
}

export default TodoItem;


Добавим обработчик всех (кроме API) поступающих запросов, которые будут обрабатываться роутером реакта (см. ниже):

Код
app.use(async function (req, res, next) {
    let flux = new Flux();

    // здесь создаётся роутер, который будет обрабатывать все запросы клиента
    let router = Router.create({
        routes: routes,
        location: req.url
    });
    let {Handler, state} = await new Promise((resolve, reject) => {
        router.run((Handler, state) =>
            resolve({Handler, state})
        );
    });

    // инициализация хранилища, см. шаг №4
    await performRouteHandlerStaticMethod(state.routes, 'routerWillRun', {state, flux});

    // рендеринг приложения в строку
    let html = React.renderToString(
        <FluxComponent flux={flux}>
            <Handler {...state} />
        </FluxComponent>
    );
    
    // неизменяемые части документа отдаются простой строкой, т.к. это повышает производительность
    res.send(`
        <!DOCTYPE html>
        <html>
            <head>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width, initial-scale=1">
                <title>HabraIsoTODO</title>
                <link rel="stylesheet" href="/css/index.css">
            </head>
            <body>
                <div id="app">
                    ${html}
                </div>
            </body>
        </html>`
    );
});


Отлично, теперь у нас есть основной обработчик клиентских запросов.

Здесь мы используем новые возможности, которые станут доступны в ES7 — async/await. Они позволяют избавить код от callback-hell (который раньше приходилось решать с помощью замечательного модуля async или подобных).

Совет: оберните все операции внутри этого обработчика в try-catch блок для отлова ошибок. Т.к. если что-то сломается внутри, то без try-catch вы не увидите сообщения об ошибке.

Шаг 3: API


Добавим немного API, которое позволит взаимодействовать клиенту и серверу. Будем использовать REST подход, т.к. он идеально вписывается в данную задачу. Определим базовые пути:

GET    /api/tasks           # все задачи
POST   /api/tasks           # создать задачу
PUT    /api/tasks           # обновить все задачи
GET    /api/tasks/active    # только активные
GET    /api/tasks/completed # только завершенные
DELETE /api/tasks/completed # удалить завершенные
PUT    /api/tasks/:id       # обновить определённую задачу
DELETE /api/tasks/:id       # удалить определённую задачу

Затем запишем их в виде роутов:

server/routes.js
import {Router} from 'express';
import MemoryStorage from './storages/MemoryStorage';
import http from 'http';

let router = new Router();
let storage = new MemoryStorage();

router.get('/tasks', async (req, res) => {
    res.json(await storage.list());
});

router.post('/tasks', async (req, res, next) => {
    if (!req.body.text || !req.body.text.length) {
        let err = new Error(http.STATUS_CODES[400]);
        err.status = 400;
        return next(err);
    }
    let task = await storage.save({
        text: req.body.text.substr(0, 256),
        completed: false
    });
    res.status(201).send(task);
});

router.put('/tasks', async (req, res) => {
    let completed = req.body.completed;
    let tasks = (await storage.list()).map(task => {
        return storage.update(task.id, {
            text: task.text,
            completed: Boolean(completed)
        });
    });
    res.status(201).json(await Promise.all(tasks));
});

router.get('/tasks/active', async (req, res) => {
    res.json(await storage.list((task) => !task.completed));
});

router.get('/tasks/completed', async (req, res) => {
    res.json(await storage.list((task) => task.completed));
});

router.delete('/tasks/completed', async (req, res, next) => {
    let deleted = [];
    try {
        let items = await storage.list((task) => task.completed);
        items.forEach(async (item) => {
            deleted.push(item.id);
            await storage.remove(item.id);
        });
        res.status(200).json({deleted});
    } catch (err) {
        next(err);
    }
});

router.get('/tasks/:id', async (req, res, next) => {
    let id = req.params.id;
    try {
        var item = await storage.fetch(id);
        res.status(200).send(item);
    } catch (err) {
        return next(err);
    }
});

router.put('/tasks/:id', async (req, res, next) => {
    let id = req.params.id;
    try {
        var item = await storage.fetch(id);
    } catch (err) {
        return next(err);
    }

    let updated = item;
    Object.keys(req.body).forEach((key) => {
        updated[key] = req.body[key];
    });

    let task = await storage.update(id, updated);
    res.status(200).json(task);
});

router.delete('/tasks/:id', async (req, res, next) => {
    let id = req.params.id;
    try {
        let removed = await storage.remove(id);
        res.status(200).send({id, removed});
    } catch (err) {
        return next(err);
    }
});

export default router;


Теперь примонтируем роуты к основному приложению:

import api from './server/routes';
// ...
app.use('/api', api);

Т.к. данные должны где-то храниться, то давайте создадим хранилище:

server/storages/MemoryStorage.js
import http from 'http';

function clone(obj) {
    return JSON.parse(JSON.stringify(obj));
}

export default class MemoryStorage {
    constructor() {
        this._items = {
            1: {
                id: 1,
                text: 'Rule the World',
                completed: false
            },
            2: {
                id: 2,
                text: 'Be an Awesome',
                completed: true
            }
        };
    }

    count() {
        return new Promise((resolve) => {
            resolve(Object.keys(this._items).length);
        });
    }

    save(item) {
        return new Promise((resolve) => {
            let obj = clone(item);
            obj.id = Math.round(Math.random() * 10000000).toString(36);
            this._items[obj.id] = obj;
            resolve(obj);
        });
    }

    fetch(id) {
        return new Promise((resolve, reject) => {
            if (!this._items[id]) {
                let err = new Error(http.STATUS_CODES[404]);
                err.status = 404;
                return reject(err);
            }
            resolve(this._items[id]);
        });
    }

    update(id, item) {
        return new Promise((resolve, reject) => {
            let obj = clone(item);
            let existed = this._items[id];
            if (!existed) {
                let err = new Error(http.STATUS_CODES[404]);
                err.status = 404;
                return reject(err);
            }

            obj.id = existed.id;
            this._items[obj.id] = obj;
            resolve(obj);
        });
    }

    remove(id) {
        return new Promise((resolve, reject) => {
            if (!this._items[id]) {
                let err = new Error(http.STATUS_CODES[404]);
                err.status = 404;
                return reject(err);
            }
            delete this._items[id];
            resolve(true);
        });
    }

    list(check) {
        return new Promise((resolve) => {
            let items = Object.keys(this._items).map((key) => this._items[key]).reduce((memo, item) => {
                if (check && check(item)) {
                    memo.push(item);
                } else if (!check) {
                    memo.push(item);
                }
                return memo;
            }, []);

            resolve(items);
        });
    }
}


Этот компонент будет хранить наши задачи в памяти процесса. Внимательный читатель мог заметить, что мы возвращаем Promise из всех методов. Это как раз то место, где эмулируется работа с внешней БД.

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

Шаг 4: Компоненты и хранилище


API это, конечно хорошо, но нам нужно ещё всё связать с компонентами. Для этого создадим набор Action'ов и Store, которые и будут общаться с сервером, возвращая состояние для отрисовки в наши компоненты.

Сперва объявим наши Action и Store в главном классе Flux'а:

shared/Flux.js
import {Flux} from 'flummox';
import TodoListAction from './actions/TodoActions';
import TodoListStore from './stores/TodoStore';

export default class extends Flux {
    constructor() {
        super();

        this.createActions('todo', TodoListAction);
        this.createStore('todo', TodoListStore, this);
    }
}


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

Теперь объявим сами действия и хранилище:

shared/actions/TodoActions.js
import {Actions} from 'flummox';
import fetch from 'isomorphic-fetch';

// мы заранее пропишем базовый хост, т.к. на сервере у нас не будет возможности получить location.host
const API_HOST = 'http://localhost:3000';

class TodoListActions extends Actions {
    async getTasks() {
        return (await fetch(`${API_HOST}/api/tasks`, {
            headers: {
                'Accept': 'application/json'
            }
        })).json();
    }

    async getActiveTasks() {
        return (await fetch(`${API_HOST}/api/tasks/active`, {
            headers: {
                'Accept': 'application/json'
            }
        })).json();
    }

    async getCompletedTasks() {
        return (await fetch(`${API_HOST}/api/tasks/completed`, {
            headers: {
                'Accept': 'application/json'
            }
        })).json();
    }

    async deleteCompletedTasks() {
        return (await fetch(`${API_HOST}/api/tasks/completed`, {
            method: 'DELETE',
            headers: {
                'Accept': 'application/json'
            }
        })).json();
    }

    async createTask(task) {
        return (await fetch(`${API_HOST}/api/tasks`, {
            method: 'POST',
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(task)
        })).json();
    }

    async deleteTask(id) {
        return (await fetch(`${API_HOST}/api/tasks/${id}`, {
            method: 'DELETE',
            headers: {
                'Accept': 'application/json'
            }
        })).json();
    }

    async toggleTask(id, completed) {
        return (await fetch(`${API_HOST}/api/tasks/${id}`, {
            method: 'PUT',
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({completed})
        })).json();
    }

    async toggleAll(completed) {
        return (await fetch(`${API_HOST}/api/tasks`, {
            method: 'PUT',
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({completed})
        })).json();
    }
}

export default TodoListActions;


shared/stores/TodoStore.js

import {Store} from 'flummox';

class TodoListStore extends Store {
    constructor(flux) {
        super();

        let actions = flux.getActionIds('todo');

        // связывание действий и соответствующих методов хранилища
        this.register(actions.getTasks, this.handleNewTasks);
        this.register(actions.getActiveTasks, this.handleNewTasks);
        this.register(actions.getCompletedTasks, this.handleNewTasks);
        this.register(actions.createTask, this.handleNewTask);
        this.register(actions.toggleTask, this.handleUpdateTask);
        this.register(actions.toggleAll, this.handleNewTasks);
        this.register(actions.deleteTask, this.handleDeleteTask);
        this.register(actions.deleteCompletedTasks, this.handleDeleteTasks);
    }

    handleNewTask(task) {
        if (task && task.id) {
            this.setState({
                tasks: this.state.tasks.concat([task])
            });
        }
    }

    handleNewTasks(tasks) {
        this.setState({
            tasks: tasks ? tasks : []
        });
    }

    handleUpdateTask(task) {
        let id = task.id;
        this.setState({
            tasks: this.state.tasks.map(t => {
                return (t.id == id) ? task : t;
            })
        });
    }

    handleDeleteTask(task) {
        let id = task.id;
        this.setState({
            tasks: this.state.tasks.map(t => {
                if (t.id != id) {
                    return t;
                }
            }).filter(Boolean)
        });
    }

    handleDeleteTasks({deleted}) {
        this.setState({
            tasks: this.state.tasks.filter(task =>
                deleted.indexOf(task.id) < 0
            )
        });
    }
}

export default TodoListStore;


На самом деле, с сервером общается только Action, а Store лишь хранит данные и связывает их с компонентами.

В конструкторе хранилища (TodoStore) мы регистрируем обработчики, которые будут автоматически вызываться при получении данных от сервера.

Теперь после вызова метода из Action'а, он будет автоматически обновлять состояние Store, а тот, в свою очередь, состояние компонента.

Шаг 5: Роутинг


Одной из важнейших составляющих любого современного приложения является роутинг. Клиентский роутинг отдаётся в компонент реакта и уже он решает что показывать.
react-router позволяет задавать пути в декларативном стиле, что как раз в духе React'а. Давайте объявим нужные нам пути:

client/routes.js
import React from 'react';
import {Route, DefaultRoute, NotFoundRoute} from 'react-router';
import AppHandler from '../shared/handlers/AppHandler';
import TodoHandler from '../shared/handlers/TodoHandler';

export default (
    <Route handler={AppHandler}>
        <DefaultRoute handler={TodoHandler} />
        <Route name="all" path="/" handler={TodoHandler} action="all" />
        <Route name="active" path="/active" handler={TodoHandler} action="active" />
        <Route name="completed" path="/completed" handler={TodoHandler} action="completed" />
    </Route>
);


Как видно, для каждого маршрута есть собственный обработчик (handler). Обработчики в нашем приложении будут загружать данные и являться т.н. "умными" (см. прикреплённые ссылки) компонентами. Их будет два:

shared/handlers/AppHandler.js
import React from 'react';
import {RouteHandler} from 'react-router';

class AppHandler extends React.Component {
    render() {
        return (
            <div>
                <section className="todoapp">
                    <RouteHandler {...this.props} key={this.props.pathname} />
                </section>
            </div>
        );
    }
}

export default AppHandler;


shared/handlers/TodoHandler.js
import React from 'react';
import Flux from 'flummox/component';
import TodoList from '../components/TodoList';
import TodoInput from '../components/TodoInput';
import ItemsCounter from '../components/ItemsCounter';
import ToggleAll from '../components/ToggleAll';

class TodoHandler extends React.Component {
    static async routerWillRun({flux, state}) {
        let action = state.routes[state.routes.length - 1].name;
        let todoActions = flux.getActions('todo');
        switch (action) {
            case 'active':
                await todoActions.getActiveTasks();
                break;
            case 'completed':
                await todoActions.getCompletedTasks();
                break;
            case 'all':
            default:
                await todoActions.getTasks();
                break;
        }
    }

    async handleNewTask(text) {
        let actions = this.props.flux.getActions('todo');
        await actions.createTask({text});
    }

    async handleToggleStatus(id, status) {
        let actions = this.props.flux.getActions('todo');
        await actions.toggleTask(id, status);
    }

    async handleToggleAll(status) {
        let actions = this.props.flux.getActions('todo');
        await actions.toggleAll(status);
    }

    async handleDeleteTask(id) {
        let actions = this.props.flux.getActions('todo');
        await actions.deleteTask(id);
    }

    async handleDeleteCompletedTasks(id) {
        let actions = this.props.flux.getActions('todo');
        await actions.deleteCompletedTasks();
    }

    render() {
        return (
            <div>
                <header className="header">
                    <h1>todos</h1>
                    <TodoInput handleNewTask={this.handleNewTask.bind(this)} />
                </header>
                <section className="main">
                    <Flux connectToStores={['todo']}>
                        <ToggleAll onToggleStatus={this.handleToggleAll.bind(this)} />
                    </Flux>
                    <Flux connectToStores={['todo']}>
                        <TodoList onToggleStatus={this.handleToggleStatus.bind(this)}
                                  onDeleteTask={this.handleDeleteTask.bind(this)} />
                    </Flux>
                </section>
                <footer className="footer">
                    <Flux connectToStores={['todo']}>
                        <ItemsCounter count={0} />
                    </Flux>
                    <ul className="filters">
                        <li>
                            <a href="/">All</a>
                        </li>
                        <li>
                            <a href="/active">Active</a>
                        </li>
                        <li>
                            <a href="/completed">Completed</a>
                        </li>
                    </ul>
                    <button className="clear-completed" onClick={this.handleDeleteCompletedTasks.bind(this)}>
                        Clear completed
                    </button>
                </footer>
            </div>
        );
    }
}

export default TodoHandler;


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

Также видно, что некоторые компоненты оборачиваются в компонент <Flux />. Его свойство connectToStores устанавливает связь между хранилищем и дочерним компонентом. Всё, что находится в state хранилища становится доступно в props дочернего компонента.

Шаг 6: Рендеринг главной страницы


Настало время отрендерить наши компоненты. Но чтобы это сделать правильно, нам нужно предварительно загрузить все существующие задачи. Как вы помните, задачи загружаются через HTTP API. Но у нас для этого есть TodoAction, в котором описан метод getTasks. В примере к Flummox описан метод с жутко-длинным названием performRouteHandlerStaticMethod, который должен вызвать загрузку данных для хранилища с помощью описанного выше метода routerWillRun.

Добавим его себе.

utils/performRouteHandlerStaticMethod.js
export default async function performRouteHandlerStaticMethod(routes, methodName, ...args) {
  return Promise.all(routes
    .map(route => route.handler[methodName])
    .filter(method => typeof method === 'function')
    .map(method => method(...args))
  );
}


Его нужно добавить в серверную и клиентскую части приложения.

import performRouteHandlerStaticMethod from '../utils/performRouteHandlerStaticMethod';

await performRouteHandlerStaticMethod(state.routes, 'routerWillRun', {state, flux});


Как это выглядит можно посмотреть здесь и здесь.

Теперь при запуске этого обработчика будет вызван метод routerWillRun, который загрузит необходимые данные в Store и они отобразятся в компоненте.

Шаг 7: Сборка клиентской части


Мы не зря установили webpack. Он поможет нам собрать наше приложение для работы на клиенте. Для этого давайте его сконфигурируем.

webpack.config.js
var path = require('path');
var webpack = require('webpack');

var DEBUG = process.env.NODE_ENV !== 'production';

var plugins = [
    new webpack.optimize.OccurenceOrderPlugin()
];

if (!DEBUG) {
    plugins.push(
        new webpack.optimize.UglifyJsPlugin()
    );
}

module.exports = {
    cache: DEBUG,
    debug: DEBUG,
    target: 'web',
    devtool: DEBUG ? '#inline-source-map' : false,
    entry: {
        client: ['./client/app.js']
    },
    output: {
        path: path.resolve('public/js'),
        publicPath: '/',
        filename: 'bundle.js',
        pathinfo: false
    },
    module: {
        loaders: [
            {
                test: /\.js/,
                loaders: ['transform?brfs', 'babel-loader?stage=0']
            },
            {
                test: /\.json$/,
                loaders: ['json-loader']
            }
        ]
    },
    plugins: plugins,
    resolve: {
        extensions: ['', '.js', '.json', '.jsx']
    }
};


Собираться приложение будет в файл bundle.js, поэтому его надо подключить на клиенте:

<script type="text/javascript" src="/js/bundle.js"></script>


Добавим команду для сборки в package.json:

"scripts": {
  "build": "webpack"
}

Теперь можно запускать сборку:

$ npm run build

Спустя некоторое время появится файл /public/js/bundle.js, который и является клиентской версией нашего приложения.

Шаг 8: Посмотрим что получилось


Мы только что создали изоморфное приложение. Теперь можем запустить его npm start и посмотреть что получилось.

Послесловие


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

Также хочется выразить благодарность Ивану Кречетову за вычитку черновика и рекомендации по улучшению качества кода.

Полезное чтение:



Happy coding!
Tags:
Hubs:
+19
Comments 25
Comments Comments 25

Articles