ASP.NET MVC: MVVM на HTML или использование knockout при создании сайта (часть 1 из 2).

Сайтостроение | создано: 14.09.2012 | опубликовано: 14.09.2012 | обновлено: 13.01.2024 | просмотров: 14286 | всего комментариев: 7

На простом примере постараюсь объяснить как с помощью JavaScript-фреймворка Knockout можно применить паттерн MVVM на HTML.

Что такое Knockout?

Если говорить о Knockout, то я не буду вдаваться в подробности, а просто перечислю четыре основных принципа фреймворка описанные на официальном сайте:

  • Декларативное связывание (declarative binding);
  • Автоматическое обновление интерфейса пользователя при изменении свойств объекта (ов) (Automatic UI Update). По такому же принципу работает INotifyPropertyChanged в Silverlight и WPF;
  • Отслеживание зависимостей (Dependency tracking);
  • Шаблоны (Templating).

Каждый из перечисленных принципов так или иначе (а в некоторых случаях, даже несколько раз) были представлены на суд общественности в той или иной форме. Существует огромное множество контролов, фреймворков и надстроек в сети, но когда появился knockout (далее "ko" или “нокаут”), который объединил всё перечисленное в одном, что называется флаконе, разработка на HTML 5 стала доставлять истинное удовольствие.

Задача для примера с использованием Knockout

Когда-то, совсем давно я писал статью о том, как сделать форму обратной связи на AJAX. В этой статье сделаем тоже самое, но только с использованием Knockout. Задача поставлена определим инструменты. Я буду использовать Visual Studio 2012, NET 4.0, MVC 4.0. Итак, приступим.

Готовим к старту

Создаем новый проект.

image

Далее надо бы обновить все пакеты, для этого запускаем команду Update-Package - обновления в консоли Nuget Manager . В новом шаблоне MVC4 проекта уже существует файл knockout.*.js. А если вы решите использовать MVC3, то вам придется дополнительно установить nuget-пакет knockout  В результате работы получился большой список обновлений и это несмотря на то, что студия вышла не более месяца назад (для подписчиков MSDN):

PM> Update-Package
... много всяких букв про обновление пакетов ...

PM>

Добавим еще один пакет  MvcTools:

PM> install-Package mvctools
Attempting to resolve dependency 'XmlExport (≥ 0.2.1)'.
Successfully installed 'XmlExport 0.2.1'.
Successfully installed 'MvcTools 1.6.3'.
Successfully added 'XmlExport 0.2.1' to MVCKnockoutDemo.
Successfully added 'MvcTools 1.6.3' to MVCKnockoutDemo.

PM>

Я сразу удалил файл _Layout.cshtml, а появившийся после установки этого пакета _LayoutExtended.cshtml поставил как стартовый по умолчанию. Это делается в файле _ViewStart.cshtml:

@{
     Layout = "~/Views/Shared/_LayoutExtended.cshtml";
}

Сейчас проект не запустится, надо в новом шаблоне поправить “_LogOnPartial” на (новый для MVC4 файл) “_LoginPartial”. А теперь достаточно заменить код внизу страницы:

@Content.Scripts("jquery-1.7.2.min.js", Url)
@Content.Scripts("jquery.unobtrusive-ajax.min.js", Url)
@Content.Scripts("modernizr-2.5.3.js", Url)
@*@Content.Scripts("jquery-ui-1.8.21.custom.min.js", Url)*@
@RenderSection("scripts", false)

на новый (опять же потому что мы используем MVC4, для MVC3 ничего менять не надо в головном шаблоне):

  @Scripts.Render("~/bundles/jquery")
  @RenderSection("scripts", required: false)
  @Html.WriteScriptBlocks()

Чуть позже в этот список мы добавим скрипты для knockout. Запустим проект – Да! Запустился.

image

Примечание:Внешний вид измененного шаблона не ахти какой, но зато какая свобода для творчества! Не оставлять же шаблон по умолчанию. Но на самом деле, я еще успел переделать nuget-пакет для MVC4.

