Pull to refresh

Реальный опыт разработки на Meteor

Reading time11 min
Views9.3K
Это рассказ о моем опыте разработки живого проекта на фреймворке Meteor. Фреймворк очень интересный, подход к разработке концептуально отличается от большинства существующих PHP/JS фреймворков. Фактически, с Meteor приходится заново учиться веб-разработке.



Для начала пару слов о проекте. Это промо страница для одного местного сайта знакомств. Была задача создать отдельную страницу с конкурсом на лучшее фото среди участниц. Всего 8 участниц. Голосовать может кто угодно, никакой регистрации или авторизации требовать не нужно. На странице будет обратный отсчет до конца конкурса.

Meteor оказался хорошим выбором для этого проекта. Или же проект оказался хорошим в качестве моей первой работы на Meteor, равнозначно. Главная особенность Meteor – это т.н. реактивность (Reactivity). Идея в том, что программист декларативно описывает логику, не задумываясь о протоколе коммуникации между клиентом и сервером. Обновление данных на клиенте происходит автоматически, как только данные изменились на сервере. Это значит никаких больше AJAX запросов в коде проекта.
Вводные данные по Meteor дублировать не хочется. Есть несколько хороших видео на сайте www.meteor.com, а также несколько статей на хабре.

Далее последует техническое описание проекта. Также будут разъяснены основные подходы к разработке, используемые в Meteor на примере этого проекта. Структура проекта следующая:

  • client/ – данные доступные клиентской части Meteor
  • client/client.js – основной скрипт клиентской части
  • client/views/ – хранилище html файлов, которые используются в Meteor
  • public/ – статичные файлы, которые доступны по URL
  • server/server.js – основной скрипт серверной части
  • model.js – общий скрипт для серверной и клиенской части


Коллекции


В качестве базы данных используется MongoDB. Клиентская часть имеет доступ к данным базы так же, как и серверная. Даже интерфейс доступа такой же – для имитации запросов к базе на клиентской стороне используется Minimongo. Клиент через Minimongo оперирует JavaScript массивами, в отличии от сервера, который делает прямые запросы к MongoDB базе.

file: model.js
// Общий для клиента и сервера код
Members = new Meteor.Collection('members');


В примере выше объявляется коллекция «участники». Так как этот файл доступен и клиентской и серверной части проекта, доступ к переменной Members есть и на клиенте и на сервере. Это можно проверить просто открыв консоль в браузере и выполнив typeof Members или Members.find().fetch(). Отличие только в реализации, ведь на сервере методы Members будут оперировать с MongoDB напрямую, а на клиенте с JavaScript массивами через Minimongo обертку.

Эти коллекции управляются самим Meteor – он сам решает когда данные необходимо обновить на клиенте. Программист может ограничить объем данных, который будет представлен переменной Members на клиенте. Это будет подмножество данных с сервера. Делается это при помощи Meteor.publish() и Meteor.subscribe().

file: client/client.js
Meteor.subscribe('members');

В данном случае все участники со всеми их данными должны быть доступны клиенту, поэтому никаких искусственных ограничений не накладывается.

file: server/server.js
Meteor.publish('members');
Meteor.startup(function () {
	if (Members.find().count() === 0) {
		Members.insert({ name: 'Александра Богинич', title: 'Александру', url: 'http://mariels.ru/member/profile_alexandra_igorevna.html', photo: 'images/member/Александра Богинич.jpg', thumb: 'http://mariels.ru/$userfiles/thumb_450_1136_94.jpg', vote: 0 });
		Members.insert({ name: 'Алена Мансурова', title: 'Алену', url: 'http://mariels.ru/member/profile_Alionushka.html', photo: 'images/member/Алена Мансурова.jpg', thumb: 'http://mariels.ru/$userfiles/thumb_444_1120_90.jpg', vote: 0 });
		// и так далее...
	}
});	


В коде выше стандартный способ инициализации коллекции в Meteor. Так как код находится в файле server/server.js, то выполняется он только на сервере.

Шаблоны HTML и Реактивность


Данные есть, теперь их нужно вывести в браузере. В Meteor по умолчанию используется JavaScript шаблонизатор Handlebars. На самом деле, довольно кривой шаблонизатор и для выполнения простой задачи вроде «получить доступ к индексу массива в цикле foreach» приходится писать новый обработчик тега. Но, привыкнув, работать с ним можно.



file: client/view/members.html
<template name="members">

