Morearty.js — новая pure JavaScript прослойка над React для разумного управления состоянием

в 12:31, , рубрики: javascript, morearty, moreartyjs, React, Веб-разработка, Программирование

Введение

В прошлом году произошло революционное событие в разработке веб-приложений: компания Facebook выпустила React — библиотеку для создания пользовательских интерфейсов в браузере, использующую радикально отличающийся подход к структурированию кода и написанию графических компонентов. Вместо того, чтобы, имея размётку, «цепляться» к ней из JavaScript, т.е. работать напрямую с DOM, вводится понятие компонента — самодостаточной единицы, которая представляет собой легковесное описание DOM. Когда «реакт» определяет, что необходимо перерисовать что-либо на странице, он рассматривает дельту изменений этого виртуального DOM и перерисовывает только изменённые части. Благодаря тому, что при таком подходе обращение к DOM происходит гораздо реже, возрастает отзывчивость интерфейса и скорость работы: работа JIT не прерывается тяжеловесными обращениями к нативному коду.

React предоставляет возможность сохранения состояния для каждого компонента, при изменении этого состояния он запускает «перерисовку». Таким образом, состояние вашего приложения может оказаться «размазанным» по дереву компонентов. Это не является недостатком React: наоборот, библиотека обеспечивает базовые необходимые блоки и не навязывает лишних правил, когда это возможно. Кто пробовал строить приложение по такой схеме, когда каждый компонент имеет своё изменяемое состояние, рано или поздно столкнулся с возрастающей неуправляемостью кода и сложностью понимания происходящего в приложении. В связи с этим начали появляться библиотеки-надстройки над React, наиболее известной из которых является Om на ClojureScript от David Nolen. Почитать подробнее про Om можно в его блоге.

Каждый React-компонент имеет возможность переопределить метод shouldComponentUpdate, чтобы помочь библиотеке узнать, необходима ли его перерисовка. По умолчанию React возвращает true, и это значит, что «перерисовке» подвергаются все компоненты. Под перерисовкой в данном контексте понимается вызов метода render каждого компонента для построения виртуального DOM, который впоследствии сравнивается с предыдущим значением, после чего перерисовываются в реальном DOM только затронутые части.

В Om был использован централизированный подход к управлению состоянием: оно хранится в ClojureScript атоме на самом верху иерархии компонентов. Дочерние же компоненты получают «указатели» на подразделы этого состояния, которое является иммутабельным значением. Это довольно очевидное решение с точки зрения функционального программирования. Таким образом это позволяет определить метод shouldComponentUpdate так, чтобы он сравнивал текущее состояние с предыдущим с помощью оператора ===, а также хранить всё состояние в одном месте, что значительно упрощает понимание работы приложения.

В компании, в которой я работаю, для проекта было принято решение создать концептуально похожую надстройку над React с нуля и на чистом JavaScript, т.к. использовать ClojureScript не было возможности и желания. Так было положено начало Morearty.js — «more» + «react» + «art» (от названия компании).

Базовые строительные блоки

Morearty.js для своей работы предоставляет следующие модули.

Структуры данных

В JavaScript отсутствуют встроенные иммутабельные персистентные структуры данных, вследствие чего были разработаны собственные Map и Vector. Библиотека Mori (структуры данных ClojureScript) была использована для прототипа, но в дальнейшем было решено от неё отказаться во-первых из-за размера — не хотелось тянуть лишнюю зависимость, во-вторых — из-за не ООП-ориентированного API.

Map

Реализация immutable hash map на основе hash trie, использующая hashcode аналогичный методу Object.hashcode в Java. Это персистентная структура данных, которая при «обновлении» пересоздаёт только узлы на пути к изменённому элементу. Поверхностный бенчмарк показал производительность в несколько раз превосходящую аналогичный Map из Mori (больше — лучше):

Morearty.js — новая pure JavaScript прослойка над React для разумного управления состоянием

Vector

Реализация immutable vector, использующая array copying. Не самое эффективное решение, но для большинства задач его производительности достаточно. В будущем крайне вероятна реимплементация, когда определимся со структурой данных, обеспечивающую эффективную вставку в любую часть вектора и приличную итерацию. Были рассмотрены RRB-trees, но на тот момент показались слишком сложными, и трата времени на эту деятельность была неоправдана. Поэтому на данный момент вектор ожидаемо проседает на вставке, но в разумных пределах:

