Pull to refresh

Ractive.js — бриллиантовый век web-разработки

Reading time 12 min
Views 50K
Как утверждает сама команда разработчиков, Ractive.js — это библиотека для разработки динамичных web интерфейсов, благодаря которой в мире web-разработки наступит расцвет: всем выдадут бонусы в 100%, холивары «кто круче» отступят в сторону, а разработчики, которые пишут интерактивные, динамичные сайты наконец то перестанут покрываться сединой и материться.

Короче, наступит бриллиантовый век веб-разработки.

Начиная очередной проект, прежде чем начать писать Backbone код (фу-фу-фу), решил применить это чудо в проекте (бриллианты!). А так как погуглив похабрив я понял, что на хабре всего одна статья о Ractive.js, нужно устранить эту несправедливость и заодно написать о том, правда ли нам всем свалится вагон счастья и будет ли вообще кто-то доволен. Ведь пообещать «диамантовый век» — это одно (каждые 4 года из телеков слышим), а сделать — совсем другое.

Под катом рассмотрю, что такое и как работает Ractive.js, и подробно распишу продакшн задачу с полной реализацией и описанием, чем это всё грозит уже всем нам.

Вначале что это за зверь


Если кратко (на самом деле, очень кратко, но идею уловите, а подробности придут с кодом) и по сути: Ractive.js до ужаса прост и состоит из двух половинок:

  • Темплейты (читай «вьюшка»), в которых вы очень декларативно описываете, как ваша программа\компонент должна выглядеть.
  • Данные — собственно данные, которые нужно представить во вьюшке и то, как на них влияет внешний мир (взаимодействие пользователя и\или сетевых запросов).


Соответственно, после того, как вы описали темплейт, данные и куда это все это рендарить (обычно id DOM элемента на страничке),
Ractive.js обеспечивает (причем абсолютно бескорыстно и без вашего участия) двухстороннюю связь между этими данными и темплейтом.
Т.е., как только данные меняются, тут же реактивненько меняется ваша вьюшка, которая соответствует этим данным, добовляются нужные и удаляются устаревшие DOM елементы. Ну и в обратную сторону:
как только пользователь чем-то в ваше приложение потыкал — меняются данные.
И так по кругу. И все очень реактивненько.

Вы спросите: «А в чем же радость?»

А радость в том, что вы пишите короткий, понятный, декларативный код без миллионов ивентов (которые как обычно летают взад и вперед, а мы пытаемся их обсервить, биндить и т.д.).
А главное — нет никаких манипуляций с DOM! Мы не создаем новые элементы… мы не удаляем их из DOM…
мы фактически никогда не ищем какой-то элемент при помощи $(selector) — всё всегда под рукой.
Ractive выстраивает паралельный DOM точно так же как это делает React и производит только точечные манипуляции с DOM.
Никаких лишних движений, и поэтому работает очень быстро. Кстати чем Ractive лучше\хуже React — тема для отдельной статьи.

Всё просто: поменяли данные — поменялось отображение. Это общая идея библиотечки.

Теперь посмотрим на реализацию, реальный код и подробности как это работает. Но сначала сформируем ТЗ к тому, что мы хотим написать.

Практика


Необходимо было написать комменты к блогу. Вы спросите, почему не использовать Disqus или что-то подобное. Суть в том, что
наш проект занимается образованием в области здорового образа жизни и в публичном блоге мы использовали как раз Disqus.
Но в рамках проекта проводятся онлайн-тренинги, и комментарии в них должны быть приватными, так как содержат частную информацию и всякие ценные ответы на вопросы.
Поэтому пришлось сделать свой компонент.

Итак, требования:

  • Пользователь должен иметь возможность оставить многострочный текст в виде комментария.
  • Комменты должны работать без перезагрузки страницы (интерактивчик как мы все любим).
  • Комменты должны быть древовидными (можно ответить на другой коммент и создать тем самым ветку).
  • Новый коммент должен появляться с красивенькой анимацией.
  • Пользователи могут удалить свой собственный коммент.


Собственно что бы сразу понять что и как мы будем строить вот на ссылка на jsfiddle с конечным результатом.

Templates

Ractive.js для описание UI использует популярный язык темлпейтов {{mustache}}. Только они его чуть-чуть подхачили и расширили.
Не буду описывать тут mustache, о нем можно почитать здесь кто не знаком.

