company_banner

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

  • Tutorial

Я разрабатываю несколько 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. Да, изначальная проблема не очень сложна, но наше маленькое решение содержит множество идей, которые можно использовать при проектировании других компонентов.

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

Tinkoff
it’s Tinkoff — просто о сложном

Комментарии 9

    0
    Спасибо за статью, недавно решал подобную задачу для древовидных комментариев, до этого момента не знал, что у компонентов может быть рекурсия, все оказалось очень просто)
      0
      Спасибо за туториал!
        0

        Немного стало больно когда увидел тип массива в котором могут быть объекты и массивы, привычнее структура классических массивов с однотипными данными


        Например


        interface Node {
          id: number,
          name: string, 
          children: Node[]
        }

        Спасибо за статью

          0
          Да, тут как раз и интересный момент :)

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

          Когда человек с продуктовой разработки приходит делать гибкую библиотеку, то желание делать интерфейсы для данных остается (я и сам переходил), но по нашему опыту для них в очень мало реальных кейсов — практически все заменяется дженериками и хендлерами, избавляя пользователей библиотеки от кучи маппингов и возни с данными, а разработчиков от ситуации еженедельных «ох, там хотят новую фичу, пойдем расширять интерфейс»
            +3

            Node с children — это не про отсутствие дженериков. Никто не мешает сделать


            interface Node<T> {
              value: T
              children: Node<T>[]
            }

            И даже, если упороться, передать в компонент функцию-маппер, извлекающую дочерние элементы из ноды


            // html
            <tree-view [roots]="roots" [getChildren]="getChildren">
            // ts
            public getChildren = (node: Node) => node.children;

            Тут скорее про то, что в полиморфном массиве из статьи нет семантической связи между родительским и дочерними элементами.
            Например, директория с тремя файлами представляется внезапно не 1 элементом и не 4, а двумя:


            [
              {...dir element...},
              [ {...file 1...}, {...file 2...}, {...file 3...} ]
            ]   

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

              –1
              Да, не спорю, такое решение есть и тоже хорошее. Про семантическую связь я размышлял, когда это делал, интерфейс тут определенно выиграет с точки зрения кейса директории с папками. Мое решение скорее от идеи «вложенность вашего массива — вложенность вашего дерева», на мой взгляд, так закладывается меньше контекста

              Юз. кейсы могут быть самыми непредсказуемыми. Можно долго продумывать их, но рано или поздно кто-то придет с чем-то совсем необычным и тут самое обидное будет, если компонент не позволит развернуться, потому что заведемо был заточен под чуть более узкий кейс. Кстати, человек, который спросил меня об этом компоненте и благодаря которому эта статья и выросла, орудовал как раз массивом с массивами строк (я не к тому, что это правильно, но так люди тоже используют это дело :) )

              Энивей, оба решения хороши и решают эту историю. Думаю, тут мы к серебрянной пуле не придем, но хорошо, что такое мнение появилось и дополнило статью, спасибо!
          0
          А что на счет перфоманса? Особенно с этой рекурсией. Вложенность 5 по 5 потянет, например?
            0
            get isArray()

            Как вы это объясните? Ведь использование get функций в шаблоне, сказывается на производительности.
              0

              Если там onPush стратегия, то разницы нет, поле там или геттер

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое