У меня есть большой проект, написанный на ASP.NET WebForms. В нем намешано много всякого, и постепенно мне это всё перестало нравиться. Решил я попробовать переписать всё на чем-нибудь современном. Angular 2 мне приглянулся сразу, и я решил пробовать его. Задача определилась такая: написать новый frontend, прикрутив его к существующему backend, с минимальными переделками последнего. Новый frontend должен быть UI-совместимым со старым, чтобы конечный пользователь ничего не заметил.
Итого имеем такой стэк: backend — ASP.NET Web API, Entity Framework, MS SQL; frontend — Angular 2; тема Bootstrap 3.
Сразу покажу результат TreeView:
Процесс настройки Angular 2 в Visual Studio описывать не буду, на просторах этого полно. Единственное, что пришлось добавить, это настройку в web.config для редиректа route-запросов на index.html:
<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() события распростаняются от потомков к родителям (наружу иерархии). Компонент рекурсивный — каждый новый уровень иерархии обрабатывает свой экземпляр компонента.
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');
}
}
<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>
Стили сделал отдельным файлом.
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;
}
Как использовать:
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));
}
}
<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>
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 и его обработку. Сократил кол-во методов.
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);
}
}
}
<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)
lega
12.03.2016 15:07Правильнее было бы добавить свойство isExpanded в модель
janitor, да, только это не модель, а ViewModel, туда как раз и надо размещать, а в текущем решении isExpanden() дергается на каждый digest цикл (ChangeDetector'a).
А вообще, что-бы построить дерево — не обязательно писать компоненты или директивы, достаточно рекурсивного вызова шаблона.
Вот пример на Angular Light.janitor
12.03.2016 15:11Да какая разница, как это назвать, "ViewModel" или просто "модель", суть-то ясна, "модель данных" на клиенте имеется в виду
supersmeh
13.03.2016 08:07А вообще, что-бы построить дерево — не обязательно писать компоненты или директивы, достаточно рекурсивного вызова шаблона.
Можно, но тогда очень страдает переиспользование. Компонент же я для того и пишу, чтобы переиспользовать его на любых страницах, где он понадобится, а дополнять/фиксить функционал только в компоненте.
xskif
14.03.2016 13:351) дергается только при изменении скоупа одного уровня или выше
2) можно использовать свойство вместо метода
3) в новом ангуляре вместо дайджеста используется другое решение, которое похоже на дайджест из первого только в N*10 раз быстрее судя по их бенчмаркам, так что не критичноlega
14.03.2016 13:592) можно использовать свойство вместо метода
В том и дело.
1) дергается только при изменении скоупа одного уровня или выше
Дергается на каждый disgest как я и написал, только у каждого компонента свой changeDetector, и система компиляции навороченная (в положительном и отрицательном смысле), так же они предлагают несколько способов взаимодействия между компонентами и их changeDetector'ами, это все можно использовать что-бы ускорить dirty-checking в A2.
3) в новом ангуляре вместо дайджеста используется другое решение, которое похоже на дайджест из первого только в N*10 раз быстрее судя по их бенчмаркам, так что не критичноxskif
14.03.2016 17:03Это почти тоже самое что я написал выше. И я не вижу тут причины уносить toggleClass в контроллер, тем самым разрушая чистую архитектуру. В приложении, над которым я сейчас работаю, более 600 вотчеров на странице (ангуляр 1) и это нагружает двух-ядерный процессор ровно на 2%. Более того, как вы указали, каждый компонент имеет свой ChageDetector, который будет вызываться только если мы что то сделали внутри компонента, например использовали метод через ngClick, а это уже практически равноценно по нагрузке прямому jQuery-стайл коду. Так почему я не должен использовать ngClass и свойство isExpanded?
janitor
Правильнее было бы добавить свойство
isExpanded
в модель (элемент массива Nodes), и указывать классы в шаблоне, а не удалять/добавлять классы в коде компонента. Как вы, например, сохранить состояние дереве в определенный момент? Например, чтобы после перезагрузки страницы можно было восстановить это саоме состояниеsupersmeh
Не добавил isExpanded в модель по таким соображениям — это поле нужно только для UI, на сервере хранить состояние дерева пока не собирался. По этому этого поля нет в модели и я решил обойтись тем, что уже есть — проверкой наличия соответствующего класса.
Задачи сохранять состояние дерева пока не ставил, возможно при решении такой задачи всё перекочует на сервер в какой нибудь asp.net Session...
Aetet
Любое состояние даже UI лучше хранить на клиенте. Т.к. вы можете в любой момент обратиться к нему, узнать чему оно равно, и отрендерить на его основе другой виджет. Плюс так гораздо проще сериализовывать. Надо помнить, что Angular 2 в первую очередь помогает рендерить верстку на основании данных. А не управлять данными на основе верстки. Это позволяет держать только один источник изменениий и уменьшает количество багов на клиенте.
supersmeh
Спасибо за ответ, в целом согласен. Хотел только уточнить про:
Это Вы в рамках теоретических рассуждений или в моем примере что-то такое заметили?
xskif
Согласен с предыдущим комментатором, в Вашем примере логика вьюхи сидит в компоненте хардкодом, не надо так.
supersmeh
Я думал, что компонент — это и есть вьюха+контроллер, оно как-бы единое целое и использовать одно без другого не получится. По этому и не вижу разницы где писать логику. Или Вы что-то другое имели в виду и я не понял.
xskif
Вы не правы. Компонент — это самостоятельная часть целого, как компонент для приготовления блюда в кулинарии или компонент устройства для его сбора. Что подразумевает под собой компонент внутри системы — дело исключительно контекстное, но компонент не может состоять из двух фундаментальных сущностей. Мы можем сказать, например, что вьюха это компонент MVC, такой же равноценный как и контроллер или модель, часть целого.
Так вот, в Вашем примере логика вьюхи, в данном случае название класса и принцип по котором он устанавливается/снимается с элемента, уехала в компонент (контроллер). Контроллер, в свою очередь, в js приложениях используется как VM и он не манипулирует вьюхой на прямую а является ее модельным представлением, то есть наличие класса active определяется свойством active или isActive. Мало того что это дает возможность сохранять состояния компонентов через сессию, так еще позволяет разделить работу между двумя людьми — верстальщиком и программистом.
supersmeh
Вот, теперь я врубился, что Вы имели ввиду. Спасибо. Все понял, постараюсь соблюдать.
xskif
Не за что. Если будут вопросы, пишите в личку.