Friday, December 25, 2015

Routing in AngularJS sample

Здравствуйте, уважаемые читатели. Наконец-то я плотно смог поработать с AngularJS, поэтому захотелось поделиться некоторыми приобретенными в процессе работы знаниями. Задача, решение которой мы разберем, состоит в том, что мы создадим приложение в связке AngularJs и ASP.NET MVC 5, которое будет позволять делать навигацию в одно окно. Весь UI мы реализуем с помощью AngularJs.Очень надеюсь, что работа, которую мы проделаем, вам понравится, и вы найдете для себя что-то новое или посмотрите с другой стороны, как можно решать ту или иную проблему на стороне клиента. Реальный проект, который был построен с таким подходом на стороне backend, был полностью на Web API, клиент – на Angular JS, поэтому пересечения логики не было, вся логика перехода была реализована на клиенте с помощью $routeProvider, о котором мы поговорим в процессе работы. Итак, создадим новое “ASP.NET Web Application” приложение и назовем его “AngularjsRouting”, как показано на рисунке ниже.
Затем выбираем темплейт “Empty”(не забываем поставить галочки “MVC” и “Web API”).
Нам нужен контроллер, который позволит отобразить нам какую-то стартовую страницу для отображения. Для этого выберем папку Controllers, нажмем правой клавишей мыши и выбираем пункт “Add -> Controller…”, как показано на рисунке ниже.
Затем среди доступных темплейтов выбираем “MVC 5 ControllerEmpty”. Называем его HomeController, чтобы к нему уже применялась существующая маршрутизация.
После создания нашего контроллера необходимо добавить представление, которое будет вызываться при запуске браузера. Для этого поставим курсор в метод Index() и нажмем правой кнопкой мыши, чтобы выбрать в контекстном меню “Add View…”.
В следующем окне ничего не меняем и просто нажимаем на кнопку Add, как показано на картинке ниже.
После этого мы можем запустить наше окно и увидеть, что все работает.
Так как основная составляющая нашего приложения будет сосредоточена на клиенте, нам необходимо с помощью NuGet Package Manager установить себе следующие пакеты.
Они как раз идут рядом, их нужно себе и установить. Также нам нужно поставить себе Angular.UI.Bootstrap, для того чтобы у нас были Bootstrap стили написаны для AngularJS.
Теперь чтобы эти все стили и скрипты не загружать явно, как это показано, например, в файле _Layout.cshtml.
<script src="~/Scripts/jquery-1.10.2.min.js"></script>
<script src="~/Scripts/bootstrap.min.js"></script>
Нужно сделать так, чтобы все скрипты и стили загружались у нас при старте приложения и были доступны везде в наших формах. Для этого нам нужно воспользоваться пакетом Web.Optimization.
Возможно, вы уже знаете, какую роль выполняет этот пакет. Если нет, то мы рассмотрим, какую возможность дает использование данного пакета, то есть, если говорить проще, то зачем мы его себе установили.  Благодаря этому пакету у нас появился коллекция BundleCollection, которая принимает два класса ScriptBundle (для javasript файлов) и StyleBundle (для css стилей) либо ваши классы, которые реализуете вы, наследуясь от класса Bundle. Теперь давайте посмотрим, как он работает на практике. Для этого перейдем в папку Add_Start и добавим новый класс BundleConfig. Ниже приведена его полная реализация.
using System.Web.Optimization;

namespace AngularjsRouting
{
    public class BundleConfig
    {
        public static void RegisterBundles(BundleCollection bundles)
        {
            BundleTable.EnableOptimizations = false;
            bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
                        "~/Scripts/jquery-{version}.js"));

            bundles.Add(new ScriptBundle("~/bundles/angular").Include(
                "~/Scripts/angular.js",
                "~/Scripts/angular-route.js",
                "~/Scripts/angular-ui/ui-bootstrap.js",
                "~/Scripts/angular-ui/ui-bootstrap-tpls.js"
                ));

            //bundles.Add(new ScriptBundle("~/bundles/app").IncludeDirectory("~/Scripts/app", "*.js", true));

            // Use the development version of Modernizr to develop with and learn from. Then, when you're
            // ready for production, use the build tool at http://modernizr.com to pick only the tests you need.
            bundles.Add(new ScriptBundle("~/bundles/modernizr").Include(
                        "~/Scripts/modernizr-*"));

            bundles.Add(new ScriptBundle("~/bundles/bootstrap").Include(
                      "~/Scripts/bootstrap.js"));