Morearty.js — новая pure JavaScript прослойка над React для разумного управления состоянием

Честно говоря, я удивлён, что prepend в Mori выглядит быстрее append-а, т.к. в Clojure conj для вектора обеспечивает эффективное добавление в конец.

API

Обе эти структуры обеспечивают максимально единообразный интерфейс в объектно-ориентированном стиле. Список поддерживаемых методов (описание приведено для неочевидных случаев; методы, выделенные курсивом, определены на базовом классе Associative, от которого наследуются обе структуры):

  • fill(var_args) — заполнение данными;
  • isEmpty();
  • get(key);
  • contains(key);
  • update(key, f) — обновление или добавление значения, если оно отсутствует;
  • updateIfExists(key, f) — обновление значения;
  • assoc(key, value) — ассоциирование (добавление) нового значения;
  • dissoc(key) — дисассоциирование (удаление) значения;
  • join(other) — слияние (для векторов — конкатенация);
  • reduce(f, acc) — редукция в стиле Array.prototype.reduce;
  • map(f) — отображение в стиле Array.prototype.map;
  • foreach(f);
  • filter(pred) — фильтрация по предикату;
  • find(pred) — поиск по предикату;
  • equals(other);
  • size();
  • toString();
  • isInstance(obj) — проверка, является ли obj экземпляром данной структуры;
  • isAssociative(obj) — проверка, является ли obj экземпляром Map или Vector;
  • getIn(path) — получение вложенного значения;
  • updateIn(path, f) — обновление вложенного значения;
  • dissocIn(path) — удаление вложенного значения.

Методы, специфичные для Map:

  • entries() — получение массива пар;
  • keys() — получение массива ключей;
  • values() — получение массива значений;
  • fillFromObject(obj, f) — заполнение из JavaScript объекта с возможностью преобразования каждого значения;
  • toObject(f) — преобразование в JavaScript объект с возможностью преобразования каждого значения;
  • merge(otherMap) — глубокое слияние двух Map-ов.

Методы, специфичные для Vector:

  • insertAt(index, value) — вставка по индексу;
  • prepend(value) — добавление в начало;
  • append(value) — добавление в конец;
  • fillFromArray(arr, f) — заполнение из JavaScript массива с возможностью преобразования каждого значения;
  • toArray(f) — преобразование в JavaScript массив с возможностью преобразования каждого значения.

Статические методы сопутствующего модуля Data.Util:

  • toJs(associative) — глубокое преобразование в JavaScript структуры (объекты и массивы);
  • fromJs(js) — глубокое преобразование (импорт) из JavaScript;
  • groupBy(vec, key) — группировка вектора по ключу.

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

var m = Map.fill('key', 'value');
assert.isTrue(m.update('key', function (x) { return x; }) === m);

Это оказывается крайне полезным при использовании в shouldComponentUpdate. Все сценарии, в которых такое поведение возможно, покрыты соответствующими тестами.

Binding

Этот модуль обеспечивает функционал, сходный с ClojureScript атомом, но значительно расширенный и «заточенный» на удобную работу со структурами Map и Vector. В нём хранится состояние всего приложения в виде иммутабельной структуры. Любое изменение состояния производится через него. Основные возможности:

  • поддержка listeners — при изменении состояния соответствующие заинтересованные слушатели оповещаются;
  • поддержка «транзакционности» — возможности изменить несколько значений и только потом уведомить слушателей (или опустить уведомление);
  • удобная модификация хранимого состояния методами update, assoc, dissoc, clear;
  • поддержка «sub-bindings» — возможности создать байндинг, указывающий на вложенный элемент состояния, полностью синхронизированный с родителем. Таки образом можно отдавать дочерним компонентам только ту часть состояния, которая им доступна.

Для адресации вложенных значений могут использоваться строки в виде 'root.subpath1.subpath2.0' или массивы ['root', 'subpath1', 'subpath2', 0].

Context

Центральный модуль Morearty.js — он создаётся при инициализации и далее вся работа ведётся через него. Для удобства он выставляет наружу модули Map, Vector, Binding и другие, а именно:

  • Util — часто используемые функции;
  • Data.Util — часто используемые функции для работы со структурами данных (toJs, fromJs, groupBy);
  • Callback — модуль, упрощающий создание колбэков, обновляющих значения внутри Binding;
  • History — работа с историей изменений, интегрированная с Binding.

