Библиотека Knockout и Web API

60

Цель создания веб-службы Web API - рефакторинг примера приложения, чтобы операции над данными приложения могли выполняться с применением запросов Ajax, результаты JSON которых будут использоваться для обновления HTML-разметки в браузере. Общая функциональность приложения останется той же самой, но не будут генерироваться полные HTML-документы для каждого взаимодействия между клиентом и сервером.

Переход к одностраничному приложению увеличивает нагрузку на браузер, поскольку состояние приложения необходимо хранить на стороне клиента. Понадобится модель данных, которую можно обновлять, множество логических операций, предназначенных для трансформации данных, и набор элементов пользовательского интерфейса, который позволит пользователю запускать эти операции. Короче говоря, нужно воссоздать миниатюрную версию шаблона MVC в браузере.

Для одностраничных приложений в Microsoft приспособлена библиотека Knockout, которая создает JavaScript-реализацию шаблона MVC (или, точнее, шаблона MVVM, настолько близкого к шаблону MVC, что я собираюсь трактовать их как одно и то же). В последующих разделах мы возвратимся к стороне ASP.NET MVC Framework примера приложения и применим библиотеку Knockout с целью создания простого приложения SPA.

Добавление библиотек JavaScript в компоновку

Первый шаг заключается в добавлении файлов Knockout и jQuery в компоновку, чтобы они стали доступными в представлении. Добавленные элементы <script> в файл _Layout.cshtml показаны в примере ниже:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@ViewBag.Title</title>
    <link href="~/Content/bootstrap.min.css" rel="stylesheet" type="text/css" />
    <script src="~/Scripts/jquery-1.10.2.js"></script>
    <script src="~/Scripts/knockout-3.0.0.js"></script>
    @RenderSection("Scripts")
</head>
<body>
    @RenderSection("Body")
</body>
</html>

Библиотека Knockout будет применяться для создания реализации MVC, а библиотека jQuery - для обработки запросов Ajax.

Реализация сводки

Первым крупным изменением будет замена частичного представления Summary встраиваемым кодом Knockout и jQuery. Вы не обязаны использовать Knockout в единственном файле, но желательно оставить частичные представления незатронутыми, чтобы можно было увидеть разницу между стандартным способом работы ASP.NET MVC Framework и приемами, применяемыми в приложении SPA. В примере ниже приведены изменения, внесенные в файл представления Index.cstml:

@using WebServices.Models

@model IEnumerable<Reservation>
@{
    ViewBag.Title = "Заявки на бронирование";
}

@section Scripts {
<script>
    var model = {
        reservations: ko.observableArray()
    };

    function sendAjaxRequest(httpMethod, callback, url) {
        $.ajax("/api/web" + (url ? "/" + url : ""), {
            type: httpMethod,
            success: callback
        });
    }

    function getAllItems() {
        sendAjaxRequest("GET", function (data) {
            model.reservations.removeAll();
            for (var i = 0; i < data.length; i++) {
                model.reservations.push(data[i]);
            }
        });
    }

    function removeItem(item) {
        sendAjaxRequest("DELETE", function () {
            getAllItems()
        }, item.ReservationId);
    }

    $(document).ready(function () {
        getAllItems();
        ko.applyBindings(model);
    });
</script>

}
@section Body {
<div id="summary" class="section panel panel-primary">
    <div class="panel-heading">Все заказы</div>
    <div class="panel-body">
        <table class="table table-striped table-condensed">
            <thead>
                <tr><th>ID</th><th>Имя</th><th>Помещение</th><th></th></tr>
            </thead>
            <tbody data-bind="foreach: model.reservations">
                <tr>
                    <td data-bind="text: ReservationId"></td>
                    <td data-bind="text: ClientName"></td>
                    <td data-bind="text: Location"></td>
                    <td>
                        <button class="btn btn-xs btn-primary"
                                data-bind="click: removeItem">
                            Удалить
                        </button>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</div>
    <div id="editor" class="section panel panel-primary">
        @Html.Partial("Editor", new Reservation())
    </div>
}

