- PVSM.RU - https://www.pvsm.ru -

Давайте сделаем переиспользуемый компонент tree view в Angular

Я разрабатываю несколько Angular-библиотек, поэтому люблю делать простые и легко переиспользуемые решения для разработчиков. Недавно один из подписчиков в Твиттере [1] спросил меня, как сделать компонент, который выводил бы его данные в виде иерархического дерева — tree view. 

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

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

Итак, что нам нужно?

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

Здесь первым приходит в голову многомерный массив: если мы встретили в нем элемент, то просто покажем его. Если встретили вложенный массив, то погружаемся на уровень ниже.

Давайте опишем такой тип в TypeScript:

export type MultidimensionalArray<string> =
| string
| ReadonlyArray<MultidimensionalArray<string>>;

Это будет работать благодаря TypeScript recursive type references [2] и позволит нам использовать подобную структуру в качестве данных:

readonly items: MultidimensionalArray<string> = [
    "Hello",
    ["here", "is", ["some", "structured"], "Data"],
    "Bye"
];

Каждый элемент это («строка» или массив из («строка» или массив из («строка» или …)))… Добро пожаловать в рекурсию!

Что ж, готово? Но здесь можно найти одну небольшую проблему. Мы планировали сделать легко переиспользуемый компонент, и, скорее всего, пользователи нашего компонента используют в своих приложениях не только строки в качестве данных.

Но эта проблема легко решается. Давайте воспользуемся TypeScript generics: [3]

export type MultidimensionalArray<T> =
| T
| ReadonlyArray<MultidimensionalArray<T>>;

Теперь у нас есть крепкая типизация и мы можем начать кодить что-нибудь настоящее!

Рекурсивный Angular-компонент

Angular поддерживает рекурсию в компонентах. Эта фича позволит нам нарисовать tree view, строя из компонентов ровно такую же структуру, которую имеет наш массив.

Давайте создадим компонент для отображения tree view:

Давайте сделаем переиспользуемый компонент tree view в Angular - 1

В классе компонента нам определенно нужен инпут для значения — тот самый элемент или массив элементов или массивов элементов и так далее

Кроме того, я сделаю еще один геттер 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

Давайте взглянем, что мы получили:

Давайте сделаем переиспользуемый компонент tree view в Angular - 2

Компонент работает корректно и может показывать строки или любой произвольный объект с методом 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 [4]

Отдельное спасибо Waterplea [5] за щепотку CSS-магии, чтобы сделать пример более лаконичным:

Давайте сделаем переиспользуемый компонент tree view в Angular - 3

Хотя постойте…

А вдруг мы захотим добавить к пункту ссылку или иконку?

Мы можем пойти еще дальше и позволить кастомизировать компонент шаблонами ng-polymorheus. [6] Они тоже поддерживают строки и обработчики, но еще позволяют представить значение как любой кастомный шаблон или компонент.

Давайте установим ng-polymorheus: [6]

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. Самим шаблоном будет картинка с иконкой и название папки или файла:

Давайте сделаем переиспользуемый компонент tree view в Angular - 4

Вот три примера с ng-polymorheus: Открыть Stackblitz [7]

Итого

Итак, мы написали один тип, один компонент с двумя инпутами и несколько строк HTML. Да, изначальная проблема не очень сложна, но наше маленькое решение содержит множество идей, которые можно использовать при проектировании других компонентов.

Я же просто показал, как размышляю, когда делаю подобные решения, чтобы их мог переиспользовать любой разработчик для своего конкретного кейса.

Автор: Роман Седов

Источник [8]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/358285

Ссылки в тексте:

[1] в Твиттере: https://twitter.com/marsibarsi

[2] TypeScript recursive type references: https://github.com/microsoft/TypeScript/pull/33050

[3] TypeScript generics:: https://www.typescriptlang.org/docs/handbook/generics.html

[4] Открыть Stackblitz: https://stackblitz.com/edit/multidimensional-view?file=src%2Fapp%2Fmultidimensional-view%2Fm-dimensional-view.component.ts

[5] Waterplea: http://habr.com/ru/users/waterplea

[6] ng-polymorheus.: https://github.com/TinkoffCreditSystems/ng-polymorpheus

[7] Открыть Stackblitz: https://stackblitz.com/edit/multidimensional-view-polymorpheus-edition?file=src%2Fapp%2Fmultidimensional-view%2Fm-dimensional-view.component.ts

[8] Источник: https://habr.com/ru/post/525042/?utm_source=habrahabr&utm_medium=rss&utm_campaign=525042