Вдохновившись статьей «Порог вхождения в Angular 2 — теория и практика», решил тоже написать статью про свои муки творчества.

У меня есть большой проект, написанный на ASP.NET WebForms. В нем намешано много всякого, и постепенно мне это всё перестало нравиться. Решил я попробовать переписать всё на чем-нибудь современном. Angular 2 мне приглянулся сразу, и я решил пробовать его. Задача определилась такая: написать новый frontend, прикрутив его к существующему backend, с минимальными переделками последнего. Новый frontend должен быть UI-совместимым со старым, чтобы конечный пользователь ничего не заметил.

Итого имеем такой стэк: backend — ASP.NET Web API, Entity Framework, MS SQL; frontend — Angular 2; тема Bootstrap 3.

Сразу покажу результат TreeView:

image

Процесс настройки Angular 2 в Visual Studio описывать не буду, на просторах этого полно. Единственное, что пришлось добавить, это настройку в web.config для редиректа route-запросов на index.html:

кусок web.config
<system.webServer>
	  <modules runAllManagedModulesForAllRequests="true"/>
	  <rewrite>
		  <rules>
			  <rule name="IndexRule" stopProcessing="true">
				  <match url=".*"/>
				  <conditions logicalGrouping="MatchAll">
					  <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true"/>
					  <add input="{REQUEST_URI}" matchType="Pattern" pattern="^/api/" negate="true"/>
				  </conditions>
				  <action type="Rewrite" url="/index.html"/>
			  </rule>
		  </rules>
	  </rewrite>
</system.webServer>


Все успешно взлетело. Статик файлы грузятся правильно, api отрабатывают контроллеры web api, остальные маршруты всегда обрабатывает index.html.

Прежде чем начинать писать конечные точки, решил сначала написать некоторые контролы-аналоги WebForm's. Чаще всего конечно используется ListView и FormView. Но начать я решил с простенького TreeView, он тоже нужен в нескольких формах.

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

При раскрытии узла проверяем наличие потомков, при отсутствии генерируем событие onRequestNodes. При выделении пользователем узла — генерируем событие onSelectedChanged. Иконки fontawesome.

Компонент имеет два входящих параметра: Nodes — список узлов на данном уровне, SelectedNode — выбранный пользователем узел. Два события: onSelectedChanged — смена выбранного пользователем узла, onRequestNodes — запрос узлов, при необходимости. @Input параметры распространяются от родителя к потомкам (вглубь иерархии). @Output() события распростаняются от потомков к родителям (наружу иерархии). Компонент рекурсивный — каждый новый уровень иерархии обрабатывает свой экземпляр компонента.

treeview.component.ts
import {Component, Input, Output, EventEmitter} from 'angular2/core';

export interface ITreeNode {
	id: number;
	name: string;
	children: Array<ITreeNode>;
}

@Component({
	selector: "tree-view",
	templateUrl: "/app/components/treeview/treeview.html",
	directives: [TreeViewComponent]
})
export class TreeViewComponent {

	@Input() Nodes: Array<ITreeNode>;
	@Input() SelectedNode: ITreeNode;

	@Output() onSelectedChanged: EventEmitter<ITreeNode> = new EventEmitter();
	@Output() onRequestNodes: EventEmitter<ITreeNode> = new EventEmitter();

	constructor() { }

	onSelectNode(node: ITreeNode) {
		this.onSelectedChanged.emit(node);
	}

	onExpand(li: HTMLLIElement, node: ITreeNode) {
		if (this.isExpanden(li)) {
			li.classList.remove('expanded');
		}
		else {
			li.classList.add('expanded');

			if (node.children.length == 0) {
				this.onRequest(node);
			}
		}
	}

	onRequest(parent: ITreeNode) {
		this.onRequestNodes.emit(parent);
	}

	isExpanden(li: HTMLLIElement) {
		return li.classList.contains('expanded');
	}
}


treeview.html
<ul class="treenodes">
	<li #li *ngFor="#node of Nodes" class="treenode">
		<i class="nodebutton fa"
		   (click)="onExpand(li, node)"
		   [ngClass]="{'fa-minus-square-o': isExpanden(li), 'fa-plus-square-o': !isExpanden(li)}">
		</i>

		<span class="nodetext"
			  [ngClass]="{'bg-info': node == SelectedNode}"
			  (click)="onSelectNode(node)">
			{{node.name}}
		</span>

		<tree-view [Nodes]="node.children"
				   [SelectedNode]="SelectedNode"
				   (onSelectedChanged)="onSelectNode($event)"
				   (onRequestNodes)="onRequest($event)"
				   *ngIf="isExpanden(li)">
		</tree-view>
	</li>
</ul>


Стили сделал отдельным файлом.

treeview.css
tree-view .treenodes {
	list-style-type: none;
	padding-left: 0;
}

tree-view tree-view .treenodes {
	list-style-type: none;
	padding-left: 16px;
}