<div id="members">
{{#render_members members}}
<span class="member span6">
	<span class="info-cont">
		<span class="shadow"></span>
		<a href="{{member.url}}" class="account">
			<img src="{{member.thumb}}" width="" height="" class="avatar"/>
			<span>{{member.name}}</span>
		</a>
	</span>
	<img src="{{member.photo}}" class="image" />
	<span class="rate-cont">
		<span class="shadow"></span>
		<button class="btn {{#if voted}}btn-info{{else}}btn-warning{{/if}} pull-center btn-large" data-id="{{member._id}}" {{#if voted}}disabled{{/if}}>
		{{#if voted}}
			Проголосуйте снова завтра
		{{else}}
			Голосовать за <span>{{member.title}}</span>
		{{/if}}
		</button>
	</span>
</span>
{{/render_members}}
</div>

</template>


Тег render_members был создан только для того, чтобы делить вывод на строки (выводить <div class="row"> через каждые две записи), а вообще это обычный foreach цикл. Переменная доступная шаблону только одна — массив members. В теле render_members доступны все поля каждого объекта из массива members. Если быть уж совсем точным, то members не массив, а курсор, но это не суть.

file: client/client.js
Template.members.members = function() {
	return Members.find({}, { sort: { vote: -1 }});
}


Members.find() возвращает курсор, тогда как Members.find().fetch() простой JavaScript массив. Используя курсор в качестве переменной шаблона members и обернув его в function() { } мы активируем реактивность Meteor на этой переменной шаблона. Это значит, что как только данные коллекции Members на сервере изменятся и обновления будут переданы на клиент, шаблон будет автоматически перерисован используя новые данные. И для этого не нужно никакого дополнительного кода на клиенте!

Методы сервера


file: server/server.js
// Код только для сервера
Votes = new Meteor.Collection('votes');


В коллекции Votes будут храниться все голоса и она может разрастись до нескольких тысяч записей. Мы не можем позволить такому огромному объему данных курсировать между сервером и клиентом по понятным причинам. К тому же, на клиенте нам совершенно ни к чему знать данные каждого голоса, такие как IP и дата. По этим причинам переменная объявляется только в коде, выполняемом на сервере, и Meteor.publish() / Meteor.subscribe() не вызываются.

file: server/server.js
// Проверка валидности IP и даты последнего голосования
var CanVote =  Match.Where(function(ip) {
	check(ip, String);
	if (ip.length > 0) {
		var yesterday = new Date();
		yesterday.setDate(yesterday.getDate() - 1);
		// голосовать можно только раз в сутки
		return Votes.find({ ip: ip, date: { $gt: yesterday } }).count() == 0;
	}
	return false;
});
// Методы сервера, доступные клиенту
Meteor.methods({
	// возвращает true если клиент может голосовать и false в обратном случае
	canVote: function() {
		return Match.test(headers.get('x-forwarded-for'), CanVote);
	},
	// проголосовать за участницу
	vote: function(memberId) {
		check(memberId, String);
		check(headers.get('x-forwarded-for'), CanVote);
		var voteId = Votes.insert({ memberId: memberId, ip: headers.get('x-forwarded-for'), date: new Date() });
		// имитация SQL JOIN
		Members.update(memberId, { $set: { vote: Votes.find({ memberId: memberId }).count() } });
		return voteId;
	},
	// возвращает количество голосов за участницу
	getMemberVotes: function(memberId) {
		check(memberId, String);
		return Votes.find({memberId:memberId}).count();
	},
	// возвращает общее суммарное количество голосов
	getTotalVotes: function() {
		return Votes.find().count();
	}
});


При помощи Meteor.methods() объявляется интерфейс связи между клиентом и сервером в рамках проекта. Так как коллекция Votes не доступна на клиенте, здесь объявлены методы для получения нужных данных об этой коллекции, как то количество голосов за участницу и общее количество голосов.

В функции голосования добавляется новая запись в коллекцию Votes, а также обновляется количество голосов (votes) у соответствующей записи из коллекции Members. Последнее нужно чтобы использовать реактивность в выводе списка участников (сортируется по votes) и графика рейтингов.

Вообще, Meteor.methods() можно определить в model.js, тогда для клиенской части создатутся обертки этих методов и при вызове данные на клиенте будут обновляться мнгновенно, а уж потом корректироваться в случае, если на сервере методы поведут себя по-другому. Это называется Latency Compensation. Но в данном случае, коллекция Votes не доступна на клиенте, а значит смысла в этом нет. Все равно придется ждать ответа от сервера.

Еще больше Реактивности




file: client/views/ratings.html
<template name="ratings">
	
<div id="ratings" class="well">
  <h1 class="heading uppercase">Рейтинги</h1>
  <div class="chart">
	  {{#each_with_index members}}
	  <div class="rating num{{index}}">
	  	<img src="{{data.thumb}}" class="avatar"/>
	  </div>
	  {{/each_with_index}}
  </div>
  <div class="pull-center pull-center-1">
	  <div id="votes">{{votes}}</div>
    	  <div><strong>голосов</strong></div>
  </div>
</div>

</template>


file: client/client.js
Session.setDefault('totalVotes', 0);
Meteor.startup(function() {
	// обновление значения totalVotes сессии
	Deps.autorun(function() {
		var total = 0;
		Members.find().forEach(function(m) { total += m.vote; });
		Session.set('totalVotes', total);
	});
	// обновление графика рейтингов топ-5
	Deps.autorun(function() {
		var top = Members.findOne({}, { sort: { vote: -1 }}); // текущий лидер голосования
		// update ratings chart
		Members.find({}, { sort: { vote: -1 }, limit: 5 }).forEach(function(m, i) {
			var height = top ? Math.floor((m.vote / top.vote) * 190) + 100 : 100;
			$('.rating.num'+(i+1)).css('height', height);
		});
	});
});
Template.ratings.members = function() {
	return Members.find({}, {limit: 5, sort: { vote: -1 }});
};
Template.ratings.votes = function() {
	return Session.get('totalVotes');
};


Session существует только на клиенте и она не персистентна, то есть сбрасывается при обновлении страницы. Объект Session так же как и курсор коллекций активирует реактивность, поэтому при изменении значения totalVotes в сессии будет перерисован шаблон ratings.

Deps.autorun() выполняется каждый раз, как реактивные данные в фукнции меняются. В данном случае это курсор Members.find(). Идея в том, что как только сервер обновит votes у какой-нибудь участницы, обновится и значение totalVotes сессии у всех клиентов, а это приведет к перериcовке блока рейтингов. Deps.autorun() используется для добавления коллбэка на изменения данных на клиенте. Есть способы подписаться на конкретные события коллекций типа added, changed, removed подробнее здесь.
Таким образом, если кто-то проголосовал в то время, пока посетитель видит блок рейтингов, столбики рейтингов изменят свою высоту, а счетчик увеличится.

Также здесь можно заметить использование jQuery. Его можно перемешивать с клиентским кодом Meteor почти без ограничений. Кстати, Meteor.startup(function {}) и jQuery(function() { }) идентичны.

file: client/client.js
Session.setDefault('voted', false);
// показываем кнопку для голосования или нет
Template.members.voted = function() {
	return Session.get('voted');
}
Template.members.events = {
	'click button': function(event) {
			var $btn = $(event.currentTarget);
			// помечаем сразу, так как Latency Compensation не задействуется
			// и в этот момент перерисовываются зависимые шаблоны в DOM
			Session.set('voted', true);
			// вызываем метод на сервере
			Meteor.call('vote',	$btn.data('id'), function(error, vote) {
				if (error) {
					Session.set('voted', false);
				}
			});
		}
}


Простой пример вызова метода сервера. Вызов происходит асинхронно, поэтому пока ждем ответа помечаем значение сессии voted как true. При анализе ответа уже можем откатить это, если произошла ошибка. Ответ сервера нам в принципе здесь не важен, интересно только произошла ли ошибка при голосовании или голос засчитан.

В этом коде также есть пример использования событий DOM. В принципе, можно использовать и jQuery.on(), но я решил пойти каноническим путем.

file: client/views/index.html
<head>
    <!-- Много meta тегов в т.ч. для SEO и социалок -->
    <link href="stylesheets/project.css" media="screen" rel="stylesheet" type="text/css" />
    <!-- ... другие стили CSS -->
    <script type="text/javascript" src="js/flipclock/flipclock.min.js"></script>
    <!-- другие скрипты JavaScript -->
    <title>Мисс Осень 2013</title>
</head>
  
<body>
  <div class="page-header-bg"></div>
  {{>header}}
  <div class="container-fluid">
	  <div class="container">
		<div class="page-header">
			<h1 class="header-gradient">Мисс Осень 2013</h1>
		</div>
	  	{{>page}}
	 </div>
 </div>
</body>

<template name="page">
  {{#if contestInProgress}}
	{{>countdown}}
	{{>members}}
	{{>social}}
	{{>ratings}}
	{{>terms}}
	{{>footer}}
{{else}}
	{{>winner}}
	{{>social}}
	{{>footer}}
{{/if}}
</template>


Meteor обрабатывает все JavaScript, HTML, CSS файлы найденные в проекте и объединяет по определенным правилам. Однако, файлы в папке public считаются статичными, доступны как есть и не обрабатываются Meteor. Стили можно было бы перенести под управление Meteor, но было решено использовать стандартный подход – включить ссылки на статичные файлы в заголовок HTML.

Некоторые сторонние библиотеки JavaScript тоже включаются как статичные файлы, хотя их можно было перенести в папку client и так же использовать из своего клиентского JavaScript кода. Дело в том, что не все библиотеки написаны так, что могут быть использованы в Meteor, в таких случаях всегда можно вернуться к стандартному включению в заголовок HTML. При различии способа включения сторонней библиотеки, использование в клиентском коде Meteor одинаково естественно.

file: client/client.js
contestEndDate = new Date('01/30/2014 12:00');

Session.set('inProgress', new Date() < contestEndDate);

Template.header.contestInProgress =
Template.page.contestInProgress = 
Template.footer.contestInProgress = function() {
	return Session.get('inProgress');
}

Meteor.startup(function() {
	// обратный отсчет 
	var targetDate = contestEndDate;
	var currentDate = new Date();
	var offsetSeconds = (targetDate.getTime() - currentDate.getTime()) / 1000;
	offsetSeconds = Math.max(0, offsetSeconds);
	var clock = $('#countdown').FlipClock(offsetSeconds, {
		clockFace: 'DailyCounter',
		defaultClockFace: 'DailyCounter',
		countdown: true,
		callbacks: {
			stop: function() {
				Session.set('inProgress', false);
			}
		}
	});
});


В index.html можно увидеть еще одно применение реактивности. Переменная contestInProgress обозначает статус конкурса – в процессе или уже окончен. Вид страницы полностью меняется в зависимости от этого статуса. Статус устанавливается при инициализации страницы, а также меняется клиентом при возникновеннии события stop счетчика FlipClock.

Переменная contestInProgress есть в трех шаблонах и значение у нее одно и то же. Шаблоны независимы друг от друга и перерисовываются по отдельности.

Из кода видно, что из обработчика события, инициируемого сторонней библиотекой FlipClock, меняется значение сессии клиента Meteor. И это при том, что библиотека FlipClock загружается браузером клиента при загрузке страницы.
Вот тут открывается неочевидное преимущество Meteor. Раз так просто перерисовать страницу по завершении отсчета, так почему бы этого не сделать? Это всего лишь одна строчка кода, зато будет эффектно смотреться если кто-то в этот момент будет просматривать страницу.
Если бы проект разрабатывался на PHP+AJAX, эта была бы отдельная задача. Несложная, но учитывая, что это событие случится только один раз за все существование проекта, возможно у программиста просто не дойдут руки сделать обновление статуса страницы. Да и зачем тратить на это время, если это увидят пару человек? Остальные просто получат уже страницу с победителем. В этом и есть прелесть Meteor – программисту не нужно думать над протоколом общения и он может сконцентрироваться на тех мелочах, которые раньше бы откладывались в долгий ящик.

Кульминация


Ну и в конце подведу некий итог. Проект оказался успешным и, я думаю, не последнюю роль здесь играла реактивность Meteor. Разработка подобного небольшого веб-проекта на Meteor одно удовольствие, хотя иногда приходится долго искать решение тривиальных задач. Я бы точно не сделал страницу с таким количеством интерактивных элементов, если бы использовал PHP.

Достоинства:

  1. Не нужно думать о протоколе связи между клиентом и сервером
  2. Код сервера и клиента пишется на одном языке
  3. Удобно отлаживать код прямо из браузера
  4. Можно быстро продемонстрировать состояние проекта клиенту при помощи meteor deploy
  5. Активное комьюнити, в том числе на stackoverflow


Недостатки:

  1. Все еще в превью стадии, не годится для крупных проектов
  2. В коде некоторые тривиальные задачи приходится решать при помощи громоздких конструкций
  3. Установка на боевой сервер требует наличие node.js сервера, а если уже есть http сервер, то настройки прокси с 80 порта
  4. Совершенно не годится для мобильных устройств из-за большого количества JavaScript
  5. Весь контент в браузере генерируется при помощи JavaScript, это может негативно отразиться на SEO


Сам конкурс уже закончился, а потыкать тестовую версию можно здесь promo.meteor.com
Исходники проекта github.com/imajus/promo
Tags:
Hubs:
Rating0
Comments9

Articles