Есть такая библиотека knockout.js. Она отличается от прочих хорошим туториалом для начинающих и кучей понятных рабочих примеров. Еще там стройная MVVM модель, декларативные связи и так далее.
Короче, если вы, как и я, поиграли с этой библиотекой, понаписали красивых формочек, и вам это понравилось, то все это дело захотелось применить на реальном проекте. И тут проблема — в реальном проекте формочек больше чем одна. А раз такие инструменты, то хочется 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. Претензии обоснованы — можно задолбаться отлаживать биндинг типа
Если писать реальное приложение, взяв за базу примеры из knockout-а, получаются огромные монолитные модели, и может быть непонятно, как их развивать и отлаживать. Главная цель моего примера — показать один из способов разбиения кода на обозримые куски.
Опишу, что будем иметь в итоге. У нас будут шаблоны в html-файликах в папке templates и knockout-js обвязка в соответствующих файликах в папке modules. При определенных действиях будет запускаться метод, который в нужный див с помощью require.js будет подгружать шаблон и код. Итоговый код примера лежит здесь: https://github.com/Kasheftin/ko-test.
Knockoutjs из коробки поддерживает два способа работы с шаблонами — безымянный и именованный. Примеры:
Во всех случаях шаблоны — куски уже имеющегося 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 будет вызываться примерно так:
Итак, делаем новый templateSource:
И переопределяем метод makeTemplateSource из объекта nativeTemplateEngine. Пока что никаких велосипедов — о переопределении makeTemplateSource написано в документации. Однако встроенный makeTemplateSource на вход принимает только template и templateDocument, где template — это имя шаблона, если оно есть, и ссылка на текущий dom в другом случае. Беспорядок со смешением типов — это не удачное решение. К тому же для подключения своего StringTemplateEngine нам нужно проверять не аттрибут name, а аттрибут html. Этих данных нет, но они приходят в метод renderTemplate, поэтому переопределим его тоже:
Переопределение 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-ы будут вложенными друг в друга, поэтому само приложение тоже будет state-ом, в него будет вложен state с меню, которое будет грузить другие state-ы c формами и данными.
Это весь код. AMD-скрипт, используем knockout и text-плагин require.js для загрузки html-шаблонов. На вход — имя файла и callback-метод, внутри две observable-переменных data и html, те самые, которые требуются в нашем stringTemplateEngine. Еще вынесен метод setVar — несколько state-ов живут на странице одновременно, они должны обмениваться данными. Как правило в setVar будет передаваться ссылка на корневой state, и оттуда будет все доставаться.
Пишем главный файл, который будет исполняться после загрузки страницы и require.js:
Я уже писал, что само приложение — это тоже state. Все друг в друга вложено. Страница состоит из меню и контента. Так вот html-код разметки страницы находится в templates/app.html, а инициализация меню и контента — в modules/app.js:
Приведу еще пример меню. При клике на ссылки меняется содержимое другого state-а, переменной currentState, которая лежит в state-е app. Доступ к ней имеется потому, что app был отправлен в setVar при инициализации меню.
На этом все. Код на модули уже разбит. Страницы примеров с разными формочками копипастнуты из live examples, только оформлены в amd-форме. Потом это все нагружается инициализациями, ajax-ами, но это уже «локальные» детали, которые лежат в state-ах.
Еще раз дам ссылку на конечный код примера — https://github.com/Kasheftin/ko-test.
Короче, если вы, как и я, поиграли с этой библиотекой, понаписали красивых формочек, и вам это понравилось, то все это дело захотелось применить на реальном проекте. И тут проблема — в реальном проекте формочек больше чем одна. А раз такие инструменты, то хочется 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. Претензии обоснованы — можно задолбаться отлаживать биндинг типа
Борьба за чистоту html-кода обширно рассмотрена в этой статье — http://www.knockmeout.net/2011/08/simplifying-and-cleaning-up-views-in.html. Нужно использовать dependentObservable, делать custom-bindings, избегать анонимных функций. Можно написать свой bindingProvider или использовать этот https://github.com/rniemeyer/knockout-classBindingProvider.<div data-bind=”value: name, event: { focus: function() { viewModel.selectItem($data); }, blur: function() { viewModel.selectItem(null); }”></div>
Цель
Если писать реальное приложение, взяв за базу примеры из 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 будет вызываться примерно так:
То есть если указано свойство html, то вызывается наш stringTemplateEngine, в другом случае отдаем на выполнение в стандартный knockout. currentState — это объект, который должен иметь свойства html с html-кодом и возможно data с объектом-модулем.<div data-bind="with: currentState"> <div data-bind="template: {html:html,data:data}"></div> </div>
Итак, делаем новый 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, а аттрибут html. Этих данных нет, но они приходят в метод 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.