tree-view .nodebutton {
	cursor: pointer;
}

tree-view .nodetext {
	padding-left: 3px;
	padding-right: 3px;
	cursor: pointer;
}


Как использовать:

sandbox.component.ts
import {Component, OnInit} from 'angular2/core';
import {NgClass} from 'angular2/common';
import {TreeViewComponent, ITreeNode} from '../treeview/treeview.component';
import {TreeService} from '../../services/tree.service';

@Component({
	templateUrl: '/app/components/sandbox/sandbox.html',
	directives: [NgClass, TreeViewComponent]
})
export class SandboxComponent implements OnInit {

	Nodes: Array<ITreeNode>;
	selectedNode: ITreeNode; // нужен для отображения детальной информации по выбранному узлу.

	constructor(private treeService: TreeService) {
	}

	// начальное заполнение верхнего уровня иерархии
	ngOnInit() {
		this.treeService.GetNodes(0).subscribe(
			res => this.Nodes = res,
			error => console.log(error)
		);
	}
	// обработка события смены выбранного узла
	onSelectNode(node: ITreeNode) {
		this.selectedNode = node;
	}
	// обработка события вложенных узлов
	onRequest(parent: ITreeNode) {
		this.treeService.GetNodes(parent.id).subscribe(
			res => parent.children = res,
			error=> console.log(error));
	}
}


sandbox.html
Напоминаю, у меня bootstrap 3.

<div class="col-lg-3">
	<div class="panel panel-info">
		<div class="panel-body">
			<tree-view [Nodes]="Nodes"
					   [SelectedNode]="selectedNode"
					   (onSelectedChanged)="onSelectNode($event)"
					   (onRequestNodes)="onRequest($event)">
			</tree-view>
		</div>
	</div>
</div>


tree.service.ts
Самый примитивный сервис
import {Injectable} from 'angular2/core';
import {Http} from 'angular2/http';
import 'rxjs/Rx';

@Injectable()
export class TreeService {
	constructor(public http: Http) {

	}
	GetNodes(parentId: number) {
		return this.http.get("/api/tree/" + parentId.toString())
			.map(res=> res.json());
	}
}


Получился вот такой «каркас» treeview. В дальнейшем можно сделать свойства для иконок, для выделения, чтобы отвязать treeview от bootstrap 3.

Backend описывать не буду, там ничего интересного, обычный web api контроллер и entity framework.

Следующий подопытный будет asp:ListView. В моём проекте он используется повсюду и по всякому. С встроенными Insert, Update templates и без, с множественной сортировкой, с пейджингом, с фильтрами…

Update 1:
Всем спасибо за комментарии. На их основании немного доработал компонент.
Добавил поле isExpanded и его обработку. Сократил кол-во методов.

treeview.component.ts ver:0.2
import {Component, Input, Output, EventEmitter} from 'angular2/core';

export interface ITreeNode {
	id: number;
	name: string;
	children: Array<ITreeNode>;
	isExpanded: boolean;
}

@Component({
	selector: "tree-view",
	templateUrl: "/app/components/treeview/treeview.html",
	directives: [TreeViewComponent]
})
export class TreeViewComponent {

	@Input() Nodes: Array<ITreeNode>;
	@Input() SelectedNode: ITreeNode;

	@Output() onSelectedChanged: EventEmitter<ITreeNode> = new EventEmitter();
	@Output() onRequestNodes: EventEmitter<ITreeNode> = new EventEmitter();

	constructor() { }

	onSelectNode(node: ITreeNode) {
		this.onSelectedChanged.emit(node);
	}

	onExpand(node: ITreeNode) {

		node.isExpanded = !node.isExpanded;

		if (node.isExpanded && node.children.length == 0) {
			this.onRequestNodes.emit(parent);
		}
	}
}


treeview.html ver:0.2
<ul class="treenodes">
	<li *ngFor="#node of Nodes" class="treenode">
		<i class="nodebutton fa fa-{{node.isExpanded ? 'minus' : 'plus'}}-square-o"
		   (click)="onExpand(node)">
		</i>

		<span class="nodetext {{node == SelectedNode ? 'bg-info' : ''}}"
			  (click)="onSelectNode(node)">
			{{node.name}}
		</span>

		<tree-view [Nodes]="node.children"
				   [SelectedNode]="SelectedNode"
				   (onSelectedChanged)="onSelectNode($event)"
				   (onRequestNodes)="onRequest($event)"
				   *ngIf="node.isExpanded">
		</tree-view>
	</li>
</ul>



Готов к конструктивной критике.

