В этом посте используется код, взятый из моей прошлой статьи (в которой создавалось пользовательское слайд-меню с динамическими данными), код которого вы можете взять из этой ветки. В качестве отступления: это длинная статья, потому что именно она была мне нужна, когда я начинал кодить с Angular.
- Основные концепции
- Пропустить вперед
- Краткая справка: Данные компонента и событие щелчка
- Событие щелчка
- Зачем определять типы с помощью интерфейсов?
- Создание интерфейса
- Добавление интерфейса к данным компонента
- Размещение родительского компонента и подготовка
- Превращение родительского компонента в основной считыватель данных
- Связывание данных между компонентами
- Привязка событий для динамических функций
- Цель: прокрутка к “секции”
- Задача: Привязка элементов с динамическими данными
- Привязка события
- Прокрутка к динамически привязанным элементам
- Динамические идентификаторы
- Vanilla JS и scrollIntoView
- Конечный результат
Основные концепции
- Определение типов с помощью интерфейсов (и почему)
- Связь между компонентами с помощью
@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