[Из песочницы] 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-е, то есть отрендерили элемент и какая-то логика сразу применилась к нему. Никаких $('.selector').myWidget () как в jQuery быть не должно, то есть всё должно быть в декларативном стиле. Тут я вспомнил про MutationObserver. Работает получившаяся система примерно так.
Объявляем логику для компонентов:

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-е (лучше на русском, мой бурятоанглийский оставляет желать лучшего).

Всем спасибо за внимание.

© Habrahabr.ru