Идея написания статьи появилась в этой ветке, может кому-то будет интересно и её почитать. Сразу скажу, писатель (в том числе кода) из меня так себе, но я буду стараться.
Писать будем как обычно тудулист, надоел конечно до чёртиков, но что-то лучшее для демонстрации придумать сложно. Сразу ссылка на работающее приложение: жмяк (код).
Данные приложения
И сразу в бой, начнём с хранилища. Единственный тип необходимый для этого приложения — Todo:
import { EventEmitter } from 'cellx';
import { observable } from 'cellx-decorators';
export default class Todo extends EventEmitter {
@observable text = void 0;
@observable done = void 0;
constructor(text, done = false) {
super();
this.text = text;
this.done = done;
}
}
Тут всё предельно просто, парочка наблюдаемых полей, одно содержит текст задачи, другое статус её выполнения.
Наследование от cellx.EventEmitter
необходимо на случай если в дальнейшем понадобится подписаться на изменения какого-то поля:
todo.on('change:text', () => {/* ... */});
В данном приложении такого нет и наследование можно убрать, я просто зачем-то всегда пишу его заранее.
Теперь напишем корневое хранилище:
import { EventEmitter, cellx } from 'cellx';
import { observable, computed } from 'cellx-decorators';
import Todo from './types/Todo';
class Store extends EventEmitter {
@observable todos = cellx.list([
new Todo('Primum', true),
new Todo('Secundo'),
new Todo('Tertium')
]);
@computed doneTodos = function() {
return this.todos.filter(todo => todo.done);
};
}
export default new Store();
Здесь уже поинтереснее. Используется cellx.list (алиас для new cellx.ObservableList
) — наблюдаемый список, наследует от cellx.EventEmitter
и при любом своём изменении генерирует событие change
. Наблюдаемое поле получая в качестве значения что-то наследующее от cellx.EventEmitter
подписывается на его change
и тоже изменяется при этом событии. Всё это значит, что не обязательно использовать встроенные коллекции, можно сделать свои унаследовав их от cellx.EventEmitter
. Из коробки есть cellx.list
и cellx.map. Отдельным модулем есть индексируемые версии обоих коллекций: cellx-indexed-collections.
Ещё один новенький — декоратор computed
, вычисляемые поля — это самая суть cellx-a — вы просто пишите формулу вычисляемого поля, вам не нужно самому подписываться на done
каждого todo при его добавлении и отписываться от него же при удалении, всё это делает cellx пока вы не видите, вам остаётся расслабиться и получать удовольствие описывая самую суть. При этом описание происходит, можно сказать, в декларативном виде — уже не нужно думать о событиях и о том как изменения будут распространяться по системе, всё пишется так, как будто отработает лишь раз. Кроме того cellx очень умный и автоматически делает некоторые хитрые оптимизации: динамическая актуализация зависимостей и схлопывание и отбрасывание событий не допустят избыточных расчётов и обновлений интерфейса. Если делать всё это вручную, код получается довольно объёмным, но, что намного хуже — глючным. Отладкой же cellx-а заниматься приходиться раз в сто лет, он просто работает.
Представление приложения
Переходим к слою отображения. Сначала компонент задачи:
import { observer } from 'cellx-react';
import React from 'react';
import toggleTodo from '../../actions/toggleTodo';
import removeTodo from '../../actions/removeTodo';
@observer
export default class TodoView extends React.Component {
render() {
let todo = this.props.todo;
return (<li>
<input type="checkbox" checked={ todo.done } onChange={ this.onCbDoneChange.bind(this) } />
<span>{ todo.text }</span>
<button onClick={ this.onBtnRemoveClick.bind(this) }>remove</button>
</li>);
}
onCbDoneChange() {
toggleTodo(this.props.todo);
}
onBtnRemoveClick() {
removeTodo(this.props.todo);
}
}
Здесь из новенького — декоратор observer
из модуля cellx-react. Грубо говоря, он просто делает метод render
вычисляемой ячейкой и вызывает React.Component#forceUpdate при её изменении.
Остаётся корневой компонент приложения:
import { computed } from 'cellx-decorators';
import { observer } from 'cellx-react';
import React from 'react';
import store from '../../store';
import addTodo from '../../actions/addTodo';
import TodoView from '../TodoView';
@observer
export default class TodoApp extends React.Component {
@computed nextNumber = function() {
return store.todos.length + 1;
};
@computed leftCount = function() {
return store.todos.length - store.doneTodos.length;
};
render() {
return (<div>
<form onSubmit={ this.onNewTodoFormSubmit.bind(this) }>
<input ref={ input => this.newTodoInput = input } />
<button type="submit">Add #{ this.nextNumber }</button>
</form>
<div>
All: { store.todos.length },
Done: { store.doneTodos.length },
Left: { this.leftCount }
</div>
<ul>{
store.todos.map(todo => <TodoView key={ todo.text } todo={ todo } />)
}</ul>
</div>);
}
onNewTodoFormSubmit(evt) {
evt.preventDefault();
let newTodoInput = this.newTodoInput;
addTodo(newTodoInput.value);
newTodoInput.value = '';
newTodoInput.focus();
}
}
Здесь ещё парочка вычисляемых полей, отличаются от Store#doneTodos
они лишь тем, что поля из которых они вычисляются лежат не на текущем экземпляре (this
) а где-то в другом месте, cellx никак не ограничивает в этом плане, эти поля можно спокойно переместить в Store
и всё так же будет работать. Определять, где должно лежать поле лучше по его сути — если поле специфично для какого-то определённого компонента, то пусть в нём и вычисляется, светиться в общем хранилище ему нет смысла. В данном случае я бы #leftCount
перенёс в хранилище, а #nextNumber
вполне неплохо смотриться и здесь.
Бизнес-логика приложения
В экшенах cellx никак не используется, поэтому я максимально их упростил, получился даже не Flux, а какой-то MVC в терминах Flux-а. Надеюсь вы мне простите это упрощение.
Результат
В данном случае приложение совсем простое и написать его так же просто можно и без cellx-а (никаких подписок на каждый done
здесь не потребуется), при дальнейшем же усложнении связей в приложении сложность их описания на cellx-e растёт линейно, без него — обычно нет и в какой-то момент приходим к мешанине событий в которой без поллитра не разобраться. Для решения проблемы, кроме реактивного программирования, есть и другие подходы со своими плюсами и минусами, но их сравнение — уже другая история (если кратко, как минимум они проигрывают из-за большого количества лишних вычислений и, как результат, более низкой производительности).
В общем-то по коду это всё, ещё раз ссылка на результат: жмяк (код).
Сравнение с другими библиотеками
MobX
Чаще всего спрашивают отличия от MobX. Это наиболее близкий аналог и отличий немного:
- cellx примерно в 10 раз быстрее.
- В статье про атомы я подсмотрел методы/опции put и pull, позволяющие ячейкам уметь чуть больше: синхронизация значения с синхронным хранилищем, синхронизация значения с асинхронным хранилищем, про pull. У MobX я ничего похожего не нашёл.
- Разная система очистки памяти, у cellx это пассивный режим, в MobX вообще нельзя отписаться от ячейки после подписки, что для меня какая-то странность, когда необходима отписка необходимо использовать autorun, который можно "убить" возвращаемым
disposer
-ом. Из минусовautorun
-а — инициализирующий запуск колбека часто вообще не в тему. - MobX лучше интегрирован с React-ом, в отличии от cellx-а он как-то вклинивается в слой бизнес-логики приложения. Я так и не понял зачем он там, но видимо зачем-то нужен.
- У MobX явно лучше с документацией.
Kefir.js, Bacon.js
Тут отличия более существенны. Отставание в скорости ещё больше, но важнее не это. Эти библиотеки предлагают создавать вычисляемые ячейки несколько иначе, в, наверное, более функциональном виде. То, что на cellx-e будет выглядеть так:
var val2 = cellx(() => val() + 1);
На этих библиотеках превратиться в что-то вроде (псевдокод, как там точно я не помню, да и не суть):
var val2 = val.lift(add(1));
Плюс в более красивом, человекочитаемом коде, минус в заметно большем пороге входа, так как теперь нужно запомнить 100500 методов на все случаи жизни (можно конечно обходиться и каким-то минимальным набором).
В тоже время в cellx-е есть возможность добавить ячейкам свои методы и ничто не мешает довести его до уровня этих библиотек, можно сказать, что он более низкоуровневый.
Подвал
Вопросы по библиотеке и идеи по её дальнейшему развитию принимаются на гитхабе.
Благодарю за внимание.
Автор: Riim