А сейчас поставим еще один пакет Knockout.Mapping:

PM> Install-Package knockout.Mapping
Attempting to resolve dependency 'knockoutjs (≥ 2.0.0)'.
Successfully installed 'Knockout.Mapping 2.3.2'.
Successfully added 'Knockout.Mapping 2.3.2' to MVCKnockoutDemo.

PM>

И после этого еще один пакет:

PM> Install-Package knockout.Validation
Attempting to resolve dependency 'knockoutjs (≥ 2.0.0)'.
Successfully installed 'Knockout.Validation 1.0.1'.
Successfully added 'Knockout.Validation 1.0.1' to MVCKnockoutDemo.

PM>

В папке Scripts появились новенькие файлы, а мы создам папку Js чтобы складировать туда скрипты, которые будем писать сами. А сейчас пока отложим эту папку в “сторону”.

Снова модели? Ага, потому что MVC

Создадим простой класс FeedbackViewModel, который будет заполнять пользователь для отправки формы обратной связи:

public class FeedbackViewModel {

    [Required]
    [StringLength(100)]
    [Display(Name = "Тема сообщения")]
    public string Subject { get; set; }

    [Required]
    [StringLength(50)]
    [Display(Name = "Как к Вам обращаться")]
    public string UserName { get; set; }

    [Required]
    [RegularExpression(@"^\w ([- .']\w )*@\w ([-.]\w )*\.\w ([-.]\w )*$",
        ErrorMessage = "Неверный формат электронной почты")]
    [StringLength(50)]
    [Display(Name = "Email для обратной связи")]
    public string EmailAdrress { get; set; }

    [Required]
    [StringLength(500)]
    [DataType(DataType.MultilineText)]
    [Display(Name = "Текст сообщения")]
    public string Message { get; set; }

    public override string ToString() {
        return string.Format(formatstring, this.UserName, this.Subject,
            this.Message, this.EmailAdrress);
    }

    private const string formatstring = @"Новое сообщение!\nПосетитель сайта
            {0} пишет на тему: ""{1}""\nСообщение:{2}\nОбратный адрес {3}";
}

AjaxController

Создаем новый контролер под названием AjaxController, он будет унаследован от базового класса Controller (Про ApiController и WebAPI мы поговорим в следующий раз).

Добавим первый метод, который будут возвращать JsonResult - список тем для сообщений от пользователя. Я это делаю для наглядности, потому что этот список можно было бы хранить в javascript-коде.

Итак, метод, возвращающий список тем сообщения:

/// <summary>
/// Загрузка тем для сообщения
/// будет тоже происходить через
/// ajax-запрос с формы
/// </summary>
/// <returns></returns>
public JsonResult LoadSubjects() {
    List<string> subjects = new List<string>() {
        "Заявка на регистрацию блога на calabonga.net",
        "Связь с администратором",
        "Связь с блогером",
        "Вопрос об копирайтах",
        "Благодарственное письмо",
        "Желание поблагодарить материально"
    };
    return Json(subjects.ToArray(), JsonRequestBehavior.AllowGet);
}

Всё просто.

Представления (View)

А теперь давайте подправим главную страницу сайта. Откроем Index.cshtml и удалив всё лишнее оставим следующую разметку. Именно с главной формы мы будем отправлять сообщения (feedback). Итак, разметка:

@{
    ViewBag.Title = "Отправка сообщения";
}

<h3></h3>

<p>
    <input type="submit" value="Отправить сообщение" />
</p>

Далее мы ее будем дописывать. Теперь подключим knockout и все что необходимо для его работы в главном шаблоне проекта, чтобы на конкретных представления (view) достаточно было подключить только скрипт с ViewModel’ом этого представления. Получилось не очень понятно, и поэтому прошу прощения за каламбур. По ходу дальше будет понятнее.

Так как мы используем MVC4 можно воспользоваться Bundles (это нечто новое и ужасно полезное из того, что появилось в MVC4).  В папке App_Start есть файл BundleConfig.cs.