            bundles.Add(new StyleBundle("~/Content/css").Include(
                      "~/Content/bootstrap.css",
                      "~/Content/Site.css",
                      "~/Content/ui-bootstrap-csp.css"
                      ));
        }
    }
}
Вы можете просто скопировать и вставить себе весь кусок кода. Если обратите внимание, то один из bundles мы закомментировали. Он нам понадобится в дальнейшем, когда мы начнем писать контроллеры в AngularJS. Теперь нам нужно привести до ума наш файл _Layout.cshtml и убрать оттуда все явные вызовы скриптов и стилей. У меня это выглядит следующим образом.
@using System.Web.Optimization
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@ViewBag.Title - My ASP.NET Application</title>
    @Styles.Render("~/Content/css")
    @Scripts.Render("~/bundles/modernizr")
</head>
<body>
    <div class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                @Html.ActionLink("Application name", "Index", "Home", new { area = "" }, new { @class = "navbar-brand" })
            </div>
            <div class="navbar-collapse collapse">
                <ul class="nav navbar-nav">
                </ul>
            </div>
        </div>
    </div>

    <div class="container body-content">
        @RenderBody()
        <hr />
        <footer>
            <p>&copy; @DateTime.Now.Year - My ASP.NET Application</p>
        </footer>
    </div>

    @Scripts.Render("~/bundles/jquery")
    @Scripts.Render("~/bundles/angular")
    @Scripts.Render("~/bundles/bootstrap")
    @*@Scripts.Render("~/bundles/app")*@
    @RenderSection("scripts", required: false)
</body>
</html>
Кода немного, поэтому, думаю вы с этим справитесь очень быстро. Как видите, я закомментировал и здесь строчку ~/bundles/app. Мы ее раскомментируем позже, как только начнем добавлять контроллеры для работы.
Нам нужно сделать последний штрих – перейти в Global.asax и добавить вызов нашего класса BundleConfig.
public class Global : HttpApplication
{
    void Application_Start(object sender, EventArgs e)
    {
        // Code that runs on application startup
        AreaRegistration.RegisterAllAreas();
        GlobalConfiguration.Configure(WebApiConfig.Register);
        RouteConfig.RegisterRoutes(RouteTable.Routes);
        BundleConfig.RegisterBundles(BundleTable.Bundles);
    }
}
Затем перейдем в наш файл Index.cshtml и добавим разметку, которую мы будем использовать в дальнейшем, вместо реализованной раньше.
<div class="row">
    <div class="col-sm-3">
        <div class="panel panel-default">
            <div class="panel-heading">
                <i class="glyphicon glyphicon-asterisk"></i>&nbsp;Navigations
            </div>
            <div class="panel-body">
                <ul class="nav nav-pills nav-stacked">
                    <li>
                        <a ng-href="books">
                            Books
                        </a>
                    </li>
                    <li>
                        <a ng-href="authors">
                            Authors
                        </a>
                    </li>
                    <li>
                        <a ng-href="prices">
                            Prices
                        </a>
                    </li>
                    <li>
                        <a ng-href="test">
                            Test
                        </a>
                    </li>
                </ul>
            </div>
        </div>
    </div>
    <div class="col-sm-9 container slide" ng-view="">
    </div>
</div>
Нужно убедиться, что все работает, как следует. Запускаем наше приложение и смотрим, что все стили у нас подтянулись.
Теперь приступим к реализации самих представлений, которые у нас будут загружаться после нажатия на какой-то из наших доступных линков для перехода. Для этого в папку с представлениями Views добавим папку, которую назовем "app". Затем добавим в нее новый html файл с названием booksview.html, реализация которого приведена ниже.
<div class="container">
    <h2>{{title}}</h2>
    <table class="table table-bordered">
        <thead>
            <tr>
                <th>Title</th>
                <th>Author</th>
                <th>Count</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>C# in Depth</td>
                <td>Jon Skeet</td>
                <td>3</td>
            </tr>
            <tr>
                <td>Refactoring: Improving the Design of Existing Code</td>
                <td>Martin Fowler</td>
                <td>2</td>
            </tr>
            <tr>
                <td>CLR via C# (Developer Reference)</td>
                <td>Jeffrey Richter</td>
                <td>5</td>
            </tr>
        </tbody>
    </table>