Опишу только особенности, которые внёс Ractive:

  • Ввели переменную this. В блоке она соответствует данному объекту. К примеру, если вы пишите
    {{#user}}
        <div>this.name</div>
    {{/user}}
    
    , то если
    var user = {name:'Вася'}
    
    получится
    <div>Вася</div>
    
  • Если блок — это массив, то this будет равняться элементу массива.
  • А также вы можете выполнять любой js код внутри {{ }}. Так, если
    var user = { 
        age:15,
        name:'Ольга'
    } 
    

    и темплейт:
    {{#user}} 
        <div class="{{this.age < 18+1*Math.random() ? 'illegal' : 'legal'}}">this.name </div>
    {{/user}}
    

    то на выходе получим предсказуемый результат
    <div class='illegal'>Ольга</div>
    

  • Проксирование DOM Events: если писать в темплейте
    <div on-eventName='callbackName'>
    

    , где eventName — это название стандартного DOM события ( click, submit и т.д.), то при наступлении события, ractive может подписаться на такой ивент в виде
    ractive.on('callbackName',function(e){});
    


Есть еще отличия, но сейчас не до них.
Дальше всё просто: каждый инстанс Ractive имеет проперти data с данными, которые должны быть отображены в темплейте.
Он засовывает data в темплейт и строит нужную нам HTML структуру, причём каждому объекту из data соответствует блок html из темплейта.
Поэтому при изменении data Ractive точно знает, что нужно добавить, а что удалить из реального DOM, и поэтому работает очень быстро, производя точечные замены в DOM.
Также, поскольку каждому кусочку DOM теперь соответствует кусок данных в data, все ивенты, которые проксируются черех on-eventName (к примеру on-click), сопровождаются ссылкой на данные из data как context. К примеру, если у меня есть такой темплейт:

{{#user}}
      <-- здесь this=user -->
      <div on-click='alert_username'>{{this.username}}</div>
{{/user}}

то обработчик события alert_username может выглядеть так:
function(e){
        var user = e.context;
        alert(user.username);
}


Надеюсь, что на реальном примере станет все понятней.

Далее — наша имплементация темплейта для нашей задачи, но сначала опишем структуру наших данных (комментов) в data:

ractive.data = {'replys':[
                        {
                         md_text:"Тут текст комента",
                         date:10240912834091,//Веремя комента nix
                         //Автор
                         user:{
                             username:'Василий Петровичь',
                             image:'http://link-roga-serega-avatar.ru'
                         },
                         //Массив с ответами
                         replys:[{
                                md_text:"Тут ну очень интересный комента",
                                date:10240912834091,
                                replys:[...]},
                                {...}]
                         },
                        {
                         md_text:"Тут текст другого комента",
                         date:10240912834091,//Веремя комента nix
                         user:{
                            username:'Василий Петровичь'
                         },
          ]};

Сам темплейт с комментариями:
{{#top_reply}}
        <!--Мы показываем форму добавления комментария сверху и снизу. Но когда комментариев нет, только снизу-->
        {{#if replys.length > 0}}
            <!--Если есть черновик - покажем форму-->
            {{#if this.reply_draft}}
                <!--это развернется в темплейт который находится между блоками comment_form - темплейт формы введения комментария.-->
                {{>comment_form}}
            {{else}}
                <!--Если нет, то покажем кнопку 'Добавить комментарий' которая раскроет форму -->
                <!--По нажатию выстрелит событие reply и e.context = this; this = top_reply            -->
                <button class="add_comment" on-click="reply">Добавить комментарий</button>
            {{/if}}
        {{/if}}
    {{/top_reply}}

    <!--Для каждого комментария верхнего уровня рендерим partial комментария смотри ниже-->
    {{#replys}}
        {{>comment}}
    {{/replys}}

    <!--Нижняя форма ответа-->
    {{#bottom_reply}}
        {{>comment_form}}
    {{/bottom_reply}}


    <!-- {{>comment}} -->
    <!--Темплейт комментария -->
    <!--Важно понять что поскольку этот partial показываем из блока replys - this будет равняться текущему комментарию-->
    <article id="{{this.id}}" class="comment" intro="scroll_to:{go:{{this.is_new}}}">
        <header>
            <!--Тут мы выставляем пользовательский аватар либо дефолтную картинку если у пользователя нет аватара-->
            <img class="avatar" src=""/>

            <span class="author">{{this.user.username}}</span>
            <!-- Можно использовать функции. Здесь мы используем moment что бы отформатировать время. Ссылку на функцию мы передали через блок data-->
            <time pubdate datetime={{moment(parseInt(date)).format()}}>
                {{moment(parseInt(date)).fromNow()}}
            </time>
            <!--Только если автор комента - это текущий пользователь мы показываем кнопку удалить-->
            {{#if user.id === current_user.id}}
                <button class="delete" on-click="delete" disabled="{{deleting}}">Удалить</button>
            {{/if}}
        </header>

        <!--Вставляем текст комментария отформатировав его-->
        {{{marked(md_text)}}}

        <footer>
            <!--если мы не набираем коммент покажем кнопку ответить-->
            {{^this.reply_draft}}
                <button on-click="reply">Ответить</button>
            {{/this.reply_draft}}
        </footer>

        <!-- форма ввода комментария-->
        {{#if this.reply_draft}}
            {{>comment_form}}
        {{/if}}

        <!--А теперь мы рекурсивно показываем ответы-->
        {{#this.replys}}
            {{>comment}}
        {{/this.replys}}

    </article>
    <!-- {{/comment}} -->


    <!-- {{>comment_form}} -->
      <article class="comment">
        <header>
            <img class="avatar" src=""/>
            <span class="author">{{current_user.username}}</span>
        </header>
        <!--при сабмите формы мы передаем ивент save-->
        <!--при чем this а соответсвенно и е.context будет указывать на текущий комментарий.-->
        <form on-submit="save" on-reset="cancel_reply">
            <!--Он автоматом связывает и синхронизует значения input с полем текста черновика. -->
            <!--так если вы что то вбиваете в textarea это сразу появляется в this.reply_draft.text-->
            <textarea value="{{this.reply_draft.text}}" placeholder="Ваш комментарий"></textarea>
            <!--отключаем форму во время сохранения комментария-->
            <button type="submit" disabled="{{this.loading}}">Комментировать</button>
            <button type="reset">Отменить</button>
        </form>
    </article>
    <!-- {{/comment_form}} -->


Данные

Теперь опишем, как темплейт связаны с данными.
Модель тут очень простая, поэтому долго останавливаться не буду: JS класс Comments с пропертями типа текст, дата публикации, пользователь, а также возможностью коммент сохранить на сервер и удалить с него.
Также присутствует метод скачивания коммента с сервера, как и обещал ничего интересного.
function Comment(id, md_text, user, domain, date, reply_to) {
    	if (!md_text || !user || !domain || !date) {
    		throw new Error('md_text, user, domain and date is required');
    	}
    	this.id = id;
    	this.md_text = md_text;
    	this.user = user;
    	this.date = date;
    	this.reply_to = reply_to ? reply_to.id || reply_to : null;
    	this.domain = domain;
    	this.replys = [];
};
Comment.prototype.destroy = function () {
        //ajax запрос на удаление коммента
};
Comment.prototype.save = function () {
    	//ajax запрос на сохранение комента
};

Comment.fetch = function (domain, options) {
    	//скачиваем все комменты за раз.
};

Теперь имплементация Ractive компоненты:
var Comments = Ractive.extend({
    	//$(selector) Элемента куда Ractive будет рендарить этот компонент
    	el: '#comments',
    	//id tag <script> в котором содержится темлейт для этого компонента ( подробней в разделе темплейтов)
    	template: '#comments_template',
        init       : function (options) {
    		var self = this;
    		//Домен куда сохранять на сервере коменты.
    		var domain = options.comments_domain;
    		//Текущий пользователь.
    		var current_user = options.current_user;
    		if (!current_user) {
    			throw new Error('options.domain and options.current_user is required');
    		}
    		//И так для начала качнем комменты с сервера.
    		Comment
    			.fetch(domain)
    			.then(function (comments) {

    				//Разложим коменты по айдишникам что бы потом меньше бегать по списку.
    				var comments_by_id = _.indexBy(comments, 'id');
    				//Сортируем по дате
    				comments = _.sortBy(comments, 'date');
    				//Так как коменты имеет древовидную форму
    				//Раскладываем ответы в массив replys отцовского комента, а так же оставляем в массиве только комменты верхненго уровня.
    				comments = _.filter(comments, function (comment) {
    					if (comment.reply_to && comments_by_id[comment.reply_to]) {
    						var parent = comments_by_id[comment.reply_to];
    						parent.replys.push(comment);
    					}
    					return comment.reply_to === null
    				});
    				//reative.set(prop_name,value) сетит данные в data, ractive.get(prop_name) берет данные из data.
    				//Таким образом рактив узнает что что то поменялось и нужно обновить вьюшку.
    				//Если мы сделаем self.data.replys = comments то ничего автоматом не обновиться.
    				self.set('replys', comments);
    			});

    		//Ractive предоставляет стандартный механизм ивентов.
    		//Events можно слушать при помощи ractive.on('event_name',callback); или .on({prop_name:callback});
    		//Events в основном используются чтобы слушать действия пользователя... к примеру этот подлец ткнул в кнопку на страничке.
    		self.on({
    			//Пользователь хочет сохранить комментарий.
    			//е тут самое интересное. это внутренний объект ractive и он состоит из
    			//e.node - html DOM откуда прилетел ивент.
    			//e.original - оригинальный ивент DOM (удобно для e.original.preventDefault());
    			//e.keypath - в этом месте чуть сложнее. Каждой модельке в ractive.data он присваивает путь. К примеру если у нас data выглядит так:
    			//data:{ comments:['text1','text2'] }; то путь к строке 'text2' будет описанн в ввиде 'comments.1'
    			//Ractive внутренне строит паралельный DOM и когда рендерит темплейт, каждому DOM элементу или сегменту соответствует keypath модели.
    			//ну и e.context - это собственно данные которые находятся на e.keypath ( ractive.get(e.keypath) );
    			save        : function (e) {
    				e.original.preventDefault();
    				//save вызываеться из формы коммента. Соответственно, если форма комента находится в корне, а не под каким-то коментом - то этот комментарий не являеться ответом.
    				var reply_to = e.context.id ? e.context.id : null;
    				//Собственно сам текст комента.
    				var reply = e.context.reply_draft;
    				//Собираем комент
    				var new_comment = new Comment(void 0, reply.text, current_user, domain, moment().valueOf(), reply_to);
    				//Отключем форму сохранения
    				self.set(e.keypath + '.loading', true);
    				//Сохраняем коммент
    				new_comment.save()
    					.then(function (comment) {
                            //типа сетевой запрос
                            setTimeout(function(){
                                //Прекращаем загрузку, очищаем форму.
    						self.set(e.keypath + '.loading', false);
    						self.set(e.keypath + '.reply_draft', false);
    						var comments = reply_to ? self.get(e.keypath + '.replys') : self.get('replys');
    						//добавляем коммент в массив ответов
    						comments.push(comment);
    						//Все. Данные уже отображены, больше ничего делать не нужно.
    						//Причем радость в том что Ractive будет рендерить только то что нужно.
    						//Если что-то добавить в массив он добавит в дом только один элемент в нужное место, а все остальное останется как было.
                            },600);
    					})
    					.fail(function (err) {
    						self.set(e.keypath + '.loading', false);
    					});
    			},
    			delete      : function (e) {
    				//Удаление комента.
    				//Мы не удаляем коменты а только затираем текст.
    				//Поэтому тут по сути так же как сохранение.
    				self.set(e.keypath + '.deleting', true);
    				e
    					.context
    					.destroy()
    					.then(function (comment) {
    						self.set(e.keypath + '.md_text', comment.md_text);
    						self.set(e.keypath + '.deleting', false);
    					})
    					.fail(function () {
    						self.set(e.keypath + '.deleting', false);
    						//TODO: show erorr;
    					});
    			},
    			reply       : function (e) {
    				//e.keypath тут путь текущего коммента в темплейте.
    				//Это пользователь княпнул и решил ответить на комментарий.
    				//Создаем объект черновика коммента. Наверное сразу нужно было создавать тут объект Comments... но это будет в v2.0
    				self.set(e.keypath + '.reply_draft', {});
    			},
    			cancel_reply: function (e) {
    				//Передумал чувак коментить...
    				//Трем его черновик
    				self.set(e.keypath + '.reply_draft', false);
    			}
    		});

    	},
    	//data содержить данные и функции которые будут доступны в темплейтах.
    	data: {
    		bottom_reply: {}, //Про top&bottom_reply чуть позже, это небольшой хак.
    		top_reply: {},
    		marked: marked, //data так же может хранить не только объекты, но и функции которые будут доступны в темплейтах.
    		moment: moment //хелпер функции для работы со временем.
    	}
    });

Анимации

В любой элемент можно добавить атрибут intro\outro для DOM елемента: , и каждый раз при добавлении\удалении элемента из DOM Ractive будет запускать указанную анимацию.

Анимации описаны функцией, которая как параметр принимает объект:

t.node — DOM элемент, который нужно анимировать.
t.isIntro — вставляется или удаляется элемент.
t.complete – функцию, которую нужно вызвать после того, как вы все заанимировали и всё закончилось. Ractive заресетит все стили элемента в начальное состояние.

Собственно функция анимации наших комментов выглядит так:

//Ractive можно заставить выполнять анимации на каждом добавлении или удалении в\из DOM элементов.
    	transitions: {
        		scroll_to: function (t, param) {
        			//Если элемент вставляется и дано добро от разработчика (param.go === true)
        			if (t.isIntro && param.go) {
        				//Скроллим к элементу который вставляется в дом.
        				//Единственное место где используеться $
        				var element = $(t.node);
        				var offsetTop = element.offset().top - element.outerHeight();
        				$('html, body').animate({scrollTop: offsetTop}, 500, function () {
        					//Потом мигнем новым коментарием
        					t.setStyle('background-color', 'yellow');
        					t.animateStyle('background-color', 'transparent', {
        						duration: 1500,
        						easing  : 'easing'
        					}, t.complete); // И сообщим Ractive t.complete что мы закончили с анимацией. Ractive свою очередь засетит все стили так как они были до анимации.
        				});
        			} else {
        				//Либо это удаление элемента из DOM, либо нам не разрешают анимацию (param.go)
        				t.complete();
        			}
        		}
        	}

Вот и все, 230 строк с коментариями (ооочень подробными), темплейтами и js кодом. Фактически никакого поиска по DOM — красота.
Ну и ще раз рабочий код можно посмотреть тут jsfiddle.net/maxtern/e2mk0tn3

Выводы


Счастье заключается в том, что в данном примере фактически ни разу не пришлось искать что-то в DOM, искать ссылки на объекты, проверять состояние компонента.
Все декларативненько и реактивненько, как и было обещано. Никто не кидается друг в друга миллионами событий.
Очень мало «склеивающего» вьюшки с моделями кода. Да и вообще очень мало кода.

Потом Ractive работает очень быстро. Поскольку вам не нужно копаться постоянно в DOM и он точно знает, в каком месте нужно что-то поменять,
перерисовки вьюшек фактически не происходит, поэтому он работает быстро. К примеру, в Backbone мы иногда вызываем render и перерисовываются куча вьюшек, даже те, которые не поменялись.

Также его можно использовать абсолютно с любыми другими библиотечками, например, с тем же Backbone — мы берем из него Model и Route.

Так же на сайте Ractive есть интерактивный туториал, который можно пройти и за 30-50 минут вы знаете Ractive. т.е. 30-50 минут и вы профи.
Процесс изучения невероятно простой и понятный.

Что вы думаете? Лично я думаю, что бриллиантовый век может быть еще и не наступил, но в очередной раз стало значительно легче жить.

Эпилог


В данный момент мы работаем над очередным релизом на сайте проекта Пятнашки, и планируем запустить планировщик питания для web.
Сам планировщик выполнен в виде игры пятнашки (что очевидно из названия проекта) и календаря. На мобиле выглядит так:
image
При всем этом в web еще рисуются графики, и пользователи подгружают и редактируют картинки. В общем, полный интерактив.

Если эта статья будет интересна, я опубликую после релиза как легко в Ractive можно разбивать сложный интерфейс на компоненты, и как они взаимодействуют друг с другом на примере этого самого планировщика.

Так же есть идея написать «Ractive vs. React. Такие реактивные фреймворки».

Официальный сайт: www.ractivejs.org
Особенно хорош и них и туториал: learn.ractivejs.org/hello-world/1
Tags:
Hubs:
+29
Comments 53
Comments Comments 53

Articles