Есть такая библиотека knockout.js. Она отличается от прочих хорошим туториалом для начинающих и кучей понятных рабочих примеров. Еще там стройная MVVC модель, декларативные связи и так далее.
Короче, если вы, как и я, поиграли с этой библиотекой, понаписали красивых формочек, и вам это понравилось, то все это дело захотелось применить на реальном проекте. И тут проблема — в реальном проекте формочек больше чем одна. А раз такие инструменты, то хочется single web page application и никак иначе. А делать один контроллер и все темплейты заверстывать на одну страницу тоже тупо и тормозно.
Под катом приведу основу своего сложного приложения. Само оно совсем не сложное, но модульное и допускает расширения, а темплейты и модели подгружаются динамически. Идея была подсмотрена в этой презентации — http://www.knockmeout.net/2012/08/thatconference-2012-session.html, код презентации выложен на github — https://github.com/rniemeyer/SamplePresentation — на базе этого кода будем писать свой.
Отступление
Вначале я упомянул про заверстывание нескольких форм на одну страницу — это не наш метод, но вполне нормальное решение. Пример небольшого single page application из двух заменяемых шаблонов можно найти в туториале прямо на родном сайте — http://learn.knockoutjs.com/#/?tutorial=webmail.
Еще отступление
Некоторые ругают knockout за его синтаксис, за идею об объявлении связей прямо в html-шаблоне в аттрибутах data-bind="...". Мол это похоже на возвращение в 90-е с вставками javascript-кода в onclick="..". Да еще все работает через eval. Претензии обоснованы — можно задолбаться отлаживать биндинг типа
<div data-bind=”value: name, event: { focus: function() { viewModel.selectItem($data); }, blur: function() { viewModel.selectItem(null); }”></div>
Борьба за чистоту html-кода обширно рассмотрена в этой статье — http://www.knockmeout.net/2011/08/simplifying-and-cleaning-up-views-in.html. Нужно использовать dependentObservable, делать custom-bindings, избегать анонимных функций. Можно написать свой bindingProvider или использовать этот https://github.com/rniemeyer/knockout-classBindingProvider.
Цель
Если писать реальное приложение, взяв за базу примеры из knockout-а, получаются огромные монолитные модели, и может быть непонятно, как их развивать и отлаживать. Главная цель моего примера — показать один из способов разбиения кода на обозримые куски.
Опишу, что будем иметь в итоге. У нас будут шаблоны в html-файликах в папке templates и knockout-js обвязка в соответствующих файликах в папке modules. При определенных действиях будет запускаться метод, который в нужный див с помощью require.js будет подгружать шаблон и код. Итоговый код примера лежит здесь: https://github.com/Kasheftin/ko-test.
StringTemplateEngine
Knockoutjs из коробки поддерживает два способа работы с шаблонами — безымянный и именованный. Примеры:
// безымянный
<div data-bind="foreach: items">
// код шаблона
<li>
<span data-bind="name"></span>
<span data-bind="price"></span>
</li>
</div>
// именованный
<div data-bind="template: {name:'person-template',data:person}"></div>
<script type="text/html" id="person-template">
// код шаблона
<h3 data-bind="text: name"></h3>
<p>Credits: <span data-bind="text: credits"></span></p>
</script>
Во всех случаях шаблоны — куски уже имеющегося dom-дерева. В нашем случае код будет приходить с сервера в виде строки, и самое органичное решение — написать свой template engine. Почерпнуть теорию можно из этой статьи, http://www.knockmeout.net/2011/10/ko-13-preview-part-3-template-sources.html. Есть, вероятно, хорошее готовое решение https://github.com/ifandelse/Knockout.js-External-Template-Engine, но мы напишем свое на основе той презентации, о которой написал вначале.
Здесь код stringTemplateEngine из презентации — https://github.com/rniemeyer/SamplePresentation/blob/master/js/stringTemplateEngine.js. Что не нравится: используется глобальный массив ko.templates, в который записываются загруженные шаблоны, и шаблонам нужно придумывать имена, по которым они вызываются. Мы не будем использовать этот массив, благо кешированием занимается require.js. Наш stringTemplateEngine будет вызываться примерно так:
<div data-bind="with: currentState">
<div data-bind="template: {html:html,data:data}"></div>
</div>
То есть если указано свойство html, то вызывается наш stringTemplateEngine, в другом случае отдаем на выполнение в стандартный knockout. currentState — это объект, который должен иметь свойства template с html-кодом и возможно data с объектом-модулем.
Итак, делаем новый templateSource:
ko.templateSources.stringTemplate = function(element,html) {
this.domElement = element;
this.html = ko.utils.unwrapObservable(html);
}
ko.templateSources.stringTemplate.prototype.text = function() {
if (arguments.length == 0)
return this.html;
this.html = ko.utils.unwrapObservable(arguments[0]);
}
И переопределяем метод makeTemplateSource из объекта nativeTemplateEngine. Пока что никаких велосипедов — о переопределении makeTemplateSource написано в документации. Однако встроенный makeTemplateSource на вход принимает только template и templateDocument, где template — это имя шаблона, если оно есть, и ссылка на текущий dom в другом случае. Беспорядок со смешением типов — это не удачное решение. К тому же для подключения своего StringTemplateEngine нам нужно проверять не аттрибут name, а аттрибут template. Этих данных нет, но они приходят в метод renderTemplate, поэтому переопределим его тоже:
var engine = new ko.nativeTemplateEngine();
// Здесь переопределяем renderTemplate - запихиваем в makeTemplateSource все что имеем
engine.renderTemplate = function(template,bindingContext,options,templateDocument) {
var templateSource = this.makeTemplateSource(template, templateDocument, bindingContext, options);
return this.renderTemplateSource(templateSource, bindingContext, options);
}
// Частичный копипаст, новые только 2 строки
engine.makeTemplateSource = function(template, templateDocument, bindingContext, options) {
// Именованный engine стандартного knockout-а
if (typeof template == "string") {
templateDocument = templateDocument || document;
var elem = templateDocument.getElementById(template);
if (!elem)
throw new Error("Cannot find template with ID " + template);
return new ko.templateSources.domElement(elem);
}
// Наш stringTemplateEngine, используем options
else if (options && options.html) {
return new ko.templateSources.stringTemplate(template,options.html);
}
else if ((template.nodeType == 1) || (template.nodeType == 8)) {
// Анонимный engine из стандартного knockout-а
return new ko.templateSources.anonymousTemplate(template);
}
else
throw new Error("Unknown template type: " + template);
}
ko.setTemplateEngine(engine);
Переопределение renderTemplate не ломает knockout, потому что makeTemplateSource вызывается только в нем и еще в одном методе rewriteTemplate, описанном здесь: https://github.com/SteveSanderson/knockout/blob/master/src/templating/templateEngine.js. Однако последний не вызывается, поскольку в nativeTemplateEngine установлено allowTemplateRewriting=false.
Полный код нашего stringTemplateEngine можно посмотреть здесь: https://github.com/Kasheftin/ko-test/blob/master/js/stringTemplateEngine.js.
State.js
Теперь будем писать state.js — это объект, который при инициализации будет грузить указанный шаблон и модуль. Наши state-ы будут вложенными друг в друга, поэтому само приложение тоже будет state-ом, в него будет вложен state с меню, которое будет грузить другие state-ы c формами и данными.
define(["knockout","text"],function(ko) {
return function(file,callback) {
var s = this;
s.callback = callback;
s.data = ko.observable(null);
s.html = ko.observable(null);
require(["/js/modules/" + file + ".js","text!/js/templates/" + file + ".html"],function(Module,html) {
s.data(typeof Module === "function" ? new Module(s) : Module);
s.html(html);
if (s.callback && typeof s.callback === "function")
s.callback(s);
});
s.setVar = function(i,v) {
var data = s.data();
data[i] = v;
s.data(data);
}
}
});
Это весь код. AMD-скрипт, используем knockout и text-плагин require.js для загрузки html-шаблонов. На вход — имя файла и callback-метод, внутри две observable-переменных data и html, те самые, которые требуются в нашем stringTemplateEngine. Еще вынесен метод setVar — несколько state-ов живут на странице одновременно, они должны обмениваться данными. Как правило в setVar будет передаваться ссылка на корневой state, и оттуда будет все доставаться.
Main.js
HTML-код главной страницы состоит из пары строк:
<body>
<div class="container" data-bind="template:{html:html,data:data}"></div>
<script type="text/javascript" data-main="/js/main" src="/lib/require/require.js"></script>
</body>
Пишем главный файл, который будет исполняться после загрузки страницы и require.js:
require(["knockout","state","stringTemplateEngine"], function(ko,State) {
var sm = new State("app",function(state) {
ko.applyBindings(state);
});
});
App.js, App.html
Я уже писал, что само приложение — это тоже state. Все друг в друга вложено. Страница состоит из меню и контента. Так вот html-код разметки страницы находится в templates/app.html, а инициализация меню и контента — в modules/app.js:
// templates/app.html:
<div class="row">
<div data-bind="with:menu"><div class="span3 menu" data-bind="template:{html:html,data:data}">Menu</div></div>
<div data-bind="with:currentState"><div class="span9 content" data-bind="template:{html:html,data:data}"></div></div>
</div>
// modules/app.js:
define(["knockout","state"],function(ko,State) {
return function() {
var app = this;
this.menu = new State("menu",function(state) {
// здесь, в callback-е, прописываем ссылку на app, чтобы app было доступно из меню и вложенных state-ов
state.setVar("app",app);
});
this.currentState = ko.observable(null);
}
});
Menu.js, Menu.html
Приведу еще пример меню. При клике на ссылки меняется содержимое другого state-а, переменной currentState, которая лежит в state-е app. Доступ к ней имеется потому, что app был отправлен в setVar при инициализации меню.
// menu.html
<ul class="nav nav-list">
<li><a href="javascript:void(0);" data-bind="click:gotoSample" data-id="1">Hello World</a></li>
<li><a href="javascript:void(0);" data-bind="click:gotoSample" data-id="2">Click counter</a></li>
<li><a href="javascript:void(0);" data-bind="click:gotoSample" data-id="3">Simple list</a></li>
...
// menu.js:
define(["jquery","knockout","state"],function($,ko,State) {
return function() {
var menu = this;
this.gotoSample = function(obj,e) {
var sampleId = $(e.target).attr("data-id");
var newState = new State("samples/sample" + sampleId,function(state) {
state.setVar("app",menu.app);
// здесь используется ссылка на app.currentState, т.е. меню изменяет observable-переменную currentState, которая лежит уровнем выше
menu.app.currentState(state);
});
}
}
});
На этом все. Код на модули уже разбит. Страницы примеров с разными формочками копипастнуты из live examples, только оформлены в amd-форме. Потом это все нагружается инициализациями, ajax-ами, но это уже «локальные» детали, которые лежат в state-ах.
Еще раз дам ссылку на конечный код примера — https://github.com/Kasheftin/ko-test.
Автор: Kasheftin