Связь между родительским и детским компонентами с помощью Angular и Vanilla JS


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


Основные концепции

  • Определение типов с помощью интерфейсов (и почему)
  • Связь между компонентами с помощью @Input() и @Output()
  • Двустороннее связывание данных с помощью дочерних селекторов
  • Одностороннее связывание событий с помощью EventEmitter
  • Маркировка элементов DOM с помощью динамических родительских данных

Пропустить вперед

  • Краткое обновление: Данные и событие
  • Зачем определять типы с помощью интерфейсов?
    • Создание интерфейса
    • Добавление интерфейса к данным компонента
  • Размещение и подготовка родительского компонента
    • Сделать родительский компонент основным считывателем данных
  • Связывание данных между компонентами
  • Привязка событий для динамических функций
    • Цель: прокрутка до “секции”
    • Задача: Привязка элементов с динамическими данными
    • Привязка события
  • Прокрутка к динамически привязанным элементам
    • Динамические идентификаторы
    • Vanilla JS и scrollIntoView
  • Конечный результат

Краткая справка: Данные компонента и событие щелчка

В прошлой статье вы, возможно, помните, что мы добавили данные непосредственно в компонент (файл .ts), чтобы мы могли динамически привязать их к шаблону (файл .html).

Если вы пропустили это, сделайте форк этой ветки из моего репозитория.

FYI: я взял пример strings из учебника по системной инженерии, который сидел рядом со мной (да, я читаю учебники для развлечения).

export class SidebarComponent implements OnInit {
  showsSidebar = true;
  sections = [
    {
      sectionHeading: 'The Evolving State of SE',
      sectionTarget: 'theEvolvingStateOfSe',
      sectionContents: [
        {
          title: 'Definitions and Terms',
          target: 'definitionsAndTerms',
        },
        {
          title: 'Root Cause Analysis',
          target: 'rootCauseAnalysis',
        },
        {
          title: 'Industry and Government',
          target: 'industryAndGovernment',
        },
        {
          title: 'Engineering Education',
          target: 'engineeringEducation',
        },
        {
          title: 'Chapter Exercises',
          target: 'chapterExercises'
        }
      ]
    },
    { 
      // ... another section's data
    }
  ];

  // more component code
}
Вход в полноэкранный режим Выход из полноэкранного режима

Событие щелчка

Мы также создали событие щелчка, которое будет передавать данные target (называемые sectionTarget на первом уровне и target в массиве sectionContents) через консоль.


Зачем определять типы с помощью интерфейсов?

  • Случайное добавление неправильного типа данных (например, тип свойства, в конце которого добавлено s, что не соответствует оригинальному формату, например, “contents” против “content”) чрезвычайно сложно отлаживать.
  • Написание большого количества кода (без определенных типов данных) не сообщит нам о несоответствии данных между функцией компонента (или сервиса) и источником данных до момента выполнения.
  • Нам нужен порядок, когда речь идет о диком диком западе JavaScript.

Создание интерфейса

Давайте создадим интерфейс, определяющий данные, которые мы рассматривали в предыдущем разделе. Создайте файл, выполнив команду $ ng g interface interfaces/system-concepts --type=interface.

  • Флаг --type=interface просто добавляет расширение -.interface.ts к имени файла, чтобы он был аккуратным и красивым.

Сначала мы добавим основную форму данных. Обратите внимание, что я добавил I в начало имени интерфейса, чтобы отличить его от классов и воспользоваться преимуществами плагинов VSCode IntelliSense для TS:

// system-concepts.interface.ts

export interface ISystemConcepts {
  sectionHeading?: string;
  sectionTarget?: string;
  sectionContents?: [];
}
Вход в полноэкранный режим Выход из полноэкранного режима
  • Добавление вопросительных знаков делает свойство необязательным, чтобы не возникало ошибок, если мы не упомянем это свойство при упоминании объекта.

