- PVSM.RU - https://www.pvsm.ru -

Пишем сложное приложение на knockout.js — 2

Пишем сложное приложение на knockout.js — 2Я тут пишу одну эпическую мегахрень, которую хочу пропиарить на хабре. Эта штука типа распределенной социальной сети. Там есть ядра с api, которые общаются по некоторому стандарту и фронтенд. Особенностью сети является то, фронтенд живет «отдельно» от ядра, то есть сеть не имеет своего домена — берем html, ставим ссылку на любое ядро и получаем сеть, которая живет поверх сайта. Внешне это похоже на социальные плагины фейсбука [1] — комментарии и лайки оттуда можно поставить на любую свою страницу — только вместо тегов fb-like используются мощные биндинги knockout.js [2] + пользователь не ограничивается огрызками из комментариев и лайков — на сайт можно импортировать практически любой блок из сети и сделать почти любое действие. Фронтенд написан на тех же технологиях, которые юзер может использовать и дописывать на своей странице.

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

Расскажу про систему, которая встраивается на html-страницу через биндинги нокаута. Код живет в подключаемых виджетах, которые состоят из html-шаблонов с knockout-обвязкой. Виджеты могут быть вложены друг в друга. Все это использует require.js [3] и живет в amd [4] форме. Зависимости от внешней страницы сведены к минимуму, все библиотеки (jquery, knockout и плагины) используются только свои в локальном пространстве с namespace-ами. Для сборки кода используется r.js [5]. Еще как крутые перцы напишем на базе бутстраповского диалога полноценный оконный менеджер — с нокаутом это как два пальца об асфальт…

Демо и исходники

Советую проглядеть мою первую статью по нокауту — http://habrahabr.ru/post/154003/ [6]. Будем развивать идеи, начатые там.

Прототип фронтенда сети лежит здесь — https://github.com/Kasheftin/uncrd [7].

Как все это выглядит, можно посмотреть здесь — http://www.photovision.ru [8]. Там сайт с фотографиями, который намерено не переделывался. Подключается один скрипт с домена uncrd.com, который обеспечивает функциональность сети. При кликах на фотки должны всплывать окна просмотрщика фотографий, можно переходить на анкеты авторов, регистрироваться, постить фотки и комментарии. Моей целью было написание стандартной сети с минимальным допустимым функционалом.

В репозитории также находится кусок документации по работе с сетью, где перечислено большинство блоков и описаны их параметры — http://uncrd.com/docs/1.html [9].

Следует заметить, что фронтенд состоит из универсального ядра и специфичных для сети виджетов. Просмотр фото и анкеты авторов относятся ко второму, оконный менеджер и механизм подключения виджетом — к первому. Мы будем рассматривать именно основу, но она плохо выделена из кода системы, поскольку разрабатывается единовременно (к примеру, авторизация находится в ядре, хотя должна быть в специфичном для сети месте), поэтому расскажу кое что из теории, а потом в demo-ветке соберу голое ядро, и подробно разберем на нем пару демовиджетов.

Недостатки и css

Стандартная логика, когда на внешний сайт подключаются модули из какой-то социальной сети — использование iframe. Это безопасно, потому что внешний сайт не утащит сессию, и css-правильно, потому что css внешнего сайта не влияет на стили внутри iframe. Эта логика здесь нарушена. Код сети встраивается прямо на страницу сайта, и за счет этого мы получаем гораздо больше возможностей, а не просто тупую вставку предопределенных квадратных блоков. Безопасность в большой степени решается регистрацией сайтов в сети и токенами. А вот css не решается. Не знаю, как на страницу с произвольными css-правилами вставить свой элемент, не сломать все вокруг и на 100% знать, как он выглядит (кроме перечисления абсолютно всех css-свойств в каждом теге). Поэтому в прототипе предполагается, что сайт работает на бутстрапе (сеть не тащит css со своим бутстрапом, поскольку это может сломать дизайн исходного сайта).

Ядро системы

Фронтенд состоит из ядра системы и динамически подгружаемых виджетов. Каждый виджет состоит из js-объекта, который находится в отдельном файле в amd-форме (пример messageForm.js [10]) и html-шаблона с кодом (пример messageForm.html [11]). За последнее время мне встречалось несколько приложений на knockout.js с модульным подходом, и все они использовали такой биндинг:

ko.bindingHandlers.widget = {
	update: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
		var widget = ko.utils.unwrapObservable(valueAccessor().data);
		require([widget.templateName],function(html) {
			ko.renderTemplate(element,bindingContext.extend({$data:widget}),{html:html},element);
			if (widget.domInit)
				widget.domInit(elem,valueAccessor());
		});
    	return { controlsDescendantBindings: true};
}

Затем в core.js или в любом виджете (так как они поддерживают вложения друг в друга) создается объект требуемого виджета: this.w = SomeWidget(); и в нужном месте шаблона вызывается биндинг <!-- ko widget: {data: w} --><!-- /ko -->. Замечу, что knockout нативно поддерживает только «именованный» и «внутренний безымянный» шаблоны, и поэтому метод renderTemplate в данном случае использует stringTemplateEngine [12] из этой статьи [6].

Плюс данного подхода в простоте. При явном создании виджета известно, что где живет, и биндинг тупо рендерит шаблон в контексте нового созданного объекта. Однако в uncrd необходимо, чтобы виджеты можно было создавать из html, без программирования. Нужно, чтобы работал, к примеру, такой код:

... код сайта ...
<!-- uncrd if: user -->
	Привет, <!-- uncrd text: user.name --><!-- /uncrd -->
	<!-- uncrd widget: 'userMenu' --><!-- /uncrd -->
<!-- /uncrd -->
<!-- uncrd ifnot: user -->
	<!-- uncrd widget: 'loginForm' --><!-- /uncrd -->
<!-- /uncrd -->
... код сайта ...

Это означает, что виджеты должны создаваться внутри widget-биндинга. При этом возникает проблема с доступом к создаваемому объекту — внутри кода модели мы не знаем, когда будут загружены и созданы объекты внутренних подвиджетов и где они будут жить. Следовательно, при создании виджета внутри widget-биндинга необходимо регистрировать создаваемый объект в его паренте внутри некоторой переменной childrenWidgets, и, если требуется удалить виджет, рекурсивно удалять все поддерево. Этим обусловлен довольно объемный код получившегося биндинга, widgetBinding.js [13].

Оконный менеджер

Мне нравится, как выглядит всплывающий диалог бутстрапа. Даже попытался использовать его оконный jquery-плагин, однако помешали глюки с fade при создании нескольких окон и неверный скролл. Вру. Главным образом помешало осознание того, что под рукой вся мощь нокаута, а он возится с jquery и DOM-ом. Ненавижу DOM-манипуляции, если есть нокаут. В uncrd манипуляции с DOM-ом встречаются всего в двух случаях в специально отведенных для этого domInit-методах. Поэтому на стилях бутстрапа был написан свой оконный менеджер windowManager.js [14], который поддерживает множественные открытые окна и перетаскивания, а все параметры окон — observable-переменные.

Расскажу про один изящный трюк там. windowManager имеет метод open, через который открывается окно с любым виджетом. Открываемое окно само по себе тоже является виджетом, и поэтому регистрирует себя в массиве childrenWidgets у парента, которым является windowManager. При инициализации windowManager-а мы явно задаем this.childrenWidgets = ko.observableArray([]), и поэтому он не переопределяется обычным массивом в widget биндинге. Удобство накаута здесь в том, что observableArray имеет те же методы push, pop, splice, что и обычный массив. Поэтому внутри widget биндинга не важно, обычный это массив или observable. Каждое открываемое окно регистрирует себя теперь уже в observableArray-е. И это значит, что можно подписаться на изменения последнего, что и делается. И теперь — если childrenWidgets не пусто, нужно затемнить страницу сайта, «прибить» контент с position:fixed и показать готовые окна, а если нет, то вернуть все как было.

Само же модальное окно modalWindow3.js [15] — это html от бутстрапа + расчет позиционирования в зависимости от размера контента, положения и перетаскивания, ничего сверхестественного. Единственной особенностью является то, что название, заголовок и футер внутреннего виджета могут быть подвиджетами, определяемыми через observable-переменные, и при изменении параметров происходит автоматическое пересоздание виджетов. Причем для этого не понадобилось писать ни строчки — все обеспечивает метод update в widget биндинге.