Здесь много чего происходит, поэтому далее каждое изменение будет объясняться отдельно. Общий эффект внесенных изменений состоит в том, что браузер использует контроллер Web API для получения деталей текущих заявок на бронирование.

В примере выше выражение @model можно удалить из представления index.cshtml. Объекты модели представления не используются для генерации содержимого в представлении, а это значит, что модель представления не нужна. Правда, контроллер по-прежнему получает объекты Reservation из хранилища и передает их представлению, но это будет исправлено далее.

Определение функций Ajax

Библиотека jQuery обладает великолепной поддержкой для выполнения запросов Ajax. С этой целью определяется функция по имени sendAjaxRequest(), которая будет применяться для нацеливания на методы контроллера Web API:

function sendAjaxRequest(httpMethod, callback, url) {
      $.ajax("/api/web" + (url ? "/" + url : ""), {
            type: httpMethod,
            success: callback
      });
}

Функция $.ajax предоставляет доступ к функциональности Ajax в библиотеке jQuery. Аргументами являются URL, который необходимо запросить, и объект, содержащий параметры конфигурации. Функция sendAjaxRequest() - это оболочка вокруг функциональности jQuery. Ее аргументы представляют собой HTTP-метод, который должен использоваться для запроса (влияющий на выбор метода действия в контроллере), функция обратного вызова, которая будет выполняться, когда запрос Ajax успешно завершен, и необязательный суффикс URL.

Применяя функцию sendAjaxRequest() в качестве основы, далее определяются функции, предназначенные для получения всех доступных данных и для удаления заявки на бронирование, примерно так:

// ...
function getAllItems() {
	sendAjaxRequest("GET", function (data) {
		model.reservations.removeAll();
		for (var i = 0; i < data.length; i++) {
			model.reservations.push(data[i]);
		}
	});
}

function removeItem(item) {
	sendAjaxRequest("DELETE", function () {
		getAllItems()
	}, item.ReservationId);
}
// ...

Функция getAllItems() нацелена на метод действия GetAllReservations() контроллера и извлекает все заявки на бронирование из сервера. Функция removeItem() нацелена на метод действия DeleteReservation() контроллера и вызывает функцию getAllItems() для обновления данных после удаления.

Определение модели

Функции Ajax опираются на модель, которая определена следующим образом:

var model = {
    reservations: ko.observableArray()
};

// ...

Библиотека Knockout работает путем создания наблюдаемых объектов, которые затем отслеживает на предмет изменений и применяет для обновления HTML-разметки, отображаемой браузером. Модель довольно проста. Она состоит из наблюдаемого массива, который похож на обычный массив JavaScript, но привязан так, что любые вносимые изменения обнаруживаются библиотекой Knockout. Ниже показано, как модель используется в функциях Ajax:

// ...
function getAllItems() {
	sendAjaxRequest("GET", function (data) {
		model.reservations.removeAll();
		for (var i = 0; i < data.length; i++) {
			model.reservations.push(data[i]);
		}
	});
}
// ...

Два подчеркнутых оператора отражают способ получения данных из сервера в модель. Метод removeAll() вызывается для удаления любых существующих данных из наблюдаемого массива, после чего производится проход в цикле по результатам, полученным от сервера, с заполнением массива новыми данными с помощью метода push().

Определение привязок

Библиотека Knockout применяет изменения, внесенные в модель данных, к HTML-элементам через привязки данных. Вот как выглядят наиболее важные привязки данных в представлении Index:

...
<tbody data-bind="foreach: model.reservations">
    <tr>
        <td data-bind="text: ReservationId"></td>
        <td data-bind="text: ClientName"></td>
        <td data-bind="text: Location"></td>
        <td>
            <button class="btn btn-xs btn-primary" data-bind="click: removeItem">
                Удалить
            </button>
        </td>
    </tr>
</tbody>
...

