[Из песочницы] Rista — легковесная и быстрая альтернатива React-у
Я хочу рассказать об одном из своих великов, который вроде получился вполне удачным и может окажется полезен кому-то ещё. Начну немного издалека.
Вообще, я люблю React! Когда этот фреймворк только появился, я отнёсся к нему довольно скептически, как, в общем-то, и ко всем появляющимся фреймворкам. Но вот дальше всё пошло по очень редкому сценарию. Чем больше я пробовал писать на нём разные хелловорлды и тудулисты, тем больше он мне нравился и в какой-то момент даже как-то полюбился. Лишь один момент в нём мне так и не стал по душе — дефолтный механизм состояний, в котором довольно много минусов.
К примеру такой код (jsfiddle):
var Example = React.createClass({
getInitialState: function() {
return {
showCounter: false,
counter: 0
};
},
tick: function() {
this.setState({ counter: this.state.counter + 1 });
},
componentDidMount: function() {
this.interval = setInterval(this.tick, 1000);
},
componentWillUnmount: function() {
clearInterval(this.interval);
},
render: function() {
console.log('render');
return {this.state.showCounter ? this.state.counter : 'counter is hidden'};
}
});
Здесь по срабатыванию console.log-а можно видеть, что рендер и патчинг происходят при каждом изменении counter-а, при том, что никакого смысла в этом просто нет. Механизм состояний React-а не умеет сам грамотно оптимизировать такие ситуации.
Кроме того каждый компонент, для отслеживания момента когда ему нужно перерендерится, следит только за своим состоянием, если просто использовать в рендере какое-то свойство из стора приложения, то он просто не будет реагировать на изменение этого свойства. Для того, что-бы это происходило нужно «протаскивать» все используемые свойства из стора в сам компонент, делая их собственными свойствами. С использованием модулья fluxible это может выглядеть как-то так:
@connectToStores([FooStore], {
FooStore: function(store, props) {
return {
foo: store.getFoo()
}
}
})
class ConnectedComponent extends React.Component {
render() {
return ;
}
}
Ещё вариант, в компоненте подписываться на изменение стора и вызывать forceUpdate в обработчике его (стора) изменения. Возможно внутри модулей типа fluxible происходит как-раз это.
Естественно в любом более-менее сложном приложении таких свойств получается довольно много и их постоянное «протаскивание» в каждый компонент начинает неслабо напрягать.
Синтаксис установки значений через setState тоже явно проигрывает в юзабельности стандартной конструкции:
this.counter = значение;
Критиковать можно довольно долго, но какой в этом смысл, если не предлагать альтернативы. Для себя в качестве механизма состояний в приложениях я давно использую другой свой велик — cellx. Это довольно мощная и, что самое главное, очень быстрая реализация реактивного программирования для javascript. Эта статья не про него, но если есть сомнения в его крутости, то можно почитать ридми на русском, там описаны его основные фичи и оптимизации. При чтении можно задавать вопрос «что из этого есть в механизме состояний React-а», думаю сомнения быстро отпадут.
Оказалось, что React и cellx довольно легко интегрируются друг с другом, по сути нужно просто создать вычисляемую ячейку (в терминологии cellx-а) в формуле которой вызвать render, в componentDidMount подписаться на неё, а в componentWillUnmount отписаться. Что бы каждый раз не писать всё это и окончательно облегчить себе жизнь, я написал простейший декоратор, делающий это за меня: react-bind-observables.
Всё было хорошо, я что-то писал на этой связке и не знал проблем, но однажды мой коллега рассказал мне про библиотечку morphdom. Суть библиотеки в том, что она делает тоже самое, что и React, но не с Virtual DOM, а с обычным dom-деревом. Как ни странно, оказалось, что делает она это ещё и довольно быстро, в среднем выходит почти в два раза быстрее React-а (ссылки на бенчмарки в репозитории morphdom). На самом деле, если разобраться подробнее, то такая скорость получается в основном за счёт заметно более быстрого рендера, склеивание строк и emptyElement.innerHTML = 'склеенная строка' срабатывают сильно быстрее чем массовое создание на каждый элемент и текстовый узел кусочков виртуального dom-дерева. А вот сам патчинг у morphdom-а всё же медленнее (в сумме получается опять же быстрее). Всё это можно в подробностях рассмотреть на этом независимом бенчмарке.
В тоже время morphdom умеет только патчить dom, в нём нет никакого механизма состояний и никакой системы компонентов. Ну и меня, конечно же, понесло.
Расскажу как всё это собиралось по порядку. Первым делом нужна какая-то компонентная система. В идеале всё должно быть примерно как в React-е, то есть отрендерили элемент
Объявляем логику для компонентов:
rista.component('hello-world', {
init() {
// Сработает при появлении элемента ` ` в документе.
// `this.block` - ссылка на появившийся элемент.
},
dispose() {
// Сработает при удалении элемента ` ` из документа.
}
});
Дальше, по готовности dom, фреймворк вызывает функцию initComponents с аргументом document.body, которая составляет css-селектор из всех объявленных компонентов, делает выборку по нему (селектору) из переданного элемента и для каждого найденного элемента создаёт экземпляр соответствующего класса-компонента, сгенерированного вызовом rista.component. В конструкторе класса вызывается его метод init. Дальше фреймворк, используя MutationObserver начинает следить за всем, что появляется в документе и удаляется из него. При появлении новых элементов опять же применяет к ним initComponents, при удалении destroyComponents, который тем же способом находит в переданном элементе все «компонентные» элементы и вызывает dispose на соответствующих им экземплярах класса.
Всё это позволяет в декларативном стиле прикрепить какую-то логику к нужным элементам и уже не беспокоиться над определением момента, когда эти элементы появиляются и их нужно инициализировать, и когда удаляются и нужно освободить занятую ими память (снять обработчики, убить таймеры/запросы и т. д.).
Идём дальше. Компонент должен уметь не только использовать контент своего корневого элемента, но и задавать собственное содержимое для него.
Тут как-раз и нужен render, который первый раз вызывается перед init-ом и должен вернуть строку (массив строк). Эта строка просто записывается в корневой элемент компонента (доступен как this.block) через innerHTML.
Теперь остаётся только понять когда нужно перерендеривать содержимое и научится как-то применять изменения без дальнейшей записи в innerHTML (я думаю не стоит объяснять, почему не стоит так делать). И тут, я думаю, уже всё понятно, с определением момента когда произошли какие-то изменения в состоянии всё разруливает cellx, так же как я описал выше для React-а, а вот обновления вносятся уже с помощью morphdom-а.
Остаётся ещё одна мелочь, morphdom применяя изменения может как-то пересортировывать элементы, временно удаляя их из dom и подставляя обратно в другом месте. Если при этом сразу применять initComponents и destroyComponents, то начнётся настоящая адуха с постоянным пересозданием уже инициализированных инстансов компонентов. Это надо как-то решать. Рецепт прост: при срабатывании MutationObserver-а не запускать initComponents и destroyComponents сразу, а просто регистрировать добавленные и удалённые элементы в соответствующих коллекциях. При этом когда элемент добавляется, не регистрировать его сразу в добавленных, а сперва искать его в коллекции удалённых и если он там есть, значит он только-что был удалён и теперь возвращается обратно, нужно просто удалить его из коллекции удалённых никуда не добавляя. При первом таком срабатывании ставится nextTick (setImmediate) с обработчиком, который сработает как только morphdom доделает свои манипуляции, в нём уже и делается обработка полученных коллекций. Как сказал один товарищ, лучше покажите мне код:
let removedNodes = new Set();
let addedNodes = new Set();
let releasePlanned = false;
function registerRemovedNode(node) {
if (addedNodes.has(node)) {
addedNodes.delete(node);
} else {
removedNodes.add(node);
if (!releasePlanned) {
releasePlanned = true;
nextTick(release);
}
}
}
function registerAddedNode(node) {
if (removedNodes.has(node)) {
removedNodes.delete(node);
} else {
addedNodes.add(node);
if (!releasePlanned) {
releasePlanned = true;
nextTick(release);
}
}
}
function release() {
releasePlanned = false;
if (removedNodes.size || addedNodes.size) {
// здесь обрабатываем removedNodes и addedNodes
}
}
Теперь всё работает, дальше остаётся реализовать всякие мелочи вроде декларативного добавления обработчиков на элементы и всё в этом духе. Об этом уже подробнее в репозитории фреймворка.
Что в результате
За последние 2–3 недели я написал целую кучу разных мелких приложений уровня тудулиста, в одном проекте, который пилю для себя, заменил React на Rista, написал несколько компонентов, некоторые уже выложил на гитхаб: popup, router, switcher.
Общие впечатления от процесса примерно те же, что и от использования связки React+cellx, есть конечно некоторые минусы вроде таких:
- нельзя закрывать компоненты так
, приходится писать полноценный закрывающий тег; - в компоненты нельзя передавать значения ссылочного типа, только строки.
В целом, значимых минусов пока не обнаружил. Основные плюсы получились следующие:
- В разы легче React-а, после минификации и gzip-а всего 10kB.
- Быстрей. Тут сложно определить какие-то конкретные цифры, morphdom быстрей аналогичного механизма в React-е почти в два раза, скорость cellx-а зависит от конкретной ситуации, но если в подобном тесте React окажется в 5 раз медленнее, то это уже будет отличным результатом для него.
- Не требует какой-то предварительной обработки кода (jsx), можно просто подключить js-файлик к проекту и написать какой-то компонент, который сразу начнёт работать.
Вроде всё. Если есть идеи по дальнейшему развитию получившегося фреймворка, не стесняйтесь создавать issue на github-е (лучше на русском, мой бурятоанглийский оставляет желать лучшего).
Всем спасибо за внимание.