public static void RegisterBundles(BundleCollection bundles) {
    bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
                "~/Scripts/jquery-{version}.js",
                "~/Scripts/knockout-2.1.0.js",
                "~/Scripts/knockout.mapping-latest.js",
                "~/Scripts/knockout.validation.js"));

    bundles.Add(new ScriptBundle("~/bundles/jqueryui").Include(
                "~/Scripts/jquery-ui-{version}.js"));

    bundles.Add(new ScriptBundle("~/bundles/jqueryval").Include(
                "~/Scripts/jquery.unobtrusive*",
                "~/Scripts/jquery.validate*"));

    bundles.Add(new ScriptBundle("~/bundles/modernizr").Include(
                "~/Scripts/modernizr-*"));

    bundles.Add(new StyleBundle("~/Content/css").Include("~/Content/site.css"));

    bundles.Add(new StyleBundle("~/Content/themes/base/css").Include(
                "~/Content/themes/base/jquery.ui.core.css",
                "~/Content/themes/base/jquery.ui.resizable.css",
                "~/Content/themes/base/jquery.ui.selectable.css",
                "~/Content/themes/base/jquery.ui.accordion.css",
                "~/Content/themes/base/jquery.ui.autocomplete.css",
                "~/Content/themes/base/jquery.ui.button.css",
                "~/Content/themes/base/jquery.ui.dialog.css",
                "~/Content/themes/base/jquery.ui.slider.css",
                "~/Content/themes/base/jquery.ui.tabs.css",
                "~/Content/themes/base/jquery.ui.datepicker.css",
                "~/Content/themes/base/jquery.ui.progressbar.css",
                "~/Content/themes/base/jquery.ui.theme.css"));
}

Обратите внимание на строку номер 2. В главном шаблоне _LayoutExtended.cshtml у нас есть строка:

   1:  @Scripts.Render("~/bundles/jquery")

Это значит, что все скрипты зарегистрированные в этом пакете (от английского Bundle  - “пакет”) будут загружаться на все страницы сайта. Я просто добавлю в этот пакет еще парочку строк:

   bundles.Add(new ScriptBundle("~/bundles/jquery").Include
               "~/Scripts/jquery-{version}.js",
               "~/Scripts/knockout-2.1.0.js",
               "~/Scripts/knockout.mapping-latest.js",
               "~/Scripts/knockout.validation.js"));

Я по привычке подключил сразу всё, что называется “до кучи”: и сам нокаут, и расширение маппинга, и валидацию ввода для нокаута. Хотя для такого просто проекта этого и не требовалось (зато вы теперь знаете про эти расширения). Теперь я могу проверить, что скрипты нокаута грузятся:

3

Теперь начнем, собственно говоря, само программирование.

JavaScript – своими руками

По идеи, надо было бы рассказать об архитектуре (структуре) JavaScript программирования отдельной статьёй, но я понадеюсь на то, что вы уже не раз не только слышали, но и применяли в личном опыте паттерны программирования на JavaScript: Object Literals, Module Pattern, Revealing Module Pattern, Prototype Pattern и другие паттерны.

Я предпочитаю разделять функционал по разным файлам, тем более, что в MVC4 появилась такая прекрасная вещь как минимизация и пакетирование (Bundles). Я создал файл site.core.js, в котором подготовил обертки для работы с jQuery.Ajax. Вот часть кода файла:

(function (site) {

    "use strict";

    var baseUrl = "/ajax/",
        serviceUrl = function (method) { return baseUrl   method; };

    site.services.ajax = function () {
        var getAjaxJson = function (method, jsonIn, callback) {
            $.ajax({
                url: serviceUrl(method),
                data: ko.toJS(jsonIn),
                type: 'GET',
                dataType: 'json',
                contentType: 'application/json; charset=utf-8',
                success: function (json) {
                    callback(json);
                },
                error: function (jqXHR, textStatus) {
                    if (confirm(jqXHR.status   " "
                          textStatus
                          ":"
                          jqXHR.statusText)) {
                        alert(jqXHR.responseText);
                    }
                }
            });
        },
        postAjaxJson = function (method, jsonIn, callback) {
            $.ajax({
                url: serviceUrl(method),
                data: ko.toJS(jsonIn),
                type: 'POST',
                dataType: 'json',
                contentType: 'application/json; charset=utf-8',
                success: function (json) {
                    callback(json);
                },
                error: function (jqXHR, textStatus) {
                    if (confirm(jqXHR.status
                          " "
                          textStatus
                          ":"
                          jqXHR.statusText)) {
                        alert(jqXHR.responseText);
                    }
                }
            });
        }
        return {
            get: getAjaxJson,
            post: postAjaxJson
        };
    }();

})(site);