Индикация загрузки

Индикация загрузки при ajax-запросах — это одна из запарных вещей в UI сайта. Каждый раз нужно думать, где всплывет иконка с «пожалуйста подождите» при сабмите очередной формы, куда нарисуется результат (особенно при ошибке), и что делать с остальными элементами на экране (блокировать ли инпуты и как реагировать на попытку закрыть окно с отправляющейся формой). В движке сети реализован следующий механизм для индикации загрузки.

В прототип каждого виджета примешиваются методы eventEmitter-а [16]. При инициализации виджета можно проставить специальную переменную this.requiresLoading = true, и тогда widgetBinding будет считать виджет асинхронным и ожидать от него события ready. Однако виджет рендерится сразу, вне зависимости от своего состояния, и поэтому должен внутри себя озаботится тем, что ему показывать пока не подготовит свои данные. Простые виджеты обычно имеют переменную this.loading = ko.observable(true), а их шаблоны выглядят так:

<!-- uncrd if: loading -->
	<div class="uncrd-loading-with-icon">Загрузка...</div>
<!-- /uncrd -->
<!-- uncrd ifnot: loading -->
	... здесь разметка виджета...
<!-- /uncrd -->

Предоположим, что на странице находится ссылка, при нажатии на которую нужно открыть модальное окно с профилем пользователя,

<a href="#" data-uncrd="click:open.bind($data,{name:'profile',id:123})">Профиль юзера #123</a>.

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

<a href="#" data-uncrd="click:open.bind($data,{name:'profile',id:123,loading:'after'})">Профиль юзера #123</a>
<!-- или так -->
<a class="uncrd-loading-after" href="#" data-uncrd="click:open.bind($data,{name:'profile',id:123})">Профиль юзера #123</a>.

И тогда при клике модальное окно не открывается пока асинхронный внутренний виджет не эмитит событие ready. Вместо этого имеем событие клика event, элемент event.currentTarget, проверяем на наличие свойства loading или css-класса uncrd-loading-что-нибудь и рисуем абсолютно позиционированную иконку загрузки рядом с элементом currentTarget. After значит после элемента, before — до, over — поверх посередине ссылки (на фотографиях и аватарах). Если не получилось пририсовать иконку загрузки к элементу, вызвавшему открытие окна, принуждаем окно к открытию топорным способом. Последний случай возникает, к примеру, при перезагрузке страницы — роутер при инициализации в зависимости от location.hash открывает соответствующее окно но не знает, к какому элементу на странице пририсовать иконку пока данные загружаются.

Подключение библиотек и компиляция с r.js

Повторю еще раз, что важным требованием к сети является максимальная автономия. Все библиотеки, включая jquery и knockout, живут локально и подгружаются из ядра системы. Именно поэтому использование тегов и атрибутов data-uncrd="..." вместо нативных и data-bind="..." — это не пижонство. Все библиотеки и скрипты ядра собираются в один файл main.js с помощью голого r.js [5]. Конфиг здесь — build.js [17]. Однако в будущем буду и всем советую в качестве основы использовать грунт [18]. В r.js почему-то можно обрабатывать либо один файл, либо всю директорию, но нельзя в одно действие собрать main.js из файлов ядра + рядом положить поддиректорию виджетов. Так же если у вас есть проект на require.js с require.config({...}) в основном файле main.js, следует быть готовым к тому, что при сборке с r.js этот конфиг не учитывается — все пути должны быть повторно указаны в файле build.js, который указывается при сборке (node r.js -o build.js).