Привязки выражаются в Knockout с использованием атрибута data-bind. Доступен широкий диапазон привязок, три из которых применялись в представлении. Ниже приведен базовый формат атрибута data-bind:

data-bind="type: expression"

Типами трех привязок в примере выше являются foreach, text и click, и они были выбраны потому, что представляют различные способы использования Knockout.

Первые две привязки, foreach и text, генерируют HTML-элементы и содержимое из данных модели. Когда к какому-то элементу применяется привязка foreach, библиотека Knockout генерирует дочерние элементы для каждого объекта в выражении, что очень похоже на оператор @foreach из Razor, который использовался в частичном представлении.

Привязка text вставляет значение выражения как текст элемента, к которому она применена. Это означает, что когда эта привязка используется, как показано ниже:

<td data-bind="text: ClientName"></td>

то Knockout вставит значение свойства текущего объекта, обрабатываемого привязкой foreach, что дает тот же самый результат, как и Razor-выражение @Model.ClientName, применяемое ранее.

Привязка click отличается: она устанавливает обработчик события click для элемента, к которому применена. Разумеется, использовать для установки обработчиков событий библиотеку Knockout необязательно, но привязка click интегрирована с другими привязками, и функции, указанной для вызова при поступлении события, передается объект данных, который был обработан привязкой foreach при ее применении. Именно по этой причине имеется возможность определить в функции removeItem() аргумент, который получает объект Reservation (или его представление в JavaScript).

Обработка привязок

Привязки Knockout не обрабатываются автоматически, поэтому в элементе <script> предусмотрен следующий код:

$(document).ready(function () {
        getAllItems();
        ko.applyBindings(model);
    });

Вызов $(document).ready() - это стандартный прием JQuery для откладывания выполнения функции до момента, когда все HTML-элементы в документе будут загружены и обработаны браузером (аналог JavaScript-обработчика window.onload). Как только это произойдет, вызывается функция getAllItems() для загрузки данных из сервера и затем функция ko.applyBindings(), чтобы воспользоваться моделью данных для обработки атрибутов data-bind. Последний вызов соединяет объекты данных с HTML-элементами, генерирует требуемое содержимое и устанавливает обработчики событий.

Тестирование привязок в сводке

Вас может интересовать, для чего вообще создавались все эти проблемы, учитывая, что, по сути, получена замена выражений Razor эквивалентными привязками Knockout. Существуют три важных отличия, и чтобы продемонстрировать их полностью, будут применяться инструменты разработчика браузера (<F12>).

Первое отличие в том, что данные модели больше не включаются в HTML-разметку, отправляемую браузеру. После того, как HTML-разметка была обработана, браузер делает запрос Ajax к контроллеру Web API и получает список заявок на бронирование, выраженный в формате JSON. Чтобы увидеть это, запустите приложение и с помощью инструментов разработчика отслеживайте запросы, выполняемые браузером. Результаты показаны на рисунке ниже:

Отслеживание запросов, выполняемых браузером

Второе отличие заключается в том, что данные обрабатываются браузером, а не сервером, как в случае визуализации представления. Чтобы протестировать это, можно отредактировать функцию getAllItems(), чтобы она не делала запрос Ajax и не обрабатывала полученные данные, примерно так:

// ...
function getAllItems() {
    return;
	// ...
}
// ...

Из функции производится возврат до того, как делается запрос Ajax, и эффект можно увидеть, запустив приложение:

Демонстрация того, что данные извлекаются и обрабатываются браузером

Хоть это может казаться очевидным, но важная характеристика приложений SPA заключается в том, что браузер выполняет намного больше работы, в том числе обработку данных и генерацию HTML-содержимого.

Последнее, третье, отличие касается того, что привязки данных являются активными, т.е. изменения в модели данных отражаются в содержимом, которое генерируют привязки foreach и text. Чтобы проверить это, верните функцию getAllItems() в рабочее состояние и перезагрузите приложение. После того, как браузер запросит, получит и обработает данные, откройте окно инструментов <F12> и перейдите в раздел Console (Консоль). Введите в консоли следующую команду и нажмите клавишу <Enter>:

model.reservations.pop()

Это выражение удаляет последний элемент из массива объектов данных в модели, и после ввода указанной команды компоновка HTML-страницы отразит изменение, как проиллюстрировано на рисунке ниже. Общий результат - перенос части сложности по генерации HTML-разметки со стороны сервера на сторону клиента.

Манипулирование моделью посредством консоли JavaScript

Усовершенствование средства удаления

Теперь, когда известно, каким образом применение Knockout изменяет природу клиента, необходимо устранить упрощение, которое ранее было допущено при определении методов Ajax для приложения. Функция removeItem() написана неудачно:

function removeItem(item) {
    sendAjaxRequest("DELETE", function () {
        getAllItems()
    }, item.ReservationId);
}

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

//...
    function removeItem(item) {
        sendAjaxRequest("DELETE", function () {
            for (var i = 0; i < model.reservations().length; i++) {
                if (model.reservations()[i].ReservationId == item.ReservationId) {
                    model.reservations.remove(model.reservations()[i]);
                    break;
                }
            }
        }, item.ReservationId);
    }
//...

Если запрос к серверу завершается успешно, соответствующий объект данных удаляется из модели, а это значит, что второй запрос Ajax больше не требуется.

Привыкание к синтаксису Knockout

При работе с наблюдаемыми массивами Knockout приходится сталкиваться с индивидуальными особенностями синтаксиса, две из которых можно было видеть в примере выше. Чтобы получить элемент из массива, обращение к model.reservations должно трактоваться как функция:

model.reservations()[i].ReservationId

Когда дело доходит до удаления элементов из массива, используется функция, которая не является стандартным кодом JavaScript:

model.reservations.remove(model.reservations()[i]);

Библиотека Knockout пытается поддерживать стандартный синтаксис JavaScript, но при отслеживании изменений в объектах данных приходится идти на некоторые компромиссы, подобные указанным особенностям. Если вы только начали работать с Knockout, это может запутывать, но вы быстро привыкнете к ним. И вы должны знать, что если не удается получить требуемый результат, то вероятно это связано с несоответствием между стандартным синтаксисом JavaScript и синтаксисом, который требуется для работы с наблюдаемым объектом или массивом Knockout.

Реализация средства создания

Следующий шаг - применение библиотеки Knockout для замены частичного представления Editor.cshtml. Здесь снова можно было бы обновить частичное представление, добавив функциональность Knockout, но решено включить все необходимое в файл Index.cshtml, как показано в примере ниже:

@using WebServices.Models

@model IEnumerable<Reservation>
@{
    ViewBag.Title = "Заявки на бронирование";
}

@section Scripts {
<script>
    var model = {
        reservations: ko.observableArray(),
        editor: {
            name: ko.observable(""),
            location: ko.observable("")
        }
    };

    function sendAjaxRequest(httpMethod, callback, url, reqData) {
        $.ajax("/api/web" + (url ? "/" + url : ""), {
            type: httpMethod,
            success: callback,
            data: reqData
        });
    }

    // ...

    function handleEditorClick() {
        sendAjaxRequest("POST", function (newItem) {
            model.reservations.push(newItem);
        }, null, {
            ClientName: model.editor.name,
            Location: model.editor.location
        });
    }

    $(document).ready(function () {
        getAllItems();
        ko.applyBindings(model);
    });
</script>

}

@section Body {
<div id="summary" class="section panel panel-primary">
    ...
</div>
<div id="editor" class="section panel panel-primary">
    <div class="panel-heading">
        Создать заказ
    </div>
    <div class="panel-body">
        <div class="form-group">
            <label>Имя клиента</label>
            <input class="form-control" data-bind="value: model.editor.name" />
        </div>
        <div class="form-group">
            <label>Помещение</label>
            <input class="form-control" data-bind="value: model.editor.location" />
        </div>
        <button class="btn btn-primary"
                data-bind="click: handleEditorClick">
            Сохранить
        </button>
    </div>
</div>
}