</div>
Мы могли бы все эти данные установить с контроллера в AngularJS, но для уменьшения количества кода вынесем побольше логики в наши представления. Затем аналогично сделаем для ссылки Authors. Добавим еще один html-файл, который назовем authorsview.html. Реализация его очень похожа с предыдущей.
<div class="container">
    <h2>{{title}}</h2>
    <table class="table table-bordered">
        <thead>
            <tr>
                <th>Firstname</th>
                <th>Lastname</th>
                <th>Email</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>John</td>
                <td>Doe</td>
                <td>john@example.com</td>
            </tr>
            <tr>
                <td>Mary</td>
                <td>Moe</td>
                <td>mary@example.com</td>
            </tr>
            <tr>
                <td>July</td>
                <td>Dooley</td>
                <td>july@example.com</td>
            </tr>
        </tbody>
    </table>
</div>

Те же самые действия проделываем для представления pricesview.html.
<div class="container">
    <h2>{{title}}</h2>
    <table class="table table-bordered">
        <thead>
            <tr>
                <th>Title</th>
                <th>Price</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>C# in Depth</td>
                <td>22.5 $</td>
            </tr>
            <tr>
                <td>Refactoring: Improving the Design of Existing Code</td>
                <td>41.52 $</td>
            </tr>
            <tr>
                <td>CLR via C# (Developer Reference)</td>
                <td>35 $</td>
            </tr>
        </tbody>
    </table>
</div>
Ну и напоследок последнее представление testview.html.
<div class="container">
    <h2>{{title}}</h2>

    <div>Hello AngularJS</div>
</div>
Теперь нам нужно сделать так, чтобы наши представления были доступны через браузер. Потому что если мы сейчас попробуем обратиться к нашей форме, то получим следующую ошибку:
Для того чтобы понять, что не так, мне пришлось в свое время убить полтора дня, так как мне никто не смог подсказать, что же все-таки я делаю не так. А чтобы найти в гугле то, что нужно, сложно представить, как составить запрос, потому что я так и не смог ничего путного найти. Поэтому делюсь знаниями, которые получил путем  экспериментов и анализа других типов проектов. Для начала скопируем с корня файл web.config в нашу папку Views/app. Именно благодаря этому файлу мы и не могли обратиться к нашей форме. В этом файле присутствуют следующие строки:
  <appSettings>
    <add key="webpages:Enabled" value="false" />
  </appSettings>

  <system.webServer>
    <handlers>
      <remove name="BlockViewHandler"/>
      <add name="BlockViewHandler" path="*" verb="*" preCondition="integratedMode" type="System.Web.HttpNotFoundHandler" />
    </handlers>
  </system.webServer>
Первая – webpages:Enabled выставлена в false. Ставим сразу в true. Во второй убираем обработчик BlockViewHandler, который будет нас редиректить на 404 страницу. После изменений эти строчки приобретут следующий вид:
<appSettings>
  <add key="webpages:Enabled" value="true" />
</appSettings>

<system.webServer>
  <handlers>
    <remove name="BlockViewHandler"/>
  </handlers>
