Сегодня я хочу показать, как используя 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
¦   L-- js
+-- server
¦   L-- storages
+-- shared
¦   +-- actions
¦   +-- components
¦   +-- handlers
¦   L-- stores
L-- 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!

Комментарии (11)


  1. Sergiy
    29.04.2015 16:39

    А как понимать «изоморфное приложение»? Какой-то слишком запутанный термин.


    1. asdf404 Автор
      29.04.2015 16:54

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

      Картинка
      image


    1. DenimTornado
      29.04.2015 16:57

      Если коротко, то Client JS + Server JS = Isomorphic JS


    1. pragmadash
      29.04.2015 20:33

      Недавно делал доклад на CodeFest 2015 как раз на эту тему, слайды можно посмотреть здесь. Позже организаторы обещают и видео на странице доклада.


  1. 4utep
    29.04.2015 16:51

    еще в копилку полезного чтения: www.smashingmagazine.com/2015/04/21/react-to-the-future-with-isomorphic-apps


    1. sferrka
      29.04.2015 20:41

      Этот пример заработал, а в статье нет — ошибки какие-то. Интересно, зачем такие длинные названия аттрибутов «reactid» — изоморфные приложения должны беречь каждый передаваемый байт)


  1. kulakowka
    29.04.2015 18:13

    Это просто великолепно! Огромное спасибо за статью.


  1. dinar_007
    30.04.2015 02:48

    Спасибо за статью. Познавательно


  1. frol
    05.05.2015 11:03

    Подскажите, можно ли как-то ускорить webpack в вашей демо? У меня на Atom машинке оно собирается больше трёх минут. Кеширование почему-то вообще не попадает. webpack --profile --json показывает гору «причин», но у меня опыта работы с NodeJS мало, а с webpack вообще нет.

    Мы с друзьями собираемся попробовать JS для нового проекта, сейчас мы живём на Python (в большей степени на Django замученной до Flask) и по большому счёту нам всё нравится кроме случаев, когда проект развивается в сторону клиентской части (JS).
    Отсюда есть желание найти «идеальный инструмент в мире JS» и оставить Python для background задач.

    На первый взгляд мне понравилось ваше Demo, но чтобы не было мучительно больно потом, я бы хотел задать несколько вопросов сейчас:
    1. Практичен ли описанный вами набор компонент для строительства большого проекта?
    2. Какие подводные камни вы знаете/подозреваете?
    3. Какие-нибудь комментарии относительно производительности babel, NodeJS и других причастных?
    4. Может вы встречали другие серебрянные пули? :) (Например, у меня вот просто несваримость с Vanilla JS и если бы не ES6/ES7, я бы не рискнул нырять в этот мир JS)


    1. asdf404 Автор
      05.05.2015 21:55
      +1

      По поводу webpack'а не подскажу. Решение специально пока что не искал. По поводу вопросов:

      1. большой проект у меня пока что только пишется, но уже могу сказать, что некоторые части лучше выносить в отдельные модули или хотя бы папки (отделить, например блог, галлерею и т.п., от остального сайта).
      2. из подводных камней, некоторые неожиданные проблемы с async/await, но о них я написал в статье (про заверните в try-catch иначе не увидите ошибок). Также недавно столкнулся с проблемой интернационализации приложения с помощью react-intl. Всё в целом хорошо, но хранение переводов — небольшая боль. Нет дефолтных переводов, надо везде таскать все переводы для текущего состояния иначе ловим исключения. А ещё нужно приучить себя всегда закрывать теги: <MyComponent /> — работает, <MyComponent></MyComponent> — тоже работает, <MyComponent> (не закрыл) — не скомпилится. И ещё одно, вы могли заметить, что при рендеринге на сервере делается запрос к API, и после рендеринга и запуска скриптов на клиенте делается снова такой же запрос. Я сейчас работаю над устранением этого досадного бага. По итогам намерен написать статью. Вы можете погуглить решения по словам rehydrate/dehydrate (так называлось это в fluxible) или же serialize/unserialize. Автор flummox уже вроде как запилил фикс, Но я ещё не смотрел.
      3. babel просто транслирует ES6/ES7 > ES5, он не выполняет код сам. Node.JS шустрая, основной затык обычно в запросах к БД. А вот с React на сервере уже не всё так хорошо. Синтетические тесты (ab -c 5 -t 10 ...) TODO списка на моём i7 с 8GiB памяти показали всего лишь около 200RPS, что не очень плохо, если сравнивать с каким-нибудь PHP. Но довольно медленно, если сравнить с другими шаблонизаторами на Node.JS. И при увеличении количества компонентов, которые рендерятся для текущего состояния, производительность продолжает падать. Этого можно избежать в некоторых случаях, если использовать некоторые оптимизации.
      4. серебрянной пули не существует :) Уже давно существуют всякие Meteor'ы, Derby.JS и т.д. Но это полноценные фреймворки и иногда с ними нужно бороться, чтобы сделать что-то нестандартное (сам не работал, но знающие люди так говорят).

      Желаю удачи в вашем проекте :)


  1. karudo
    25.05.2015 20:00

    Спасибо!
    А какие вещи, например, сделаны не совсем правильно?