Чтобы создать редактор модели, библиотека Knockout должна применяться по-другому, как будет пошагово рассматриваться в последующих разделах.

Расширение модели

Для создания нового объекта Reservation в хранилище от пользователя необходимо получить две порции информации: имя заказчика (соответствует свойству ClientName) и местоположение (соответствует свойству Location). Первый шаг предусматривает расширение модели, поэтому определяются переменные, которые можно использовать для хранения упомянутых значений:

var model = {
    reservations: ko.observableArray(),
    editor: {
            name: ko.observable(""),
            location: ko.observable("")
    }
};

Функция ko.observable() создает наблюдаемое значение, с которым будет производиться работа далее. Изменения этих значений будут отражаться в любых привязках, использующих свойства name и location.

Реализация элементов <input>

Следующий шаг заключается в создании элементов <input>, посредством которых пользователь будет предоставлять значения для новых свойств модели. Для этого применяется привязка value библиотеки Knockout, которая устанавливает атрибут value элемента, как показано ниже:

<input class="form-control" data-bind="value: model.editor.name" />

Привязки value гарантируют, что значения, введенные пользователем в элементах <input>, будут использоваться для установки свойств модели. Обратите внимание, что элемент <form> больше не нужен. Для отправки значений данных серверу в качестве ответа на щелчок на кнопке будет применяться запрос Ajax, и это никак не полагается на стандартную поддержку форм в браузере.

Создание обработчика событий

Для обработки события click элемента <button>, который отображается под элементами <input>, используется привязка click:

<button class="btn btn-primary" data-bind="click: handleEditorClick">
    Сохранить
</button>

Эта привязка указывает, что при щелчке на кнопке должна вызываться функция handleEditorClick(), которая определена внутри элемента <script> следующим образом:

// ...

    function handleEditorClick() {
        sendAjaxRequest("POST", function (newItem) {
            model.reservations.push(newItem);
        }, null, {
            ClientName: model.editor.name,
            Location: model.editor.location
        });
    }
// ...

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

Тестирование средства создания

Чтобы увидеть реализацию с помощью Knockout средства создания в работе, запустите приложение, введите имя и местоположение в элементах <input> и щелкните на кнопке "Сохранить", как показано на рисунке ниже:

Создание новой заявки на бронирование

Завершение работы над приложением

Теперь, когда вы узнали, как применять Knockout и Web API для создания одностраничного приложения, можно завершить работу над приложением, добавив недостающие средства и устранив мелкие шероховатости.

Упрощение контроллера Home

Контроллер Home по-прежнему использует метод действия для извлечения данных из хранилища и манипулирования объектами Reservation, хотя все данные, отображаемые клиентом, запрашиваются через Ajax и передаются контроллеру Web API. В примере ниже можно видеть, что из контроллера Home были удалены методы действий, которые заменяет контроллер Web API. Кроме того, был обновлен метод действия Index() который больше не передает объект модели представления.

using System.Web.Mvc;

namespace WebServices.Controllers
{
    public class HomeController : Controller
    {
        public ViewResult Index()
        {
            return View();
        }
	}
}

Управление видимостью содержимого

Финальное изменение связано с управлением видимостью элементов в HTML-документе, чтобы отображалась либо только сводка, либо только редактор. В примере ниже показано, как это делается:

@using WebServices.Models

@model IEnumerable<Reservation>
@{
    ViewBag.Title = "Заявки на бронирование";
}