</system.webServer>
Нужно проверить, что после внесенных изменений все заработало. Запускаем для этого наш проект и в строке браузера вводим путь к любой нашей форме.
Осталось дело за малым. Нам нужно реализовать главный модуль на AngularJS, который будет нам обеспечивать навигацию между нашими контроллерами и представлениями. Для этого перейдем в папку Scripts и добавим новую папку app, а уже в нее добавим папку controllers, в которой будут храниться контроллеры. Но пока нам очень рано приступать к реализации контроллеров. Приступим к реализации главного модуля, без которого не будет возможна работа нашего представления, так как этого я пытаюсь добиться в данной статье. Для начала добавим в папку Scripts/app, которую мы только что создали новый JS файл, который назовем app.js. Ниже представлена его реализация.
(function () {
    "use strict";

    angular.module("angularRoutingDemo", ['ngRoute', 'ui.bootstrap', 'ui.bootstrap.tpls'])
        .config(function ($routeProvider, $locationProvider) {
            //debugger;
            console.log("app");
        });
})();
Со временем мы ее расширим, но для начала нам этого достаточно. После того как мы добавили этот файл, настало само время раскомментировать тот код, о котором упоминалось выше. Первый в файле BundleConfig раскомментируем строку ниже.
bundles.Add(new ScriptBundle("~/bundles/app").IncludeDirectory("~/Scripts/app", "*.js", true));
Затем в файле _Layout.cshtml нужно проделать то же самое и раскомментировать строку ниже.
@Scripts.Render("~/bundles/app")
В этом же файле переходим в шапку <html> о изменяем ее на следующую:
<html ng-app="angularRoutingDemo" >
Запустим и проверим, что наш модуль запускается. В консоле должно быть это выведено.
Теперь приступим к реализации наших контроллеров, которые мы будем добавлять в папку controllers, созданную выше. Контроллеры у нас будут очень примитивными и будут только обновлять свойство {{title}}, которое мы реализовали в наших представлениях. Для начала добавим контроллер authorsCtr.js, как показано ниже.
(function () {
    "use strict";

    angular.module("angularRoutingDemo").controller("authorsCtr", [
        '$scope', function ($scope) {

            console.log("authorsCtr");

            $scope.title = 'Authors View';
        }
    ]);;

})();
Затем добавим файл booksCtr.js.
(function () {
    "use strict";

    angular.module("angularRoutingDemo").controller("booksCtr", [
        '$scope', function ($scope) {

            console.log("booksCtr");

            $scope.title = 'Books View';
        }
    ]);;

})();
Также нужно добавить файл pricesCrt.js, который будет реализовать контроллер для взаимодействия с нашим представлением pricesview.html.
(function () {
    "use strict";

    angular.module("angularRoutingDemo").controller("pricesCtr", [
        '$scope', function ($scope) {

            console.log("pricesCtr");

            $scope.title = 'Price View';
        }
    ]);;

})();
Осталось добавить последний файл testCtr.js.
(function () {
    "use strict";

    angular.module("angularRoutingDemo").controller("testCtr", [
        '$scope', function ($scope) {

            console.log("testCtr");

            $scope.title = 'Test View';
        }
    ]);;

})();
Запустим наш проект, чтобы убедиться, что ничего не сломалось. После того как мы убедились, что ничего не сломалось и все заработало, приступаем к настройке маршрутизации. Для этого в app.js файле добавим маршрутизацию, чтобы при нажатии на нужный нам линк загружалась соответствующая форма.
(function () {
    "use strict";

    angular.module("angularRoutingDemo", ['ngRoute', 'ui.bootstrap', 'ui.bootstrap.tpls'])
        .config(function ($routeProvider, $locationProvider) {
            //debugger;
            console.log("app");

            $routeProvider
                .when('/', {
                    templateUrl: 'Views/app/booksview.html',
                    controller: 'booksCtr'
                })
                .when('/authors', {
                    templateUrl: 'Views/app/authorsview.html',
                    controller: 'authorsCtr'
                })
                .when('/prices', {
                    templateUrl: 'Views/app/pricesview.html',
                    controller: 'pricesCtr'
                })
                .when('/test', {
                    templateUrl: 'Views/app/testview.html',
                    controller: 'testCtr'
                })
                .otherwise({
                    redirectTo: '/'
                });

            $locationProvider.html5Mode({
                enabled: false
            });
        });
})();
Если мы запустим нашу форму, то увидим соответствующий результат.
Визуально все, вроде, работает. Но на самом деле, если вы нажмете на какой-либо линк, то вы будете получать 404 ошибку. Все это происходит из-за баги с $locationProvider, которая описана здесь. Я из-за этой баги в свое время потратил очень много времени. Не повторяйте моей ошибки.
Чтобы исправить эту ошибку, добавим в <head> (файл _Layout.cshtml) следующую строку кода:
<base href="/" />
И заменяем в файле app.js вызов $locationProvider.html5Mode на следующий:
$locationProvider.html5Mode(true);
Запускаем и проверяем, что у нас все работает.
Все красиво отображается в правой части нашего приложения, и можем его кастомизировать, как только хотим. Заметьте, у меня в скриншоте выше выбрано представление pricesview.html, а title, который вы видите, установлен с контроллера.
$scope.title = 'Price View';
Почему все это работает и как, очень хорошо показано на следующей картинке.
Картинку я взял со статьи Using an AngularJS Factory to Interact with a RESTful Service. По сути, $scope в AngularJS выступает чем-то вроде ViewModel в классическом представлении паттерна MVVM.

Итоги
Сегодня мы с вами рассмотрели небольшое погружение в AngularJS. Как видите, с сервером общения мы постарались избежать по максимуму. Вся логика наша была построена на клиенте. Подход, который использует AngularJS, – это отличный шанс перейти полностью на Web API и перегонять только json файлы между клиентом и сервером. До этого я работал с knockoutjs, и AngularJS заставил меня по-другому взглянуть на мир разработки фронтенда. Мне не нужно заботиться о подписке на свойства, вместо меня эта работа целиком возложена на AngularJS. Для меня больше всего сложности вызывает незнание JavaScript на продвинутом уровне, как хотелось бы. В следующей статье мы рассмотрим, как разработать приложение с полноценным общением между AngularJS и Web API. Спасибо, что дочитали статью до конца, и я надеюсь, что вы нашли что-то занимательное для себя.

Исходники: AngularJS Sample