Комментарии (15)


  1. janitor
    11.03.2016 14:52

    Правильнее было бы добавить свойство isExpanded в модель (элемент массива Nodes), и указывать классы в шаблоне, а не удалять/добавлять классы в коде компонента. Как вы, например, сохранить состояние дереве в определенный момент? Например, чтобы после перезагрузки страницы можно было восстановить это саоме состояние


    1. supersmeh
      11.03.2016 17:09

      Не добавил isExpanded в модель по таким соображениям — это поле нужно только для UI, на сервере хранить состояние дерева пока не собирался. По этому этого поля нет в модели и я решил обойтись тем, что уже есть — проверкой наличия соответствующего класса.
      Задачи сохранять состояние дерева пока не ставил, возможно при решении такой задачи всё перекочует на сервер в какой нибудь asp.net Session...


      1. Aetet
        11.03.2016 19:26
        +1

        Любое состояние даже UI лучше хранить на клиенте. Т.к. вы можете в любой момент обратиться к нему, узнать чему оно равно, и отрендерить на его основе другой виджет. Плюс так гораздо проще сериализовывать. Надо помнить, что Angular 2 в первую очередь помогает рендерить верстку на основании данных. А не управлять данными на основе верстки. Это позволяет держать только один источник изменениий и уменьшает количество багов на клиенте.


        1. supersmeh
          13.03.2016 08:13

          Спасибо за ответ, в целом согласен. Хотел только уточнить про:

          А не управлять данными на основе верстки

          Это Вы в рамках теоретических рассуждений или в моем примере что-то такое заметили?


      1. xskif
        12.03.2016 11:26
        +1

        Согласен с предыдущим комментатором, в Вашем примере логика вьюхи сидит в компоненте хардкодом, не надо так.


        1. supersmeh
          13.03.2016 08:19

          Я думал, что компонент — это и есть вьюха+контроллер, оно как-бы единое целое и использовать одно без другого не получится. По этому и не вижу разницы где писать логику. Или Вы что-то другое имели в виду и я не понял.


          1. xskif
            14.03.2016 13:27

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

            Так вот, в Вашем примере логика вьюхи, в данном случае название класса и принцип по котором он устанавливается/снимается с элемента, уехала в компонент (контроллер). Контроллер, в свою очередь, в js приложениях используется как VM и он не манипулирует вьюхой на прямую а является ее модельным представлением, то есть наличие класса active определяется свойством active или isActive. Мало того что это дает возможность сохранять состояния компонентов через сессию, так еще позволяет разделить работу между двумя людьми — верстальщиком и программистом.


            1. supersmeh
              14.03.2016 14:22

              Вот, теперь я врубился, что Вы имели ввиду. Спасибо. Все понял, постараюсь соблюдать.


              1. xskif
                14.03.2016 17:04

                Не за что. Если будут вопросы, пишите в личку.


  1. lega
    12.03.2016 15:07

    Правильнее было бы добавить свойство isExpanded в модель

    janitor, да, только это не модель, а ViewModel, туда как раз и надо размещать, а в текущем решении isExpanden() дергается на каждый digest цикл (ChangeDetector'a).

    А вообще, что-бы построить дерево — не обязательно писать компоненты или директивы, достаточно рекурсивного вызова шаблона.
    Вот пример на Angular Light.


    1. janitor
      12.03.2016 15:11

      Да какая разница, как это назвать, "ViewModel" или просто "модель", суть-то ясна, "модель данных" на клиенте имеется в виду


    1. supersmeh
      13.03.2016 08:07

      А вообще, что-бы построить дерево — не обязательно писать компоненты или директивы, достаточно рекурсивного вызова шаблона.

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


    1. xskif
      14.03.2016 13:35

      1) дергается только при изменении скоупа одного уровня или выше
      2) можно использовать свойство вместо метода
      3) в новом ангуляре вместо дайджеста используется другое решение, которое похоже на дайджест из первого только в N*10 раз быстрее судя по их бенчмаркам, так что не критично


      1. lega
        14.03.2016 13:59

        2) можно использовать свойство вместо метода
        В том и дело.

        1) дергается только при изменении скоупа одного уровня или выше
        3) в новом ангуляре вместо дайджеста используется другое решение, которое похоже на дайджест из первого только в N*10 раз быстрее судя по их бенчмаркам, так что не критично
        Дергается на каждый disgest как я и написал, только у каждого компонента свой changeDetector, и система компиляции навороченная (в положительном и отрицательном смысле), так же они предлагают несколько способов взаимодействия между компонентами и их changeDetector'ами, это все можно использовать что-бы ускорить dirty-checking в A2.


        1. xskif
          14.03.2016 17:03

          Это почти тоже самое что я написал выше. И я не вижу тут причины уносить toggleClass в контроллер, тем самым разрушая чистую архитектуру. В приложении, над которым я сейчас работаю, более 600 вотчеров на странице (ангуляр 1) и это нагружает двух-ядерный процессор ровно на 2%. Более того, как вы указали, каждый компонент имеет свой ChageDetector, который будет вызываться только если мы что то сделали внутри компонента, например использовали метод через ngClick, а это уже практически равноценно по нагрузке прямому jQuery-стайл коду. Так почему я не должен использовать ngClass и свойство isExpanded?