Pull to refresh

Разработка мобильных Javascript MVC приложений с Framework7, RequireJS и Handlebars

Reading time 14 min
Views 33K
Недавно передо мной стала задача разработки IPhone и Android приложения. Опыта разработки под IOS у меня ранее не было, да и хотелось написать один раз и запускать на обеих платформах. Соответственно был выбран был выбран Javascript и PhoneGap.

И если с языком я определился относительно быстро, то далее было много вопросов.
Хотелось сделать, что бы приложение максимально повторяло интерфейс IOS7 и было похоже на native по скорости работы. При этом с одной стороны не было желания использовать «монстров», на подобии dojo или jquery mobile. c другой стороны хотелось получить удобную модульную MVC структуру приложения.

В итоге в финал моего личного сравнения вышли:
Ionic framework: http://ionicframework.com/
Framework7: http://www.idangero.us/framework7/

У Ионика сначала мне понравилась документация, простые примеры и знакомая по AngularJs структура кода. Но после первых попыток создать приложение наступило разочарование. Запущенное простое приложение на Iphone5 тормозило. При нажатии на кнопки или навигации была визуально заметна задержка между нажатием и срабатыванием. На подобии 300мс задержки при клике. Но по заявлениям создателей их фреймворк содержит собственную реализацию библиотеки fastclick… Странно. Так же даже в простом приложении временами были заметны подтормаживания в анимации. В итоге после пары дней чтения документации и тестовых примеров я понял, что надо искать что-то еще.

