Пишем сложное приложение на knockoutjs

в 10:40, , рубрики: javascript, knockout, Knockout.js, knockoutjs, requirejs, Веб-разработка, метки: , , , ,

Есть такая библиотека 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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js