Я разрабатываю несколько Angular-библиотек, поэтому люблю делать простые и легко переиспользуемые решения для разработчиков. Недавно один из подписчиков в Твиттере спросил меня, как сделать компонент, который выводил бы его данные в виде иерархического дерева — tree view.
Я люблю подобные задачи, потому что они дают возможность покрыть много различных юзкейсов с минимальным количеством логики внутри. В этой статье опишу, как я размышляю, когда решаю подобные задачи.
Дисклеймер: эта статья-туториал рассчитана на аудиторию изучающих Angular. Если вы понимаете, как сделать рекурсивный тип, рекурсивный компонент и преобразовать в нем данные, переданные функцией-обработчиком, можете ее пропустить.
Итак, что нам нужно?
В первую очередь нам надо понять, с какими данными мы будем работать. Что описывает такую древовидную структуру?
Здесь первым приходит в голову многомерный массив: если мы встретили в нем элемент, то просто покажем его. Если встретили вложенный массив, то погружаемся на уровень ниже.
Давайте опишем такой тип в TypeScript:
export type MultidimensionalArray<string> =
| string
| ReadonlyArray<MultidimensionalArray<string>>;
Это будет работать благодаря TypeScript recursive type references и позволит нам использовать подобную структуру в качестве данных:
readonly items: MultidimensionalArray<string> = [
"Hello",
["here", "is", ["some", "structured"], "Data"],
"Bye"
];
Каждый элемент это («строка» или массив из («строка» или массив из («строка» или …)))… Добро пожаловать в рекурсию!
Что ж, готово? Но здесь можно найти одну небольшую проблему. Мы планировали сделать легко переиспользуемый компонент, и, скорее всего, пользователи нашего компонента используют в своих приложениях не только строки в качестве данных.
Но эта проблема легко решается. Давайте воспользуемся TypeScript generics:
export type MultidimensionalArray<T> =
| T
| ReadonlyArray<MultidimensionalArray<T>>;
Теперь у нас есть крепкая типизация и мы можем начать кодить что-нибудь настоящее!
Рекурсивный Angular-компонент
Angular поддерживает рекурсию в компонентах. Эта фича позволит нам нарисовать tree view, строя из компонентов ровно такую же структуру, которую имеет наш массив.
Давайте создадим компонент для отображения tree view:
В классе компонента нам определенно нужен инпут для значения — тот самый элемент или массив элементов или массивов элементов и так далее
Кроме того, я сделаю еще один геттер isArray
Его можно будет использовать в компоненте для проверки, а также завязать на него HostBinding, чтобы можно было легко разделить случаи массива и отдельного элемента в стилях.
@Component({
selector: "m-dimensional-view",
templateUrl: "./m-dimensional-view.template.html",
styleUrls: ["./m-dimensional-view.styles.less"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MultidimensionalViewComponent<T> {
@Input()
value: MultidimensionalArray<T> = [];
@HostBinding("class._array")
get isArray(): boolean {
return Array.isArray(this.value);
}
}
В шаблоне же нам нужно рассмотреть два кейса с помощью isArray-геттера и *ngIf
Если у нас массив, то мы можем проитерировать по каждому его элементу через *ngFor
передав элемент в m-dimensional-view следующего уровня, — так мы и получим необходимую нам рекурсию.
Если у нас отдельный элемент, давайте просто отобразим его, заодно покинув рекурсию.
<ng-container *ngIf="isArray; else itemView">
<m-dimensional-view
*ngFor="let item of value"
[value]="item"
></m-dimensional-view>
</ng-container>
<ng-template #itemView>
{{ value }}
</ng-template>
На этой стадии мы можем написать простейшие стили, чтобы убедиться, что наша задумка работает.
:host {
display: block;
&._array {
margin-left: 20px;
}
}
Просто margin-left для каждого уровня вложенности, написано на LESS
Давайте взглянем, что мы получили:
Компонент работает корректно и может показывать строки или любой произвольный объект с методом toString (интерполяция {{value}}
приводит значение к строчному виду по умолчанию).
Но все же разработчики, которые будут переиспользовать наш компонент, редко имеют данные с реализованными toString-методами. Если они будут орудовать обычными объектами, то их дерево будет состоять исключительно из [object Object]
Поддержка данных любого типа
Проблема предыдущего решения может быть легко устранена с помощью хендлеров — функций-обработчиков. Это такие функции, которые принимают в себя элемент и отвечают на какой-то вопрос. В нашем случае вопрос будет звучать так: «Какое строчное представление этого элемента?».
Давайте добавим еще один инпут к нашему компоненту с подобным хендлером:
@Component({})
export class MultidimensionalViewComponent<T> {
// ...
@Input()
stringify: (item: T) => string = (item: T) => String(item);
// ...
}
Разработчик может передать функцию, которая приведет элемент к строке. По умолчанию же будет нативный String.
Также не забудем добавить обработку значения в шаблон:
<ng-container *ngIf="isArray; else itemView">
<m-dimensional-view
*ngFor="let item of value"
[stringify]="stringify"
[value]="item"
></m-dimensional-view>
</ng-container>
<ng-template #itemView>
{{stringify(value)}}
</ng-template>
Теперь мы используем stringify для значения, если в нашем компоненте отдельный элемент, или передаем ее для значения на следующем уровне вложенности.
Мы можем добавить любые стили для нашего дерева. Мой пример будет немного необычным: подписчику, по запросу которого я готовил этот компонент, было нужно дерево, которое стартует сверху и отрисовывает структуру линиями из точек.
Вот по этой ссылке можно увидеть весь получившийся код в действии: Открыть Stackblitz
Отдельное спасибо Waterplea за щепотку CSS-магии, чтобы сделать пример более лаконичным:
Хотя постойте…
А вдруг мы захотим добавить к пункту ссылку или иконку?
Мы можем пойти еще дальше и позволить кастомизировать компонент шаблонами ng-polymorheus. Они тоже поддерживают строки и обработчики, но еще позволяют представить значение как любой кастомный шаблон или компонент.
Давайте установим ng-polymorheus:
npm i @tinkoff/ng-polymorpheus
В нем содержится специальный тип для «строка», или «обработчик», или «шаблон», или «компонент». Импортируем его и немного перепишем класс:
import { PolymorpheusContent } from "@tinkoff/ng-polymorpheus";
// ...
@Component({
selector: "m-dimensional-view",
templateUrl: "./m-dimensional-view.template.html",
styleUrls: ["./m-dimensional-view.styles.less"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MultidimensionalViewComponent<T> {
@Input()
value: MultidimensionalArray<T> = [];
@Input()
content: PolymorpheusContent = "";
@HostBinding("class._array")
get isArray(): boolean {
return Array.isArray(this.value);
}
}
В шаблоне компонента нам нужно заменить функцию stringify на polymorpheus-outlet. Этот компонент создаст блок с контентом. Если контент будет строкой или числом, то блок покажет их значение. Если контент — функция, шаблон или компонент, то мы сможем получить значение благодаря context и кастомизировать контент под каждый конкретный элемент.
Теперь мы готовы создать более хитрый пример. Давайте посмотрим на массив из папок и файлов с различными иконками:
readonly itemsWithIcons: MultidimensionalArray<Node> = [
{
title: "Documents",
icon: "https://www.flaticon.com/svg/static/icons/svg/210/210086.svg"
},
[
{
title: "hello.doc",
icon: "https://www.flaticon.com/svg/static/icons/svg/2306/2306060.svg"
},
{
title: "table.csv",
icon: "https://www.flaticon.com/svg/static/icons/svg/2306/2306046.svg"
}
]
];
Добавим шаблон polymorheus для кастомизации, он будет передаваться как контент в компонент вывода дерева:
<m-dimensional-view
[value]="itemsWithIcons"
[content]="itemView"
></m-dimensional-view>
<ng-template #itemView let-icon="icon" let-title="title">
<img alt="icon" width="16" [src]="icon" />
{{title}}
</ng-template>
В этом шаблоне у нас есть доступ к полям объекта элемента из контекста, который пробрасывается внутри tree view компонента. Когда мы пишем let-icon
мы получаем локальную переменную со строкой, которую можем использовать внутри этого ng-template. Самим шаблоном будет картинка с иконкой и название папки или файла:
Вот три примера с ng-polymorheus: Открыть Stackblitz
Итого
Итак, мы написали один тип, один компонент с двумя инпутами и несколько строк HTML. Да, изначальная проблема не очень сложна, но наше маленькое решение содержит множество идей, которые можно использовать при проектировании других компонентов.
Я же просто показал, как размышляю, когда делаю подобные решения, чтобы их мог переиспользовать любой разработчик для своего конкретного кейса.
Автор: Роман Седов