Также я создал файл site.services.feedback.js. Этот файл содержит непосредственно сам data-сервис, который и будет используя обертку site.services.ajax чтобы обращаться к методам AjaxController:

///////////////////////////////////////////////////////////////
//  site.feedback
//  Работает через dataService с объектами на форме
//  Feedback
//  автор: calabonga.net
///////////////////////////////////////////////////////////////

(function (site) {

    "use strict";

    site.services.feedbackForm = {
        loadSubjects: function (callback) {
            if (typeof callback === undefined) {
                throw new Error(200, "callback is undefined");
            }
            site.services.ajax.get("LoadSubjects", {}, callback);
        },
        sendFeedback: function (feedback, callback) {
            if (typeof callback === undefined) {
                throw new Error(200, "callback is undefined");
            }
            site.services.ajax.post("SendFeedback", feedback, callback);
        }
    };

})(site);

Обратите внимание на строки 17 и 23, именно в них и вызываются методы контролера.

И, собственно говоря, еще один файл site.vm.feedback.js, который уже является ViewModel’ом для представления Index.cshtml:

///////////////////////////////////////////////////////////////
//  site.feedback
//  Работает через dataService с объектами на форме
//  Feedback
//  автор: calabonga.net
///////////////////////////////////////////////////////////////

(function (site) {

    "use strict";

    site.vm.feedbackViewModel = function(){
        var
            view = {
                title: "Отправка сообщения"
            },
            subjects = ko.observableArray([]),
            isbusy = ko.observable(false),
            loadSubjects = function () {
                isbusy(true);
                site.services.feedbackForm.loadSubjects(callback);
            },
            callback = function (json) {
                isbusy(false);
                ko.mapping.fromJS(json, {}, subjects);
                //subjects(json);
                var total = subjects().length;
            }

        loadSubjects();

        return {
            view: view,
            subjects: subjects,
            isbusy: isbusy
        }

    }();

})(site);



$(function () {

    "use strict";

    // привязка ViewModel к форме
    ko.applyBindings(site.vm.feedbackViewModel);
});

Этот ViewModel может пока только загрузить список тем для сообщений, мы его доработаем позже.  Назовем этот листинг – “модель 1”. Далее по ходу статьи, я буду на него ссылаться.

Для того чтобы подключить эти скрипты, на странице Index.cshtml пропишу такой код в самом низу страницы, чтобы не мешался:

   @section scripts {
       <script src="@Scripts.Url("~/js/site.core.js")"></script>
       <script src="@Scripts.Url("~/js/site.service.feedback.js")"></script>
       <script src="@Scripts.Url("~/js/site.vm.feedback.js")"></script>
   }

Библиотеки все подгружены (на главном шаблоне), скрипты подключены (на странице Index.cshtml) – переходим к самой разметке.

Декларативная привязка (Declarative binding)

Теперь самое интересное. Я постараюсь объяснить, что же такое декларативная привязка (MVVM) на конкретном примере. Для начала добавлю код, который выведет на форму наименование (см. “модель 1” строка 15) и количество загруженных тем сообщений (см. “модель 1” строка 17):

@{
    ViewBag.Title = "Отправка сообщения";
}

<h3 data-bind="text: view.title"></h3>
<div data-bind="ifnot: isbusy">
    <span data-bind="text: subjects().length"></span>
    <p>
        <input type="submit" value="Отправить сообщение" />
    </p>
</div>