Дальше я вернулся к Framework7. Запустил тестовые приложения, глянул компоненты в kitchen sink и первоначально испытал wow эффект. На IPhone все работает быстро, красиво и очень похоже на native. При этом столкнулся с двумя достаточно большими минусами:
  • На тот момент практически отсутствовала документация. Сейчас она уже есть, достаточно подробная (http://www.idangero.us/framework7/docs/).
  • Во всех примерах код был в одном файле-простыне в jquery-like формате. При этом отсутствовала модульность, подгрузка шаблонов из отдельных файлов и т.п.

В общем я подтянул свои теоретические знания, просмотрел различные статьи и примеры и смог решить для себя задачу по совмещению Framework7 и модульного MVC подхода для создания мобильных приложений. Для реализации асинхронной загрузки модулей использовал RequireJs, для шаблонов – Handlebars.

Соответственно создал парочку учебных примеров и сейчас хочу поделиться ими с сообществом. Надеюсь они будут полезны, как начинающим разработчикам, так и более опытным, которые пока еще не знают про данный фреймворк.

Начинаем


Для работы нам потребуются следующие библиотеки:
  • Framework7
  • Handlebars – необходим для шаблонов
  • RequireJS – асинхронная загрузка модулей
  • Дополнительные плагины к RequireJs для загрузки шаблонов:
  • А также хочу вас познакомить с прекрасной библиотекой иконок – ionicons http://ionicons.com/


Структура проекта



Создадим следующую структуру файлов проекта (файлы index.html и app.js пока оставим пустыми)
Что бы упростить себе жизнь – можно скачать архив со структурой по этой ссылкe:
Dropbox
(В данном архиве уже заполнены первые версии файлов index.html и app.js)

Также сразу даю ссылку на исходники на Github — там лежит последняя версия вместе с пошаговой историей правок — создания данного тестового приложения:
https://github.com/philipshurpik/Framework7-MVC-base

Создадим самый простой index.html файл, в котором подключим все необходимые библиотеки:

<!DOCTYPE html>
<html class="with-statusbar-overlay">
<head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, minimal-ui">
	<meta name="apple-mobile-web-app-capable" content="yes">
	<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
	<title>F7 Contacts MVC</title>
	<link rel="stylesheet" href="lib/css/framework7.css">
	<link rel="stylesheet" href="lib/css/ionicons.css">
	<link rel="stylesheet" href="css/app.css">
</head>
<body>
<div class="statusbar-overlay"></div>
<div class="views">
	<div class="view view-main navbar-fixed">
		<div class="navbar">
			<div class="navbar-inner">
				<div class="left"></div>
				<div class="center" style="left:22px">Contacts</div>
				<div class="right">
					<a href="contact.html" class="link icon-only"><i class="icon icon-plus">+</i></a>
				</div>
			</div>
		</div>
		<div class="pages">
			<div data-page="list" class="page">
				<div class="page-content">
					<div class="list-block contacts-list">
						<ul>
							<a href="contact.html" class="item-link item-content">
								<div class="item-media"><i class="icon ion-ios7-person"></i></div>
								<div class="item-inner">
									<div class="item-title">Andrey Smirnov</div>
								</div>
							</a>
							<a href="contact.html?id={{id}}" class="item-link item-content">
								<div class="item-media"><i class="icon ion-ios7-person"></i></div>
								<div class="item-inner">
									<div class="item-title">Olga Kot</div>
								</div>
							</a>
						</ul>
					</div>
				</div>
			</div>
		</div>
	</div>
</div>
</body>
</html>
<script type="text/javascript" src="lib/framework7.js"></script>
<script type="text/javascript" src="app.js"></script>


Также в файл app.js поместим инициализацию приложения:
var f7 = new Framework7({
	modalTitle: 'F7-MVC-Base',
	animateNavBackIcon: true
});
var mainView = f7.addView('.view-main', {
	dynamicNavbar: true
});


Запустим и получим следующую картинку:

Вот. У нас есть первая страничка и на ней даже что-то больше, чем hello-world.

Да, если кто не знает. В Devtools Chrome рядом с консолью есть вкладка Emulation, на которой можно выбрать нужный девайс и посмотреть, как примерно приложение будет выглядеть на экране этого устройства.



Подключаем RequireJs и Handlebars, подгружаем контакты


Теперь нам необходимо динамически подгружать контакты (например из localstorage) и отображать их в списке.
Для этого изменим наши файлы:

1. index.html
Заменим прямое подключение нашего app.js файла на подключение Require.Js
<script data-main="app" src="lib/require.js"></script>
Атрибут data-main указывает на точку входа в приложение (это наш файл app.js)&
Также можно удалить то, что находится внутри тегов ul – внутренности списка будут генерироваться с помощью шаблона.

2. app.js
Переделаем наш файл в RequireJs модуль:
define('app', ['js/list/listController'], function(listController) {
	var f7 = new Framework7({
		modalTitle: 'F7-MVC-Base',
		animateNavBackIcon: true
	});
	var mainView = f7.addView('.view-main', {
		dynamicNavbar: true
	});
	listController.init();
	return {
		f7: f7,
		mainView: mainView
	};
});

Все тоже самое, только обернули в модуль + добавили загрузку нашего первого контроллера, которого пока еще нету.

Главная страница: контроллер, представление, template элемента


Теперь нам необходимо создать контроллер для главной страницы, ее представление, а также handlebars template.
Предлагаю назвать и разместить файлы следующим образом:

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

Создадим простой контроллер для списка. И в нем сразу же инициализируем наш localstorage несколькими объектами контактов:

Файл: js/list/listController.js
define(["js/list/listView"], function(ListView) {

	function init() {
		var contacts = loadContacts();
		ListView.render({ model: contacts });
	}

	function loadContacts() {
		var f7Base = localStorage.getItem("f7Base");
		var contacts = f7Base ? JSON.parse(f7Base) : tempInitializeStorage();
		return contacts;
	}

	function tempInitializeStorage() {
		var contacts = [
			{id: "1", firstName: "Alex", lastName: "Black", phone: "+380501234567" },
			{id: "2", firstName: "Kate", lastName: "White", phone: "+380507654321" }
		];
		localStorage.setItem("f7Base", JSON.stringify(contacts));
		return JSON.parse(localStorage.getItem("f7Base"));
	}

	return {
		init: init
	};
});


Так же теперь нам необходимо добавить представление, которое будет отвечать за рендеринг наших данных (которые мы передаем при его инициализации) с помощью темплейта.
Файл: js/list/listView.js
define(['hbs!js/list/contact-list-item'], function(template) {
	var $ = Framework7.$;

	function render(params) {
		$('.contacts-list ul').html(template(params.model));
	}

	return {
		render: render
	};
});


А также код нашего простого темплейта:
Файл: js/list/contact-list-item.hbs
{{#.}}
	<a href="contact.html?id={{id}}" class="item-link item-content">
		<div class="item-media"><i class="icon ion-ios7-person"></i></div>
		<div class="item-inner">
			<div class="item-title">{{firstName}} {{lastName}}</div>
		</div>
	</a>
{{/.}}


Запускаем — и получаем — все тоже самое, но модульное и гораздо более расширяемое.

Теперь нам необходимо добавить страницу просмотра и редактирования контакта.

Навигация между страницами в Framework7


Каждая страница размещена в отдельном html файле.
Страница содержится внутри div c class=”page”
<div class="page" data-page="list">

Аттрибут data-page определяет уникальное название страницы которое будет нам необходимо в дальнейшем для роутинга.
Все визуальные элементы страницы необходимо размещать внутри:
<div class="page-content"> который является дочерним для <div class="page">

Навигация между страницами осуществляется или при нажатии на html ссылку:
<a href="about.html">Go to About page</a>
Bли из js кода:
app.mainView.loadPage('about.html');

Навигация назад (вместе с анимацией) осуществляется аналогично:
Или добавлением класса back в ссылку:
<a href="index.html" class="back"> Go back to home page </a>
Или из js кода:
app.mainView.goBack();

При переходе между страницами Framework7 генерирует события, на которые можно подписаться:
PageBeforeInit, PageInit, PageBeforeAnimation, PageAfterAnimation, PageBeforeRemove

Полная информация о страницах и событиях тут:
http://www.idangero.us/framework7/docs/pages.html
http://www.idangero.us/framework7/docs/ linking-pages.html

Создаем router.js


Воспользуемся событием, которое возникает после вставки новой страницы в DOM – PageBeforeInit.
Создадим простой роутер (файл router.js) и положим его в папку js, в котором подпишемся на возникновение события pageBeforeInit:

define(function() {
	var $ = Framework7.$;

	function init() {
		$(document).on('pageBeforeInit', function (e) {
			var page = e.detail.page;
			load(page.name, page.query);
		});
    }

	function load(controllerName, query) {
		require(['js/' + controllerName + '/'+ controllerName + 'Controller'], function(controller) {
			controller.init(query);
		});
	}

	return {
        init: init,
		load: load
    };
});

При срабатывании события мы с помощью Require загружаем необходимый нам модуль контроллера и инициализируем его, передавая в него параметры запроса, с которым была открыта страница.

Также переделаем модуль app.js, добавим в него инициализацию роутера и уберем подключение и инициализацию контроллера:
define('app', ['js/router'], function(Router) {
	Router.init();
	var f7 = new Framework7({
		modalTitle: 'F7-MVC-Base',
		animateNavBackIcon: true
	});
	var mainView = f7.addView('.view-main', {
		dynamicNavbar: true
	});
	return {
		f7: f7,
		mainView: mainView,
		router: router
	};
});

Теперь при первой загрузке приложения, после вставки главной страницы в DOM сработает обработчик события pageBeforeInit.
При этом его свойство e.detail.page.name будет равняться list, то есть тому, что было задано тут в свойстве data-page: Соответственно будет запущен соответствующий контроллер.

Страница редактирования контакта


Далее необходимо создать страницу добавления и редактирования контакта.
Добавим в корень проекта html файл contact.html (если вы скачивали структуру файлов из архива, то он там уже должен быть)
Соответствующие ссылки на contact.html уже были добавлены ранее в navbar главной страницы и в темплейт элементов списка контактов.
<div class="navbar">
	<div class="navbar-inner">
		<div class="left sliding">
			<a href="#" class="back link">
				<i class="icon icon-back-white"></i>
				<span>Back</span>
			</a>
		</div>
		<div class="center contacts-header"></div>
		<div class="right contact-save-link">
			<a href="#" class="link">
				<span>Save</span>
			</a>
		</div>
	</div>
</div>
<div class="pages">
	<div data-page="contact" class="page contact-page">

	</div>
</div>


Теперь при нажатии на элемент списка или на кнопку добавить – роутер пробует загрузить файл js/contact/contactController.

Соотвественно нам необходимо создать его, представление страницы, а так же шаблон содержимого страницы. Вот так:


Содержимое файла contactController.js:
define(["app","js/contact/contactView"], function(app, ContactView) {

	var state = {isNew: false};
	var contact = null;

	function init(query){
		if (query && query.id) {
			var contacts = JSON.parse(localStorage.getItem("f7Base"));
			for (var i = 0; i< contacts.length; i++) {
				if (contacts[i].id === query.id) {
					contact = contacts[i];
					state.isNew = false;
					break;
				}
			}
		}
		else {
			contact = { id: Math.floor((Math.random() * 100000) + 5).toString()};
			state.isNew = true;
		}
		ContactView.render({
			model: contact,
			state: state
		});
	}

	return {
		init: init
	};
});

Если страница в режиме редактирования (в query содержится значение id контакта, то получаем его из localStorage.
Если нет, то создаем новый. Пока что для простоты мы не используем модели, поэтому наш контакт – это просто объект.

Также страница представления contactView.js:
define(['hbs!js/contact/contact'], function(viewTemplate) {
	var $ = Framework7.$;

	function render(params) {
		$('.contact-page').html(viewTemplate({ model: params.model }));
		$('.contacts-header').text(params.state.isNew ? "New contact" : "Contact");
	}

	return {
		render: render
	}
});

И шаблон contact.hbs:
<div class="page-content">
	<form id="contactEdit" class="list-block">
		<ul>
			<input name="id" type="hidden" value="{{model.id}}">
			<li>
				<div class="item-content">
					<div class="item-media"><i class="icon ion-ios7-football-outline"></i></div>
					<div class="item-inner">
						<div class="item-input">
							<input name="firstName" type="text" placeholder="First name" value="{{model.firstName}}">
						</div>
					</div>
				</div>
			</li>
			<li>
				<div class="item-content">
					<div class="item-media"><i class="icon ion-ios7-football-outline"></i></div>
					<div class="item-inner">
						<div class="item-input">
							<input name="lastName" type="text" placeholder="Last name" value="{{model.lastName}}">
						</div>
					</div>
				</div>
			</li>
			<li>
				<div class="item-content">
					<div class="item-media"><i class="icon ion-ios7-telephone-outline"></i></div>
					<div class="item-inner">
						<div class="item-input">
							<input name="phone" type="tel" placeholder="Phone" value="{{model.phone}}">
						</div>
					</div>
				</div>
			</li>
		</ul>
	</form>
</div>

Ну что же. Теперь мы можем открыть нашу страницу добавления или редактирования контакта:


Осталось добавить возможность контакты сохранять и удалять.
Начнем с сохранения.

Сохранение контактов


Для начала добавим обработчик кнопки сохранить.
Конечно можно сделать это сразу напрямую в контроллере вот так:
$(‘.contact-save-link’).on(‘click’, function() {
 // some code here
});

Но так делать не хорошо, и лучше отделять работу с DOM и работу с данными и моделями.
Поэтому разделим подписку на обработку события и саму обработку.
В контроллере сделаем массив bindings:
var bindings = [{
		element: '.contact-save-link',
		event: 'click',
		handler: saveContact
	}];

Передадим этот массив в качестве одного из свойств объекта params в представление.

И добавим функцию-обработчик:
function saveContact() {
// some code here
}

А в представлении добавим подписку на события по данному конфигу – функцию bindEvents:
	function bindEvents(bindings) {
		for (var i in bindings) {
			$(bindings[i].element).on(bindings[i].event, bindings[i].handler);
		}
	}

И ее вызов из функции render:
bindEvents(params.bindings);

Теперь необходимо получить значение данных введенные в форму:
Делаем это в функции saveContact:

function saveContact() {
		var contacts = JSON.parse(localStorage.getItem("f7Base"))
		var newContact = app.f7.formToJSON('#contactEdit');
		if (state.isNew) {
			contacts.push(newContact)
		}
		else {
			for (var i = 0; i< contacts.length; i++) {
				if (contacts[i].id === newContact.id) {
					contacts[i] = newContact;
					break;
				}
			}
		}
		localStorage.setItem("f7Base", JSON.stringify(contacts));
		app.router.load('list');
		app.mainView.goBack();
	}

Так же полученные данные сохраняем сразу в localStorage.
Последние две строчки отвечают за возврат на предыдущую страницу (список), а также перезагрузку данных в listController.

У нас теперь все работает!

Создание модели:


Но так оперировать всеми данными в контроллере не очень хорошо. К тому же иногда необходимо добавить специальные функции – например по валидации данных.

Поэтому сделаем модель в файле js/contactModel.js.
За одно добавим в нее функцию валидации, а также установки значений из другого объекта.

define(['app'],function(app) {

	function Contact(values) {
		values = values || {};
		this.id = values['id'] || Math.floor((Math.random() * 100000) + 5).toString();

		this.firstName = values['firstName'] || '';
		this.lastName = values['lastName'] || '';
		this.phone = values['phone'] || '';
	}

	Contact.prototype.setValues = function(formInput) {
		for(var field in formInput){
			if (this[field] !== undefined) {
				this[field] = formInput[field];
			}
		}
	};

	Contact.prototype.validate = function() {
		var result = true;
		if (!this.firstName && !this.lastName) {
			result = false;
		}
		return result;
	};

	return Contact;
}); 

Заметьте, функции добавляются не в сам объект, а в его прототип. Соответственно при передаче или сохранении объекта в JSON передаются только его свойства, без функций.

Теперь подключим модель в contactController:
Добавим в список зависимостей:
define(["app","js/contact/contactView", "js/contactModel"], function(app, ContactView, Contact)

Изменим в функции init соответственно присвоение и создание контакта:
contact = new Contact(contacts[i]);

и
contact = new Contact();

И модифицируем функцию save, добавив в нее запуск валидации модели:
function saveContact() {
		var formInput = app.f7.formToJSON('#contactEdit');
		contact.setValues(formInput);
		if (!contact.validate()) {
			app.f7.alert("First name and last name are empty");
			return;
		}
		var contacts = JSON.parse(localStorage.getItem("f7Base"));
		if (state.isNew) {
			contacts.push(contact);
		}
		else {
			for (var i = 0; i< contacts.length; i++) {
				if (contacts[i].id === contact.id) {
					contacts[i] = contact;
					break;
				}
			}
		}
		localStorage.setItem("f7Base", JSON.stringify(contacts));
		app.mainView.goBack();
		app.router.load('list');
	}

Сохранение готово.

Swipe to delete


Осталось добавить удаление из списка контактов.
Реализуем это с помощью жеста Swipe To Delete в списке.
Модифицируем разметку шаблона элементов:
{{#.}}
	<li id="{{id}}" class="swipeout">
		<a href="contact.html?id={{id}}" class="item-link item-content swipeout-content">
			<div class="item-media"><i class="icon ion-ios7-person"></i></div>
			<div class="item-inner">
				<div class="item-title">{{firstName}} {{lastName}}</div>
			</div>
		</a>
		<div class="swipeout-actions">
			<div class="swipeout-actions-inner">
				<a href="#" class="swipeout-delete">Delete</a>
			</div>
		</div>
	</li>
{{/.}}

Добавим в listController подписку на событие:
var bindings = [{
		element: '.swipeout',
		event: 'deleted',
		handler: itemDeleted
	}];

И дальше сделаем по аналогии с подпиской в контактах – передадим в представление и там подпишемся в функции bindEvents(bindings)

А также добавим обработчик события удаления:
function itemDeleted(e) {
		var id = e.srcElement.id;
		var contacts = JSON.parse(localStorage.getItem("f7Base"));
		for (var i = 0; i < contacts.length; i++) {
			if (contacts[i].id === id) {
				contacts.splice(i, 1);
			}
		}
		localStorage.setItem("f7Base", JSON.stringify(contacts));
	}

Смотрим на результат:


Заключение


У нас вышло готовое очень простое мобильное MVC приложение с использованием Framework7.
А сам Framework7 в связке с Phonegap позволяет создавать красивые native-like приложения в первую очередь для IOS. Что может быть полезно для разработчиков, которые плохо знакомы с ObjectiveC.
При этом мы сразу получаем кросс-платформенное приложение, которое отлично и плавно работает на Android 4.4 (и скорее всего должно так же работать и на следующих версиях).
Для нормальной поддержки недорогих Android устройств на предыдущих версиях Android, достаточно отключить анимацию между страницами, что бы получить тоже достаточно приемлимое быстродействие UI.

Исходники проекта вместе с последовательной историей правок доступны тут:
https://github.com/philipshurpik/Framework7-MVC-base

Так же я сделал расширенный учебный пример приложения контактов, имеющий больше фич и использующий больше возможностей Framework7. В нем добавлены левая выдвигающаяся панель меню, popup редактирования, строка поиска и т.д.
Его исходники вот:
https://github.com/philipshurpik/Framework7-Contacts7-MVC
А вот и скриншоты (с котиками):


Надеюсь эти примеры окажутся вам полезными.
Я сам учился на подобных, поэтому и решил создать данную статью.

Буду рад ответить на вопросы.

П.С. Автора данного фреймворка vladimirkharlampidi на хабре пока нету, но если хабровчан заинтересует эта тема — я думаю он тоже будет рад принять инвайт и присоединиться к обсуждению.

П.П.С. Еще я сделал небольшой research по поводу скорости работы на Android, особенно на старых версиях и залил в репозиторий в app.css хаки по оптимизации css анимаций. Возможно какие-то из них войдут в будущие версии фреймворка. Ну и возможно кому-то будут полезны для их приложений.
Tags:
Hubs:
+23
Comments 26
Comments Comments 26

Articles