В r.js возможны ошибки (#341 [19]) при работе с namespace-ами, это связано с тем, что в данный момент namespace подставляется в переменные define и require через обычные регулярные выражения. В коде библиотеки knockout.js немного отличается синтаксис в проверке на amd окружение, и регулярка для namespace там не срабатывает. Решения пока нет, нужно проверять вывод и добавлять регулярки или править код библиотек.

Практика, пишем свои виджеты

В репозитории в ветке demo [20] выкинул все ненужное. В папке /demo/1.html находится файлик, на котором демонстрируются примитивные виджеты, которые сейчас напишем.

Начнем с виджета топ-бара, который не требует загрузок, а просто показывает полосу с верхним меню. Логика виджета находится в source/widgets/models/topBar.js, она пустая:

define(function() {
	var TopBar = function(options) { }
	return TopBar;
});

Html-шаблон виджета находится в source/widgets/templates/topBar.html, обычный html типа:

<div class="navbar navbar-fixed-top uncrd-topbar">
	<div class="navbar-inner">
		<div class="container">
			<a class="brand" href="#">UnCRD</a>
			<ul class="nav">
				<li><a href="#" data-uncrd="click:o('page1')">Страница со скролом</a></li>
				<li><a href="#" data-uncrd="click:o({name:'page2',param1:'value1',loading:'after'})">Страница с асинхронной загрузкой</a></li>
				<li><a href="#" data-uncrd="click:o({modalWindow:{header:'Трямц',content:'Трямц'}})">Модальное окно без виджета</a></li>
			</ul>				
			<div class="loginForm" data-uncrd="widget:{name:'loginForm',template:'loginFormInline'}"></div>
		</div>
	</div>
</div>

Несмотря на то, что TopBar ничего не содержит, следует помнить, что widgetBinding примешивает в него методы eventEmitter-а, виджет имеет массив this.childrenWidgets из одного элемента — внутреннего виджета loginForm. Так же он имеет общие для всех виджетов методы this.destroy (удаляет дерево подвиджетов), this.open (открывает окно) и this.o (сокращение от this.open, которое возвращает this.open.bind(...)).

Усложним topBar. Пусть при инициализации он должен красиво jquery-slow выехать сверху. Есть два способа это сделать. Правильнее написать customBinding для выезжания и прибиндить его к корневому элементу шаблона, однако иногда хочется изнутри модели иметь доступ к DOM-у шаблона, который, очевидно, появляется уже после создания объекта виджета и применения renderTemplate. Для этого предусмотрен метод domInit — он вызывается после renderTemplate если указан и имеет параметры self (сам виджет), element (DOM-элемент, который вызвал создание виджета) и firstDomChild (первый найденный DOM-элемент типа nodeType=1 внутри шаблона):

define(["jquery"],function($) {
	var TopBar = function(options) { }
	TopBar.prototype.domInit = function(self,element,firstDomChild) {
		$(firstDomChild).hide().slideDown();
	}
	return TopBar;
});

При клике на первую ссылку топ-бара вызывается метод o('page1'). Это значит, что будет открыто модальное окно, в контент которого будет загружен виджет с моделью /widgets/models/page1.js и шаблоном /widgets/templates/page1.html. Несмотря на то, что формально виджет modalWindow содержит в себе page1, фактически page1 — главный, а modalWindow всего лишь обвязка, поэтому у page1 есть доступ к своему окну в свойстве this.modalWindow.

define(function() {
	var Page1 = function(o) {
		var modalWindow = o.options.modalWindow;
		if (modalWindow) {
			modalWindow.width(700);
			modalWindow.cssPosition("absolute");
			this.close = function() {
				modalWindow.destroy(); // текущий виджет является подвиджетом своего modalWindow, и поэтому удаление modalWindow вызовет сначала удаление этого виджета
			}
		}
		else {
			this.close = function() {
				this.destroy();
			}
		}
	}
	return Page1;
});
<div>
	... много контента ...
	<a href="#" data-uncrd="click:close">Закрыть модальное окно с page1</a>
</div>

По умолчанию модальное окно имеет position=fixed. При открытии окна контент сайта затемняется fade-дивом и «прибивается», т.е. ему проставляется position=fixed, marginTop=scrollTop, тогда затемненный контент остается на месте и пропадает скролл. Если модальное окно имеет position=absolute и по высоте больше экрана, возникший скролл начинает управлять смещением окна вместо смещения контента. При закрытии всех окон контенту проставляются исходные свойства. Это поведение мне кажется более правильным, чем бутстраповское, когда появляется диалог с position:fixed, а контент под ним продолжает скроллиться (и случается ахтунг, если вдруг модальное окно по высоте оказывается больше экрана).

Перейдем к асинхронной загрузке. Пусть page2 перед отображением нужно загрузить какие-нибудь данные. Проставляем this.requiresLoading, эмитим событие ready, не забываем о том что виджет может быть показан сразу еще с незагруженными данными:

define(["knockout"],function(ko) {
	var Page2 = function(o) {
		this.requiresLoading = true;
		this.loading = ko.observable(true);
		this.stringFromServer = ko.observable(null);
	}
	Page2.prototype.domInit = function(self,element,firstDomChild) {
		setTimeout(function() { // Эмуляция ответа с сервера
			self.stringFromServer("Полученные с сервера данные");
			self.loading(false);
			self.emit("ready");
		},1000);
	}
	return Page2;
});
<!-- uncrd if: loading -->
	<div class="uncrd-loading-with-icon">Загрузка...</div>
<!-- /uncrd -->
<!-- uncrd ifnot: loading -->
	<div data-uncrd="text:stringFromServer"></div>
<!-- /uncrd -->

Вставим этот виджет прямо на страницу /demo/1.html рядом с топ-баром и перегрузим страницу. Видим, что виджету здесь некуда приписывать свою иконку загрузки, поэтому он отрисовывается сразу и показывает иконку загрузки внутри себя. Однако при клике на ссылку page2 в верхнем меню иконка загрузки появится возле ссылки, а модальное окно со страницей покажется уже готовое.

Свойства модального окна могут быть проставлены из внутреннего виджета, однако с меньшим приоритетом их можно указать прямо в методе open. Например, можно вообще не указывать название внутреннего виджета, а вместо него указать свойство content — тогда получится обычное модальное окно с текстом:

<a href="#" data-uncrd="click:o({modalWindow:{header:'Трямц',content:'Трямц'}})">Модальное окно без внутреннего виджета</a>

Итог

Простые виджеты написали вместе. Сложные виджеты можно увидеть в репозитории, https://github.com/Kasheftin/uncrd [7]. Это те же самые простые виджеты, только более объемные. Система находится в разработке на уровне прототипа. Как работает прототип на живом сайте — смотреть здесь: http://www.photovision.ru [8]. Демо-страницу со всеми виджетами и кусками документации смотреть здесь: http://uncrd.com/docs/1.html [9]. Демку из голого ядра и трех тупых виджетов-примеров можно скачать из ветки demo [20], там все уже собрано (и проставлены относительные пути), нужно только открыть в браузере /demo/1.html.

Автор: Kasheftin

Источник [21]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/30975

Ссылки в тексте:

[1] социальные плагины фейсбука: http://developers.facebook.com/docs/plugins/

[2] knockout.js: http://knockoutjs.com/

[3] require.js: http://requirejs.org/

[4] amd: http://requirejs.org/docs/whyamd.html

[5] r.js: https://github.com/jrburke/r.js/

[6] http://habrahabr.ru/post/154003/: http://habrahabr.ru/post/154003/

[7] https://github.com/Kasheftin/uncrd: https://github.com/Kasheftin/uncrd

[8] http://www.photovision.ru: http://www.photovision.ru

[9] http://uncrd.com/docs/1.html: http://uncrd.com/docs/1.html

[10] messageForm.js: https://github.com/Kasheftin/uncrd/blob/master/source/js/widgets/models/messageForm.js

[11] messageForm.html: https://github.com/Kasheftin/uncrd/blob/master/source/js/widgets/templates/messageForm.html

[12] stringTemplateEngine: https://github.com/Kasheftin/uncrd/blob/master/source/js/stringTemplateEngine.js

[13] widgetBinding.js: https://github.com/Kasheftin/uncrd/blob/master/source/js/widgetBinding.js

[14] windowManager.js: https://github.com/Kasheftin/uncrd/blob/master/source/js/windowManager.js

[15] modalWindow3.js: https://github.com/Kasheftin/uncrd/blob/master/source/js/widgets/models/modalWindow3.js

[16] eventEmitter-а: https://github.com/Wolfy87/EventEmitter

[17] build.js: https://github.com/Kasheftin/uncrd/blob/master/tools/build.js

[18] грунт: http://gruntjs.com

[19] #341: https://github.com/jrburke/r.js/issues/341

[20] demo: https://github.com/Kasheftin/uncrd/tree/demo

[21] Источник: http://habrahabr.ru/post/175117/