@section scripts {
    <script src="@Scripts.Url("~/js/site.core.js")"></script>
    <script src="@Scripts.Url("~/js/site.service.feedback.js")"></script>
    <script src="@Scripts.Url("~/js/site.vm.feedback.js")"></script>
}

Вот таким незатейлевым способом с использованием атрибута HTML5 “data-…” это можно сделать. Заголовок представления привязывается к HTML-разметке в строке 5, количество загруженных тем - в строке 7.  А вот так это выглядит:

4

Теперь отобразим список тем в элементе <ul>. Для это придется использовать шаблон. Благо что начиная с версии нокаута 2.0 появилась поддержка собственных (nativeTemplateEngine) шаблонов. Код для отображения списка тем сообщений с использованием шаблонов knockout:

<h3 data-bind="text: view.title"></h3>
<div data-bind="ifnot: isbusy">
    <p>
        Тем для сообщения (<span data-bind="text: subjects().length"></span>шт.):
    </p>
    <ul data-bind="template: {'name':'subjectTemplate', foreach: subjects}"></ul>

    <p>
        <input type="submit" value="Отправить сообщение" />
    </p>
</div>

<script id="subjectTemplate" type="text/html">
    <li><span data-bind="text: $data"></span></li>
</script>

@section scripts {
    <script src="@Scripts.Url("~/js/site.core.js")"></script>
    <script src="@Scripts.Url("~/js/site.service.feedback.js")"></script>
    <script src="@Scripts.Url("~/js/site.vm.feedback.js")"></script>
}

Добавленный элемент списка (см. строку 6) использует шаблон (см. строки с 13-15). Вот теперь как это выглядит:

5

Отлично! Вот только мне нужен выпадающий список. Легко для этого поменяем элемент и привязку на новый тип:.

<h3 data-bind="text: view.title"></h3>
<div data-bind="ifnot: isbusy">
    <p>
        Тем для сообщения (<span data-bind="text: subjects().length"></span>шт.):
    </p>
    <p>
        <label for="Subject"></label>
        <select data-bind="options: subjects" id="Subject"></select>
    </p>
    <p>
        <input type="submit" value="Отправить сообщение" />
    </p>
</div>

Я удалил <ul> вместе с шаблоном и поставил <select> (см. строка 8):

6

Обратите внимания, я ни строчки кода при этом не поменял! Только разметка! Это и есть декларативная привязка, которая реализовывается принципами MVVM паттерна, которые, в свою очередь, предоставляет фреймворк Knockout (нокаут).

Ссылки

Заключение

В следующей части, я закончу формирование формы обратной связи, подключу проверку (валидацию) введенных пользователем данных, реализую отправку через метод Send. Пишите комментарии, мне важно ваше мнение.

Комментарии к статье (7)

А у меня не запускается,выдает ошибку: 

Ошибка сервера в приложении '/'.

The following sections have been defined but have not been rendered for the layout page "~/Views/Shared/_LayoutExtended.cshtml": "featured".

Ахмед, такое ощущение, что вы или не установили MvcTools или не указали, чтобы  этот мастер-шаблон использовался по умолчанию. Ищите в статье место про _ViewStart.cshtml

ПроApiController и WebAPI мы поговорим в следующий раз.- ждем с нетерением.

во вьювСтарте я менял имя мастер страницы и все обновления произвел,и мвц тулс установил. Просто моя студия экспресс2012 такое сообщение выдает,когда я указываю на другую мастер страницу, предворительно создав и указав во вьювстарте имя файла мастер страницы. Я даже на другом проекте эксперементировал,но такие же результаты( Ничего. Мене очень нравится ваш блог и ваши статьи, мало где есть такие уроки.

Ахмед, к обоим частям приложены проекты с демонстрациями, вы не пробовали разобраться что не так в вашем проекте?

Нашлась причина: если добавить этот метод использования разделов @RenderSection("featured", required: false) в мастер-страницу - не выводит ошибку. Или если закоментировать либо удалить определение раздела в  index.cshtml. Щас буду двигаться дальше)