Поскольку свойство sectionContents содержит массив объектов, которые имеют свои собственные определенные свойства (для title и target), мы добавим еще один интерфейс перед этим, чтобы мы могли ссылаться на него с помощью этого массива:

// system-concepts.interface.ts

export interface ISectionContents {
  title?: string;
  target?: string;
}
export interface ISystemConcepts {
  sectionHeading?: string;
  sectionTarget?: string;
  sectionContents?: ISectionContents[]; // We refer to ISectionContents here
}
Вход в полноэкранный режим Выход из полноэкранного режима

Добавление интерфейса к данным компонента

Теперь, вернувшись в компонент, мы можем импортировать интерфейс и добавить его в массив sections. Убедитесь, что после него стоит [], так как он определяет массив, а не один объект (секцию):

// sidebar.component.ts

import { ISystemConcepts } from './../../interfaces/system-concepts.interface';

export class SidebarComponent implements OnInit {
  showsSidebar = true;

  // Here is where we add the imported interface
  sections: ISystemConcepts[] = [
    {
      sectionHeading: 'The Evolving State of SE',
      sectionTarget: 'theEvolvingStateOfSe',
      sectionContents: [
        {
          title: 'Definitions and Terms',
          target: 'definitionsAndTerms',

  // More component code
}
Вход в полноэкранный режим Выход из полноэкранного режима
  • Если вы запустите $ ng serve, вы увидите, что ошибок нет.
  • Пока приложение обслуживается локально, попробуйте изменить интерфейс и посмотреть, какие ошибки появляются.

Размещение родительского компонента и подготовка

  • Создайте родительский компонент под названием Article, выполнив в терминале команду $ ng g c views/article.
  • В файле app.component.html удалите селектор <app-sidebar> и замените его новым селектором <app-article></app-article.
  • Если вы запустите $ ng serve, вы должны увидеть “article works!” в вашем браузере.
<!-- app.component.html -->

<app-article></app-article>
Вход в полноэкранный режим Выйдите из полноэкранного режима
  • Теперь удалите содержимое компонента Article и замените его дочерним селектором <app-sidebar></app-sidebar>.

Вы увидите, что он ведет себя точно так же, как и раньше.

Превращение родительского компонента в основной считыватель данных

Скопируйте данные из массива sections дочернего компонента и вставьте их в новое свойство sections, созданное в родительском компоненте.

Если вы удалите данные из дочернего компонента, оставив пустое свойство sections: ISystemConcepts[] = [];, локально обслуживаемое приложение все еще будет показывать саму боковую панель, но ни один из данных не будет отображаться.


Связывание данных между компонентами

Чтобы отобразить данные, которые теперь определены в родительском файле article.component.ts, мы должны создать дверь, которая переводит одно свойство в другое (поскольку им не нужны одинаковые имена). Для этого мы импортируем декоратор @Input() (“дверь”) из основного пакета Angular в дочерний файл sidebar.component.ts и префиксируем им свойство sections этого дочернего компонента следующим образом:

// sidebar.component.ts

// We import `Input` from angular core ⤵️
import { Component, Input, OnInit } from '@angular/core';

// ... other code 

export class SidebarComponent implements OnInit {
  showsSidebar = true;
  @Input() sections: ISystemConcepts[] = [];

  // ... more code
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Эта созданная нами “дверь” позволяет нам теперь привязать данные к селектору <app-sidebar></app-sidebar>, который находится внутри родительского компонента. В Angular все, что имеет [], означает двустороннее связывание данных, а () – событий (об этом позже).

<!--article.component.html-->

<app-sidebar [sections]>
</app-sidebar>
Вход в полноэкранный режим Выйти из полноэкранного режима

Однако это выдаст ошибку, так как мы не определили родительских свойств для свойства [sections], в которое оно должно переводиться. Поскольку свойство data родительского компонента также называется sections, мы можем добавить его сюда:

<!--article.component.html-->
<!--This means [childDataProperty]="parentDataProperty"-->
<app-sidebar [sections]="sections">
</app-sidebar>
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь, если вы обслужите приложение, вы увидите, что данные вернулись.

Прежде чем двигаться дальше, не забудьте добавить интерфейс ISystemConcepts[] к свойству sections родительского компонента, как это было в дочернем компоненте, и создать свойство showsTableOfContents = false; в родительском компоненте, к которому дочерний компонент должен получить доступ так же, как описано выше:

// article.component.ts

export class ArticleComponent implements OnInit {
  showsTableOfContents = false;
}
Вход в полноэкранный режим Выйти из полноэкранного режима
// sidebar.component.ts

export class SidebarComponent implements OnInit {
  @Input() showsSidebar = true;
}
Войти в полноэкранный режим Выйти из полноэкранного режима
<!--article.component.html-->
<app-sidebar
  [showsSidebar]="showsTableOfContents"
  [sections]="sections">
</app-sidebar>
Войти в полноэкранный режим Выход из полноэкранного режима

Привязка событий для динамических функций

Теперь, когда мы рассмотрели привязку между компонентами (используя [] в элементе selector), мы можем аналогично привязать события, используя ().

Цель: прокрутка к “секции”

Обычно вы можете использовать декоратор Angular @ViewChild для определения элемента в DOM и выполнения действий над ним. В качестве примера, если в вашем шаблоне есть элемент с привязанным ID, как показано ниже:

<div #thisDiv></div>
Войти в полноэкранный режим Выйти из полноэкранного режима

Вы можете идентифицировать его в компоненте следующим образом:

@ViewChild('thisDiv') divElement!: ElementRef;
Войти в полноэкранный режим Выход из полноэкранного режима

Затем вы можете выполнять функции, например, красивый scrollIntoView():

onSomeEvent(event) {
  if (event) { 
    this.divElement.nativeElement.scrollIntoView({ behavior: 'smooth'}); 
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Это и есть наш желаемый сценарий использования:


Задача: Привязка элементов с динамическими данными

Поскольку мы хотим, чтобы дочерний компонент оставался “немым”, и чтобы любые данные, созданные в нем, поступали от родителя, создание статических идентификаторов для элементов, которые мы хотим прокручивать, не будет соответствовать данным, поступающим из дочернего события. В идеале мы хотим, чтобы приложение определяло идентификаторы якорей с динамическими родительскими данными; и прокручивало страницу именно к этим якорям, если они соответствуют родительским данным, поступающим через событие щелчка.

Привязка события

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

  • Импортируйте Output и EventEmitter из основного пакета в компонент sidebar.
  • Прикрепите к метке [я называю ее “onSelection”] @Output() и создайте эмиттер события типа string.
// sidebar.component.ts

// Import from core package like this ⤵️
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';

// ... more code

export class SidebarComponent implements OnInit {
  @Input() showsSidebar = true;
  @Input() sections: ISystemConcepts[] = [];

  // Here's where we label a new EventEmitter with `onSelection` ⤵️
  @Output() onSelection = new EventEmitter<string>();
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь, вместо того, чтобы функция дочернего onTargetContentClick() просто отправляла целевую строку в консоль, мы можем использовать эмиттер для передачи строки в родительский (где другой параллельный метод перехватит ее):

// sidebar.component.ts

onTargetContentClick(targetString: string | undefined, event: Event) {
  if (event) {
    this.onSelection.emit(targetString);
    this.closeSidebar();
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Аналогично тому, как мы привязывали данные к селектору для заполнения шаблона, мы можем привязать события с помощью () вместо [], чтобы они совпадали с событиями в компоненте:

<!--article.component.html-->

<app-sidebar
  (onSelection)=""
  [showsSidebar]="showsTableOfContents"
  [sections]="sections">
</app-sidebar>
Вход в полноэкранный режим Выход из полноэкранного режима

И, как и раньше, он вернет ошибку, если мы не создадим параллельное событие, в которое он будет передавать данные:

// article.component.ts

onSelect(event: Event) {
  console.log(event);
}
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, теперь наш селектор завершен:

<!--article.component.html-->

<app-sidebar
  (onSelection)="onSelect($event)"
  [showsSidebar]="showsTableOfContents"
  [sections]="sections">
</app-sidebar>
Вход в полноэкранный режим Выход из полноэкранного режима

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

В файле article.component.html я создал некоторую структуру под селектором <app-sidebar> для заполнения содержимого статьи.

Примечание: классы mt-5 и mb-3 взяты из моего пакета @riapacheco/yutes, который использует SCSS аргументы для классов, изменяющих margin- и padding (например, mt-5 переводится как margin-top: 5rem).

<!--article.component.html -->

<!--Child Component-->
<app-sidebar
  [showsSidebar]="showsTableOfContents"
  [sections]="sections"
  (onSelection)="onSelect($event)">
</app-sidebar>

<div class="article-view">

  <!--Section Block -->
  <div
    *ngFor="let section of sections"
    class="section mt-5 mb-3">
    <!--Section Title -->
    <h1>
      {{ section.sectionHeading }}
    </h1>

    <!--Content Block-->
    <div
      *ngFor="let content of section.sectionContents"
      class="section-items mt-3">
      <h3>
        {{ content.title }}
      </h3>

      <!--Filler text that would have otherwise come from the data source but I was lazy -->
      <p>
        Lorem ipsum dolor sit, amet consectetur adipisicing elit. Maxime odio molestiae doloremque nulla facilis. Pariatur officiis repudiandae ab vero ratione? Sint illum rem error aliquam consectetur incidunt quo laborum. Neque!
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Odit repellendus fugiat commodi impedit incidunt mollitia sed non dicta qui velit earum quos eligendi, eveniet porro labore dolore, fugit inventore alias.
      </p>
    </div>

  </div>
</div>
Вход в полноэкранный режим Выход из полноэкранного режима

С помощью небольшого SCSS:

:host {
  width: 100%;
}

.article-view {
  width: 65%;
  margin: auto;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Динамические идентификаторы

В идеале мы хотим, чтобы идентификаторы якорей совпадали с целевыми строками, передаваемыми через событие click. Поэтому для самого заголовка раздела (к которому мы хотим прокрутить страницу) мы добавляем следующий ID (который соответствует переданным данным). То же самое мы делаем и для блока содержимого!

<div class="article-view">

  <!--Section Block -->
  <div
    *ngFor="let section of sections"
    class="section mt-5 mb-3">

    <!--**ADDED THIS ID⤵️** -->
    <h1 id="{{section.sectionTarget}}">
      {{ section.sectionHeading }}
    </h1>

    <!--Content Block-->
    <div
      *ngFor="let content of section.sectionContents"
      class="section-items mt-3">
      <!--**ADDED THIS ID ⤵️ **-->
      <h3 id="{{content.target}}">
        {{ content.title }}
      </h3>
      <p>
        Lorem ipsum dolor sit, amet consectetur adipisicing elit. Maxime odio molestiae doloremque nulla facilis. Pariatur officiis repudiandae ab vero ratione? Sint illum rem error aliquam consectetur incidunt quo laborum. Neque!
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Odit repellendus fugiat commodi impedit incidunt mollitia sed non dicta qui velit earum quos eligendi, eveniet porro labore dolore, fugit inventore alias.
      </p>
    </div>
Войти в полноэкранный режим Выход из полноэкранного режима

Vanilla JS и scrollIntoView

Теперь в родительском компоненте мы можем использовать немного Vanilla JS, чтобы взять этот ID и прикрепить его к const, которая будет действовать как якорь для прокрутки:

onSelect(targetString: string) {
  const targetLocation = document.getElementById(targetString);
  targetLocation?.scrollIntoView({ behavior: 'smooth' });
}
Вход в полноэкранный режим Выход из полноэкранного режима

Конечный результат

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

Ri

Оцените статью
Procodings.ru
Добавить комментарий