Context позволяет создавать компоненты, которые получают корректно определённый метод shouldComponentUpdate, а также метод getState(), который возвращает Binding компонента. По умолчанию он хранится в this.props.state и соответственно передаётся дочерним компонентам через атрибут state. Эти детали станут более очевидны ниже, когда будут рассмотрены на примере.

Сборка

Morearty.js собирается Grunt-ом с использованием urequire. Таким образом его можно загрузить, например, посредством require.js или присвоить глобальной переменной в браузере. Весь код покрыт тестами, которых на данный момент насчитывается более 400.

TodoMVC

Рассмотрим реализацию знаменитого TodoMVC, используя Morearty. Для последовательности изложения и удобства ознакомления код будет помещён в один файл app.js без использования require.js. Рабочее приложение доступно по этой ссылке, исходники — на Github.

index.html

Для маршрутизации будет использоваться director, файл стилей — стандартный из TODOMVC. Рендериться приложение будет в div с идентификатором «root».

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Morearty.js • TodoMVC</title>

  <script src="js/libs/react-with-addons-0.10.0.min.js"></script>
  <script src="js/libs/morearty-0.1.0.min.js"></script>
  <script src="js/libs/director-1.2.3.min.js"></script>

  <link rel="stylesheet" href="css/base.css">
</head>

<body>
<div id="root"></div>
<script src="js/app.js"></script>
</body>

</html>
app.js

Прежде всего необходимо создать контекст:

var Ctx = Morearty.createContext(React, {
    nowShowing: 'all',
    items: [{
      title: 'My first task',
      completed: false,
      editing: false
    }]
  }
);

В nowShowing будет храниться выбранный в данный момент раздел приложения (all | active | completed), в items — список TODO элементов. Morearty.js сам рекурсивно сконвертирует структуры данных во встроенные Map и Vector при необходимости.

Создаётся Bootstrap компонент, который будет инициализировать приложение (JSX не будет использоваться при имплементации):

var Bootstrap = Ctx.createClass({
  componentWillMount: function () {
    Ctx.init(this);
  },

  render: function () {
    return App({ state: Ctx.state() });
  }
});

При монтировании инициализируется контекст: подключается глобальный слушатель, который запускает render в случае каких-либо изменений в состоянии.

Создаётся компонент приложения, в котором инициализирутся маршрутизация:

var NOW_SHOWING = Object.freeze({ ALL: 'all', ACTIVE: 'active', COMPLETED: 'completed' });

var App = Ctx.createClass({
  componentDidMount: function () {
    var state = this.getState();
    Router({
      '/': state.assoc.bind(state, 'nowShowing', NOW_SHOWING.ALL),
      '/active': state.assoc.bind(state, 'nowShowing', NOW_SHOWING.ACTIVE),
      '/completed': state.assoc.bind(state, 'nowShowing', NOW_SHOWING.COMPLETED)
    }).init();
  },

  render: function () {
    var state = this.getState();
    var _ = Ctx.React.DOM;
    return  _.section({ id: 'todoapp' },
      Header({ state: state }),
      TodoList({ state: state }),
      Footer({ state: state })
    );
  }
});

Обратите внимание, что для получения байндинга состояния используется метод getState. При переходе в тот или иной раздел устанавливается свойство nowShowing. Состояние передаётся в дочерние компоненты через свойство state.

Компонент заголовка:

var Header = Ctx.createClass({
  componentDidMount: function () {
    this.refs.newTodo.getDOMNode().focus();
  },

  onAddTodo: function (event) {
    var title = event.target.value;
    if (title) {
      this.getState().update('items', function (todos) {
        return todos.append(Ctx.Data.Map.fill(
          'title', title,
          'completed', false,
          'editing', false
        ));
      });
      event.target.value = '';
    }
  },

  render: function () {
    var _ = Ctx.React.DOM;
    return _.header({ id: 'header' },
      _.h1(null, 'todos'),
      _.input({
        id: 'new-todo',
        ref: 'newTodo',
        placeholder: 'What needs to be done?',
        onKeyPress: Ctx.Callback.onEnter(this.onAddTodo)
      })
    );
  }
});

Наибольший интерес представляет обработчик onAddTodo. В нём обновляется вложенное значение «items» с помощью метода update, который добавляет в конец вектора новый элемент.