@section Scripts {
<script>
    var model = {
        reservations: ko.observableArray(),
        editor: {
            name: ko.observable(""),
            location: ko.observable("")
        },
        displaySummary: ko.observable(true)
    };

    function sendAjaxRequest(httpMethod, callback, url, reqData) {
        $.ajax("/api/web" + (url ? "/" + url : ""), {
            type: httpMethod,
            success: callback,
            data: reqData
        });
    }

    function getAllItems() {
        sendAjaxRequest("GET", function (data) {
            model.reservations.removeAll();
            for (var i = 0; i < data.length; i++) {
                model.reservations.push(data[i]);
            }
        });
    }

    function removeItem(item) {
        sendAjaxRequest("DELETE", function () {
            for (var i = 0; i < model.reservations().length; i++) {
                if (model.reservations()[i].ReservationId == item.ReservationId) {
                    model.reservations.remove(model.reservations()[i]);
                    break;
                }
            }
        }, item.ReservationId);
    }

    function handleCreateClick() {
        model.displaySummary(false);
    }

    function handleEditorClick() {
        sendAjaxRequest("POST", function (newItem) {
            model.reservations.push(newItem);
            model.displaySummary(true);
        }, null, {
            ClientName: model.editor.name,
            Location: model.editor.location
        });
    }

    $(document).ready(function () {
        getAllItems();
        ko.applyBindings(model);
    });
</script>

}
@section Body {
<div id="summary" class="section panel panel-primary" data-bind="if: model.displaySummary">
    <div class="panel-heading">Все заказы</div>
    <div class="panel-body">
        <table class="table table-striped table-condensed">
            <thead>
                <tr><th>ID</th><th>Имя</th><th>Помещение</th><th></th></tr>
            </thead>
            <tbody data-bind="foreach: model.reservations">
                <tr>
                    <td data-bind="text: ReservationId"></td>
                    <td data-bind="text: ClientName"></td>
                    <td data-bind="text: Location"></td>
                    <td>
                        <button class="btn btn-xs btn-primary"
                                data-bind="click: removeItem">
                            Удалить
                        </button>
                    </td>
                </tr>
            </tbody>
        </table>
        <button class="btn btn-primary"
                data-bind="click: handleCreateClick">
            Создать
        </button>
    </div>
</div>
<div id="editor" class="section panel panel-primary" data-bind="ifnot: model.displaySummary">
    <div class="panel-heading">
        Создать заказ
    </div>
    <div class="panel-body">
        <div class="form-group">
            <label>Имя клиента</label>
            <input class="form-control" data-bind="value: model.editor.name" />
        </div>
        <div class="form-group">
            <label>Помещение</label>
            <input class="form-control" data-bind="value: model.editor.location" />
        </div>
        <button class="btn btn-primary"
                data-bind="click: handleEditorClick">
            Сохранить
        </button>
    </div>
</div>
}

В модель было добавлено свойство, которое указывает, должна ли отображаться сводка. Это свойство применяется с привязками if и if not, которые добавляют и удаляют элементы в DOM-модели на основе своих выражений. Если свойство displaySummary равно true, сводка по данным будет отображена, а если оно равно false, то отобразится редактор.

Еще одним изменением было добавление кнопки "Создать", щелчок на которой приводит к вызову функции, изменяющей значение свойства displaySummary, а также добавление к функции обратного вызова кода, который обрабатывает новые элементы данных. Окончательный результат можно видеть на рисунке ниже:

Добавление заявки на бронирование в финальной версии приложения

Итак, в этой статье было показано, как использовать Web API и Knockout для создания одностраничного приложения, которое выполняет операции над данными с использованием веб-службы REST. Хотя средство Web API не является частью инфраструктуры ASP.NET MVC Framework, оно смоделировано настолько близко к природе и структуре MVC, что покажется знакомым разработчикам, применяющим MVC, и как было продемонстрировано ранее, контроллеры Web API могут быть добавлены в приложение наряду с обычными контроллерами MVC.

На этом изучение ASP.NET MVC Framework завершено. Я начал с создания простого приложения, после чего предоставил вам комплексный тур по различным компонентам инфраструктуры, показывая, каким образом их конфигурировать, настраивать или полностью заменять. Желаю вам всяческих успехов на поприще разработки собственных проектов ASP.NET MVC Framework.

Пройди тесты
Лучший чат для C# программистов