Компонент списка:

var TodoList = Ctx.createClass({
  onToggleAll: function (event) {
    var completed = event.target.checked;
    this.getState().update('items', function (items) {
      return items.map(function (item) {
        return item.assoc('completed', completed);
      });
    });
  },

  render: function () {
    var state = this.getState();
    var nowShowing = state.val('nowShowing');
    var itemsBinding = state.sub('items');
    var items = itemsBinding.val();

    var isShown = function (item) {
      switch (nowShowing) {
        case NOW_SHOWING.ALL:
          return true;
        case NOW_SHOWING.ACTIVE:
          return !item.get('completed');
        case NOW_SHOWING.COMPLETED:
          return item.get('completed');
      }
    };

    var renderTodo = function (item, index) {
      return isShown(item) ? TodoItem({ state: itemsBinding.sub(index) }) : null;
    };

    var allCompleted = !items.find(function (item) {
      return !item.get('completed');
    });

    var _ = Ctx.React.DOM;
    return _.section({ id: 'main' },
      items.size() ? _.input({ id: 'toggle-all', type: 'checkbox', checked: allCompleted, onChange: this.onToggleAll }) : null,
      _.ul({ id: 'todo-list' },
        items.map(renderTodo).toArray()
      )
    );
  }
});

Обработчик onToggleAll обновляет поле «completed» всех элементов списка. В render, перед тем как отобразить список элементов, он фильтруется согласено выбранному разделу. Обратите внимание, как передаётся состояние самим элементам: им нет необходимости знать о чём-либо кроме своего собственно набора свойств «title», «completed», «editing», поэтому они получают под-состояние с помощью itemsBinding.sub(index). Таким образом метод getState, вызванный внутри элемента списка, вернёт байндинг, указывающий на соответствующий элемент в глобальном состоянии.

Теперь сам TodoItem:

var TodoItem = Ctx.createClass({
  componentDidUpdate: function () {
    if (this.getState().val('editing')) {
      var node = this.refs.editField.getDOMNode();
      node.focus();
      node.setSelectionRange(node.value.length, node.value.length);
    }
  },

  onToggleCompleted: function (event) {
    this.getState().assoc('completed', event.target.checked);
    return false;
  },

  onToggleEditing: function () {
    this.getState().update('editing', Ctx.Util.not);
    return false;
  },

  onEnter: function (event) {
    this.getState().atomically()
      .assoc('title', event.target.value)
      .assoc('editing', false)
      .commit();
    return false;
  },

  render: function () {
    var state = this.getState();
    var item = state.val();

    var liClass = Ctx.React.addons.classSet({
      completed: item.get('completed'),
      editing: item.get('editing')
    });
    var title = item.get('title');

    var _ = Ctx.React.DOM;
    return _.li({ className: liClass },
      _.div({ className: 'view' },
        _.input({
          className: 'toggle',
          type: 'checkbox',
          checked: item.get('completed'),
          onChange: this.onToggleCompleted
        }),
        _.label({ onClick: this.onToggleEditing }, title),
        _.button({ className: 'destroy', onClick: state.dissoc.bind(state, '') })
      ),
      _.input({
        className: 'edit',
        ref: 'editField',
        value: title,
        onChange: Ctx.Callback.assoc(state, 'title'),
        onKeyPress: Ctx.Callback.onEnter(this.onEnter),
        onBlur: this.onToggleEditing
      })
    )
  }
});

Здесь заслуживает внимания вспомогательный вызов Ctx.Callback.assoc(state, 'text'), который используется для удобного обновления состояния при изменении текста в поле ввода, а также dissoc, который позволяет элементу удалить себя из списка, не используя каких-либо колбэков, передаваемых из родителя. Также в обработчике onEnter можно видеть пример использования «транзакции» для обновления двух значений сразу. Это необходимо, чтобы глобальный слушатель не перезапускал render дважды.

Компонент Footer:

var Footer = Ctx.createClass({
  onClearCompleted: function () {
    this.getState().update('items', function (items) {
      return items.filter(function (item) {
        return !item.get('completed');
      });
    });
  },

  render: function () {
    var state = this.getState();
    var nowShowing = state.val('nowShowing');

    var items = state.val('items');
    var completedItems = items.filter(function (item) {
      return item.get('completed');
    });
    var completedItemsCount = completedItems.size();

    var _ = Ctx.React.DOM;
    return _.footer({ id: 'footer' },
      _.span({ id: 'todo-count' }, items.size() - completedItemsCount + ' items left'),
      _.ul({ id: 'filters' },
        _.li(null, _.a({ className: nowShowing === NOW_SHOWING.ALL ? 'selected' : '', href: '#/' }, 'All')),
        _.li(null, _.a({ className: nowShowing === NOW_SHOWING.ACTIVE ? 'selected' : '', href: '#/active' }, 'Active')),
        _.li(null, _.a({ className: nowShowing === NOW_SHOWING.COMPLETED ? 'selected' : '', href: '#/completed' }, 'Completed'))
      ),
      completedItemsCount ?
        _.button({ id: 'clear-completed', onClick: this.onClearCompleted },
            'Clear completed (' + completedItemsCount + ')'
        ) :
        null
    );
  }
});

И собственно запуск приложения обычным для React способом:

Ctx.React.renderComponent(
  Bootstrap(),
  document.getElementById('root')
);

Итого, ~220 строк с довольно размашистым форматированием. На мой взгляд, ключевым моментом является отсутствие каких-либо «костылей». Для сравнения можно открыть официальную реализацию на голом React: app.jsx, todoItem.jsx, todoModel.js, весь репозиторий тут. В коде на каждом шагу передаются колбэки дочерним компонентам, встречаются комментарии, описывающее неочевидное поведение «реакта» в тех или иных случаях:

React optimizes renders by batching them. This means you can't call parent's `onEdit` (which in this case triggeres a re-render), and immediately manipulate the DOM as if the rendering's over. Put it as a callback. Refer to app.jsx' `edit` method.

В модели можно видеть такую цитату:

Note: it's usually better to use immutable data structures since they're easier to reason about and React works very well with them. That's why we use map() and filter() everywhere instead of mutating the array or todo items themselves.

Также в компонентах определяется метод shouldComponentUpdate самостоятельно в качестве оптимизации. Morearty это делает для каждого компонента сам.

Привязываем форму напрямую к JSON-ответу от сервера

Отдельно хочется выделить один очень интересный use case. Допустим, есть форма для редактирования каких-либо данных, которые приходят от сервера в виде JSON структуры. При получении JSON он кладётся в какой-то подходящий раздел состояния, а элементы управления формы получают байндинги на подразделы этого «JSON» (в кавычках, потому что хранится он в иммутабельных структурах). Таким образом каждое изменение поля, добавление элементов, удаление автоматически отражается в этой структуре, и впоследствии нет необходимости собирать данные по форме — они уже лежат готовые к отправке на сервер. На проекте в компании это используется для отображения довольно объёмной формы с данными от REST бэкэнда: при сохранении вся изменённая структура отсылается обратно, что значительно экономит время на разработку, особенно, если эти данные кладутся напрямую в JSON-store наподобие MongoDB.

Фреймворк, библиотека или надстройка?

На этот счёт нету полной убеждённости, поэтому можно называть как угодно. Концептуально Morearty — это надстройка на React, для вразумительного манипулирования состоянием. С другой стороны она обеспечивает множество библиотечных методов для упрощения жизни, что делает её библиотекой. Но в отличие от «реакта» на управление состоянием накладываются определённые рамки, что более свойственно для фреймворка.

Настоящее и будущее

На данный момент доступна версия 0.1.0, которая успешно используется на проекте в компании. Размер — 30kb сжатая (production) и 70kb несжатая (development) версии. Также доступна в npm (npm install morearty). Опыт показывает, что библиотека значительно упрощает разработку интерфейса. Например, TodoMVC выше была написана за 3-4 часа параллельно с написанием этой статьи. В планах на ближайшее будущее: прежде всего — доведение до ума документации, развитие библиотеки, сбор отзывов и пожеланий от сообщества, чтобы знать, в какую сторону двигаться, исправление ошибок.

Искренне надеюсь, что материал представил для Вас интерес, несмотря на некоторую скоротечность изложения. Спасибо за чтение, и желаю Вам счастья.

Ресурсы

  • страница проекта и исходный код на Github;
  • документация по API;
  • официальная страница React;
  • официальная страница Om и отличное введение от автора.

Автор: Tvaroh

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js