Сделать модальное окно используя директиву angular

Обновлено: 27.04.2024

Modal dialogs are great for when you need to restrict a user to a particular action before they can return to the normal use of the application. For instance, when you try to close Microsoft Word without saving, it shows a dialog where you choose to resume your work (the “Cancel” option) or close the program (by either saving or not saving your work). The modal dialog appears because you can’t continue using Word as long as you don’t choose one of the options available. Until then, you can only stare at the dialog.

But let’s look at another example: a logout operation. Say, the user clicks the logout button in your application but, instead of logging them out right away, you want to confirm that’s what the user is trying to do. After all, they might have clicked the button by mistake. Incidentally, that is what I will be showing you today, how to create a logout modal dialog component in Angular (8). Once the user clicks the logout button, a modal pops up with only two options for the user: “Logout”, which confirms the user’s intention, and “Go back”, which closes the modal and returns the user to the normal use of the application.

Before we get into the code, I want to preface it by clarifying that this article is written under the assumption that you are already familiar with Angular. The resulting application itself is very simple so if you have a basic understanding of the framework you should be fine. To use the modal we will only need two things: to create a button in the root component ( app-root ) and create a new component, which will be the modal itself.

First up, we need to create a new Angular project through the command line:

The first command creates a new project called modal-component. When prompted for routing, you can choose whatever you prefer as it doesn’t affect this example. For the stylesheet format, choose CSS. The second command moves us inside the folder created for the project. The third command, code . , is a handy shortcut to open Visual Studio Code in the current working directory, that is, the project’s folder. This is just a nice shortcut if you’re using Visual Studio Code as your editor.

Now that we have our project open in the code editor, there’s one thing we need to install in the project: Angular Material. If you’re not familiar, it includes a bunch of pre-made components ready to be added to a project. In this example, we’ll be making use of its MatButton for, you guessed it, the buttons and MatDialog to create the modal dialog.

So, to install the library, return to the command line and enter

ng add @angular/material

While installing Material, you will be prompted for three questions: theme, set up of HammerJS and set up of Material animations. You can choose whichever theme you prefer as it doesn’t impact our functionality, but please choose to set Material animations or it will break the application. For HammerJS, don’t set it up. If you have any doubts regarding the installation of Angular Material, please refer to the official Getting Started page.

Now that Angular Material is set, we just need to do one last thing that’s not actual code: create the modal component. I mean, not the finished version of course, but the component which will be the modal. For that, enter

ng generate component modal

in the command line. And so this command creates a new component in the application called modal (or rather, app-modal ). Now we can finally start writing code!

The first thing we’ll do is add a new key the @NgModule in the app.module.ts file, as well as some imports, as shown in the following code gist:

According to the documentation, this is the purpose of entryComponents : “The set of components to compile when this NgModule is defined, so that they can be dynamically loaded into the view”. In practice, if this was not set, then the MatDialog would not be loaded properly (in our case the MatDialog will make use of ModalComponent , the component we created earlier). For the imports, well, we need to import the Angular Material modules we will be using.

Now that all the setup is out of the we can finally focus on writing HTML, CSS and TypeScript. Oh and by the way, you can run the project by entering

in the command line. This opens the project in your browser and will rebuild the project whenever you modify it, so you can check it out throughout the article to see how it changes.

Before touching any component, let’s set the global styles right away in the styles.css file:

With the global styles defined, we can move on to the individual components. Let’s start with the app-root component, the “home page” of the application, which will look like this:

app-root component

That’s it, just a lonely logout button in the center of the screen. Clicking it will trigger the logout modal component, that is, before actually logging out the user, we first ask them for confirmation. To reiterate, when the modal appears, the user can either confirm the logout or return to the application.

So, let’s start with the HTML:

As you can see, it’s simple. A main element holds the whole component, which contains just one other element: the logout button. But due notice the mat-raised-button attribute. That’s what transforms the normal HTML button into an Angular Material button, including its associated styles and animations. Though, we also give it an id to alter slightly its styles:

Pretty simple as well, with only the text color, background color and borders of the button changed. Nothing else regarding the CSS for app-root . Now there’s one last thing we need to change for this component: its TypeScript. Our objective is to create the function which is called when the logout button is clicked. And what does the function actually do? Open the modal component, that is, a MatDialog:

Ok, now let me explain what happens in this file. First notice that there are three new imports: MatDialog and MatDialogConfig (both from Angular Material) and ModalComponent , the class of the component we have created. In the class constructor of app-root , we inject a dependency: the MatDialog which we have been talking about since the beginning. If we didn’t inject it, then we wouldn’t be able to open a dialog.

Then, we do two things inside the openModal() function: configure the dialog to be opened with a MatDialogConfig object and actually open the modal. We make use of four configuration options:

  • disableClose : if the user clicks outside the modal, it doesn’t close;
  • id : an id for which the dialog will be known as (useful for the CSS);
  • height : height of the dialog;
  • width : width of the dialog.

In the last line of the function, we open the modal dialog by calling the open() method of the MatDialog object injected in the constructor. To open a dialog, we need to specify which will be the component rendered inside along with a corresponding configuration object.

And that’s it for the app-root component, now when you click the logout button it will open an empty modal dialog!

Now we need to look at the app-modal component, the component we created. Again, we’ll need to change its HTML, CSS and TypeScript, but first, let me show you what the modal will look like in the end:

The modal dialog

Again, we are starting with the HTML:

We have a single div element that wraps all the content of the modal, and then that content is separated in three parts:

  1. An header (the “title” shown in the modal);
  2. A brief description regarding the action the user is trying to do (in this case I wrote a joke to try keep them away from logging out);
  3. A footer, which is made up of the two buttons: one to confirm the action (“Logout”) and one to cancel the action (“Go back”).

Fairly simple stuff as well, and notice how we’ve used MatButtons once again for the modal.

Now, the CSS has a trick behind it because of Angular Material.

What is tricky about the modal’s CSS is styling the MatDialog Angular Material component. If you look at the stylesheet above, there’s no ruleset for the design of the dialog itself. modal-content-wrapper is the id of the HTML element that wraps all of the content inside the modal, not the actual modal.

Enter the MatDialog configuration object. Remember when we wrote the configurations for the dialog to be opened in app-root ? We passed it a custom id, modal-component , which we can now use to style the dialog. But we can’t do that in the CSS of app-modal as that is in fact the content of the dialog, not the dialog itself. Hence that ruleset I asked you to ignore back in styles.css . While this file contains the global CSS rulesets, we have no other choice but to set the styles for this particular MatDialog here.

Unlike the buttons that are “transformed” into MatButtons by adding the mat-raised-button attribute, which still allow us to modify the button’s styles in the CSS of the components where they are used, the dialog works differently as it is almost in a limbo between app-root (where it is opened) and app-modal (the component it contains). In short, we style the MatDialog in the global styles.css file because we can’t access it in neither of the other component’s stylesheets.

But, as we are dealing with global styles, we need to be careful with the specificity of our ruleset so it doesn’t affect other dialogs we might have in the application. Thus, let’s look at the styles.css code gist once again.

Look at the last ruleset, that’s the one responsible for the styles of our MatDialog . We use a CSS selector of tag name ( mat-dialog-container ), which catches any MatDialog in the application, and then restrict it only to those that have an id of modal-component , in other words, we are selecting only the logout modal we are working with. Angular Material components can be tricky to style so there’s a clear trade-off between their usability and the needs of each project.

With the CSS out of the way, there’s just one last thing to change, which is the app-modal ‘s TypeScript. Here we need to do three things:

  • Inject a MatDialogRef object in the component to have access to the dialog’s methods (namely, to close the dialog);
  • Create the actionFunction() function for the “Logout” button;
  • Create the closeModal() function for the “Go back” button.

Whichever button the user clicks, for the sake of this demo, they will be returned to the “home page” of the application. In other words, whichever button the user clicks, the modal will close and show the big “Logout” button once more.

So, what’s the difference between the “Logout” button (the one inside the modal), and the “Go back” button? The first displays an alert box before closing the modal to let the user know they have logged out. The second button simply closes the modal.

Now let’s step back from the demo for a minute. Once the user clicks the “Logout” button and the application calls the actionFunction() function, that’s where your logic to execute the desired action comes in. In the case of a real logout, that’s where you’d have the code for wrapping up the user’s session. The other function, the function that cancels the operation the user initiated, simply closes the dialog and lets them return to the normal use of the application.

And with this we also have reached the end of the explanation. If you’ve followed along with the code, you now have a working modal dialog component which was dead simple to implement. Of course, in your real application you’ll be including services to interact with the server, and a modal can be applied wherever you need the user to confirm their action before actually executing it, such as when deleting an item.

If you have the project running, open your browser at localhost:4200 to see its finalized state. If it is not running, type ng serve --open in the command line and it will open a new tab in your browser automatically.

To end this article, I leave here a link to the complete code in a GitHub repository.

All constructive feedback is welcome as this is the longest technical article I’ve written and there’s probably plenty of ways in which I can improve. Have a great day :)

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

В данной статье я хочу рассказать о двух способах работы с модальными окнами:

  1. «Обычное» добавление компонентов
  2. Динамическое добавление компонентов

Сразу скажу что есть еще несколько способов добавления модальных окон, такие как добавление в стиле bootstrap (похоже на 1 способ, только в 1 способе модальное окно вынесли в отдельный компонент), так же никто не мешает использовать typescript чтоб напрямую в dom добавить какое-либо модальное окно, хоть этот способ мне и не нравится, но он существует.

Во всех примерах я буду опускать css и html в тех местах, где это не будет влиять на логику. Ссылка на репозиторий с исходным кодом будет приведена в конце статьи.

«Обычное» добавление компонентов

Создадим, для начала, компонент, который будет простым диалоговым окном для подтверждения:


Мы создали компонент с входными значениями header и description и в ответ получаем одно значение boolean переменной с результатом работы окна. Если будет необходимость вернуть какие-либо значения с модального окна в компонент, который его вызвал, можно создать класс для представления результата выполнения:


И возвращать данные через него.

Теперь для использованием диалогового окна нам необходимо добавить его в какой-либо модуль. Тут есть несколько способов:

  1. Объединить модальные окна в один модуль
  2. Добавить в тот модуль, где он будет использоваться


ModalDialogComponent — это собственно сам компонент диалогового окна.
SimpleModalPageComponent — это компонент (далее компоненты, которые имеют в названии слово Page буду называть страницы), где будем отображать диалоговое окно.

Теперь добавим модальное окно в template страницы:


Управлять видимостью модального окна будем через ngIf. При желании эту логику можно перенести внутрь диалогового окна, либо объединить кнопку для отображения окна с самим окном в один компонент.

Код страницы для отображения диалогового окна:

Для начала давайте создадим сервис, который будет отвечать за доступ к нотификации и модель нотификации:


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

Теперь определим компонент нотификации:


В конструкторе мы подписываемся на добавление нотификации и задаем автозакрытие нотификации по истечении 5 секунд.

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

Для использования добавляем в template страницы (SimpleModalPageComponent)


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


Не забываем добавлять компоненты и сервисы в модули.

Динамическое добавление компонентов

Думаю нужно сразу сказать почему я решил не создавать очередной модный и молодеждый пакет в npm и просто описать подход для создания модальных окон. Причина в том, что создать универсальный пакет сложно и все равно он будет подходить малому количеству пользователей (вспоминается история про то, что усредненное и универсальное решение рискует не подойти никому).

А теперь перейдем к тому из-за чего я затеял написание этой статьи. Добавить компонент динамически «из воздуха» в Angular не получится (скорее всего можно, но это сложно и рискует часто ломаться с обновлениями). Поэтому все должно быть где-то определено явно (на мой взгляд это хорошо).

Для динамического добавления компонентов должно быть известно куда мы их планируем добавлять. Для этого нам необходимо получить объект ViewContainerRef.

Получить его можно следующим способом:


Таким образом мы получаем объект ViewContainerRef. Как вы могли заметить помимо этого объекта мы используем NotificationManager и инициализируем его значением ViewContainerRef.

NotificationManager предназначен для работы с модальными окнами и нотификацией. Далее мы определим данный класс:


В предыдущем листинге я намеренно пропустил некоторые части кода, чтоб ввести их после некоторых объяснений. До того как добавлять компонент куда-либо нам необходимо его вначале создать. Методы createComponent и createNotificationWithData это внутренние методы класса и предназначены для создания компонента и инициализации его какими-либо данными соответственно.

Рассмотрим метод createComponent:


На вход мы подаем класс компонента, далее используем метод fromResolvedProviders из ReflectiveInjector для получения объекта ReflectiveInjector. Далее через ComponentFactoryResolver создаем фабрику для компонента и собственно создаем компонент.

Метод createNotificationWithData создает компонент и добавляет к нему данные:


После того, как мы разобрали методы для создания компонентов необходимо рассмотреть как эти объекты использовать. Добавим в NotificationManager метод для отображения модального окна:


ModalDialogBase это базовый класс для модели. Спрячу его под спойлер вместе с ModalDialogResult

Метод showDialog принимает класс компонента, данные для его инициализации и возвращает Subject для получения результата выполнения модального окна.

Для добавления компонента используем метод insert у notificationBlock


Данный метод добавляет компонент и после этого он будет отображен пользователю. Через dialog.instance мы получаем объект компонента и можем обращается к его методам и полям. Например мы можем подписаться на получение результата и удалить данное диалоговое окно из dom после закрытия:


Если вызвать у объекта ComponentRef метод destroy компонент удалится не только из dom, но и из notificationBlock, что очень удобно.

Под спойлером код модального окна:

Далее давайте рассмотрим создание нотификации. Мы может добавлять ее так же, как и модальные окна, но на мой взгляд их лучше выделить в отдельное место, поэтому давайте создадим компонент NotificationPanelComponent:


В методе showNotification мы добавляем компонент для отображения, подписываемся на событие закрытия окна и задаем таймаут для закрытия окна. Для простоты закрытие реализовано через метод close у компонента нотификации.

Все нотификации должны наследоваться от класса NotificationBase.

И приведем код самого компонента нотификации:


Для использования нотификации необходимо добавить метод showToast и NotificationPanelComponent в NotificationManager:


Если попробовать сделать все, что было приведено до этого, то ничего работать не будет, ведь есть нюанс, а именно в том, как все это объединять в модули. Например если вы попробуете найти информацию где-либо, кроме оф. документации по NgModule, то рискуете не увидеть информацию про такую вещь, как entryComponents.

В оф. документации написано:


То есть если мы хотим создавать компоненты через ComponentFactory и ComponentFactoryResolver нам необходимо указывать наши компоненты помимо declarations еще и в entryComponents.


По поводу объединения в модули. Я считаю неплохим вариантов объединять схожий функционал модальных окон в модули и импортировать их в NotificationModule.

Теперь для использования модальных окон необходимо только указать NotificationModule в imports и можно использовать.


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

image

Любой создаваемый проект не обходится без динамического создания элементов. Рано или поздно вам понадобится либо создать tooltip для элемента, показать модальное окно, или вовсе сформировать некоторые блоки динамически подгружая их с сервера. При решении таких задач я зачастую определяю зрелость фреймворка, который использую: насколько просто я могу в нем создавать динамический контент, и какие возможности он мне для этого предлагает. В этой статье мы поговорим о динамическом создании контента в новом Angular и рассмотрим различные подходы, которые он нам предоставляет.

Прежде чем перейти к созданию контента, нам необходимо рассмотреть ряд абстракций, которые есть в Angular — что они собой представляют и для чего используются. Так как ангуляр разработан как решение, которое может работать на различных платформах — в браузере, на мобильном устройстве и на сервере, — то прямая работа с DOM в нем не очень приветствуется, хотя и возможна. Например, следующий пример будет хорошо работать в браузере, но может перестать работать, если вы используете Web Worker или ваш код работает на мобильном устройстве.

Вместо прямой работы с DOM-элементом Angular предоставляет нам следующие абстракции — Renderer, TemplateRef, ElementRef и ViewContainerRef. Давайте рассмотрим их по порядку и посмотрим, как с помощью них мы сможем создавать динамический контент.

Renderer

Я буду говорить о Renderer2 (далее просто Renderer), так как первая версия уже помечена как deprecated. Renderer используется в основном для манипуляций над уже существующими элементами, например для изменения стилей элемента, атрибутов и параметров элемента. Наиболее часто его использование можно встретить при создании директив. Но он также позволяет создавать новые элементы и вставлять их в DOM, что подходит для нашей задачи.

Давайте рассмотрим методы, которые нам предоставляет Renderer:

    createElement(name: string, namespace?: string): any
    Позволяет создать элемент DOM и опционально указать для него пространство имен. Пространство имен используется, например, для вставки SVG-элементов. Элемент после создания не будет отображаться в DOM пока мы его туда не добавим.

appendChild(parent: any, newChild: any): void
insertBefore(parent: any, newChild: any, refChild: any): void
removeChild(parent: any, oldChild: any): void

Используются для вставки/удаления созданных или существующих элементов в DOM.

setAttribute(el: any, name: string, value: string, namespace?: string): void
removeAttribute(el: any, name: string, namespace?: string): void
setProperty(el: any, name: string, value: any): void

Используются для изменения атрибутов или параметров DOM-элемента, например, для установки значения checkbox.

Это далеко не все, что предоставляет Renderer, но, даже используя указанные методы уже можно динамически создавать и изменять элементы DOM.

Но прежде чем воспользоваться возможностями Renderer, нам необходимо рассмотреть еще один момент — как в ангуляр находить DOM элементы-контейнеры, в которые мы будем добавлять динамический контент. Для этого у нас есть два способа — воспользоваться Dependency Injector или использовать ряд декораторов — @ViewChild/@ViewChildren и>@ViewChildren/@ContentChildren. Давайте рассмотрим оба варианта и начнем с самого простого.

Доступ к элементу через DI

Данный способ довольно часто используется при создании собственных директив. Для того чтобы получить доступ к элементу (контейнеру) директивы, надо добавить в конструктор директивы приватную переменную с типом ElementRef. Давайте рассмотрим, как будет выглядеть добавление элементов с помощью сервиса Renderer в данном случае:

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


@ViewChildren/@ContentChildren

Для поиска элементов в DOM ангуляр предоставляет ряд декораторов — @ViewChild/@ViewChildren и>@ViewChildren/@ContentChildren. Директива @ViewChild отличается от @ViewChildren тем, что первая всегда вернет вам только один элемент, в то время как вторая позволяет вам находить несколько элементов, возвращая вам объект типа QueryList.

QueryList представляет из себя итерируемый интерфейс, а также позволяет подписываться на изменение элементов через механизм Observable. Декораторы @ViewChildren и @ContentChildren необходимо использовать в обработчике ngAfterViewInit жизненного цикла компонента, так как раньше QuryList просто будет не определен.

Пара директив>@ViewChildren/@ContentChildren ведет себя аналогичным образом и отличается от связки @ViewChild/@ViewChildren только тем, что>@ViewChildren ищет элементы просто в DOM-дереве, в то время как @ViewChild ищет элементы в ShadowDom. В данной статье для простоты мы не будет рассматривать связку>@ViewChildren/@ContentChildren, а также ограничимся только @ViewChild-декоратором, так как не будем использовать несколько элементов. Для поиска элементов мы воспользуемся следующим синтаксисом @ViewChild:

  • query params – элемент который ищем. Может быть, как имя шаблона, html элемент или компонент/директива.
  • descendants – определяет искать элемент только среди прямых потомков или смотреть глубже.
  • read — указание типа возвращаемого элемента. Обычно указание данного параметра не является необходимым, так как ангуляр довольно сообразителен и, если вы ищете шаблон, он вернет вам TemplateRef, если вы ищете html элемент, ангуляр вернет вам ElementRef. Но в некоторых случая, например, когда вам надо получить ViewContainerRef, вам придётся указать тип возвращаемого элемента.

При поиске элементов указанные декораторы возвращают переменную типа ElementRef — верхнеуровневую абстракцию, которая содержит в себе ссылку на «нативный» DOM-элемент:

Итак, давайте посмотрим, как нам найти элемент в компоненте и, используя Renderer, изменить его содержимое:

Как и в примере выше, мы создаем кнопку с заданным текстом и добавляем ее в DOM. Только на этот раз мы вставляем кнопку в нужный нам контейнер внутри компонента. Данный подход слишком низкоуровневый и используется довольно редко, поэтому пойдем дальше и рассмотрим, что же еще предоставляет нам Angular.

TemplateRef

Идея использования шаблонов для вставки новых элементов не нова и давно используется JS-разработчиками. При использовании template тега из HTML5 браузер создаст DOM-дерево для содержимого тега, но не будет вставлять его в DOM. Вот пример использования тега template в классическом, «нативном» JS:

Ангуляр предоставляет свою нотацию описания шаблонов, а также позволяет манипулировать шаблоном и его содержимым. С этой абстракцией вы могли познакомиться, если создавали свои собственные структурные директивы наподобие ngIf и ngFor. Для доступа к шаблону мы воспользуемся типом TemplateRef — это ссылка на элемент ng-template в вашем компоненте или директиве. У вас есть два способа получить доступ к шаблону — используя тег ng-template и Dependency Injection или используя поиск элементов через Query-декораторы, о которых мы рассказывали выше. Давайте рассмотрим оба способа и начнем с самого простого:

В примере выше мы использовали Dependency Injection, чтобы получить доступ к шаблону нашей директивы и динамически вставили ее в DOM, используя ViewContainerRef. О ViewContainerRef мы поговорим позднее, пока не обращайте на него внимания, а сейчас давайте рассмотрим, как мы можем динамически создавать DOM-элементы, используя декоратор @ViewChild:

В данном примере с помощью декоратора @ViewChild мы находим нужный нам шаблон в виде переменной типа TemplateRef и вставляем его в DOM аналогичным способом, как и в примере с конструктором.

Кстати, ангуляр удалит тег ng-template и его содержимое из DOM и вместо него разместит комментарий -->. Данный способ позволяет создавать простой динамический контент на основе готовых шаблонов. Но пойдем дальше и посмотрим, что еще нам доступно.

ViewContainerRef

Настало время поговорить о ViewContainerRef, который мы неоднократно видели в примерах выше. ViewContainerRef представляет собой ссылку на контейнер компонента или директивы и, кроме доступа к элементу, позволяет создавать два типа View — Host Views (View элементы, создаваемые на основе компонентов) и Embedded Views (View элементы, создаваемые на основе готовых шаблонов). Все создаваемые элементы имеют базовый тип View, который является основным строительным блоком для Angular приложений и представляет собой сгруппированные DOM-элементы, с которыми ангуляр работает как с единым целым и позволяет привязывать эту группу к Change Detection механизму. ViewContainerRef содержит в себе довольно много методов, давайте их рассмотрим:

createEmbeddedView(templateRef: TemplateRef, context?: C, index?: number): EmbeddedViewRef
Этим методом мы пользовались в наших примерах. Он позволяет создавать новые View-элементы на основе готовых шаблонов и вставляет результат в DOM-контейнер. В качестве параметров можно также передать контекст, данные из которого можно использовать в шаблоне, и индекс, по которому можно разместить создаваемый элемент.

createComponent(componentFactory: ComponentFactory, index?: number, injector?: Injector, projectableNodes?: any[][], ngModule?: NgModuleRef): ComponentRef
Создает View элемент на основе экземпляра компонента и вставляет его в DOM, возвращая нам указатель на созданный компонент. Для создания элемента необходимо сначала получить фабрику компонента и инжектор.

clear(): void
Удаляет все View элементы в контейнере

insert(viewRef: ViewRef, index?: number): ViewRef
Вставляет View-элемент, в заданную позицию контейнера

remove(index?: number): void
Удаляет View-элемент по указанному индексу. Если индекс не задан, будет удален последний View-элемент.

Как работает createEmbeddedView мы видели уже в примерах выше, давайте теперь рассмотрим, как создавать View элементы, используя метод createComponent. Этот метод позволяет динамически создавать элементы на основе готовых компонентов. Но для начала нам нужно научиться находить фабрику нужного нам компонента и поможет нам в этом ComponentFactoryResolver. Я не буду тут описывать весь код создания компонента целиком, а сделаю ряд допущений.

Во-первых, предположим, что у нас есть в проекте компонент Popover, который выглядит, например, так:

и он добавлен в атрибут entryComponents нашего модуля. Последнее, кстати, важно, так как без добавления компонента в entryComponents ничего работать не будет, ангуляр просто не узнает о компоненте, потому что не встретит его в шаблонах.

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

То есть наша директива получает на вход три параметра – ссылку на шаблон типа TemplateRef, значение заголовка и позицию, где надо показать popover.

Исходя из указанных предположений, код нашей директивы, показывающей popover, будет выглядеть следующим образом:

Как вы видите, здесь есть два обработчика событий мышки на компоненте – один показывающий компонент и один удаляющий его из DOM. В коде, показывающем компонент, мы сначала создаем View на основе переданного нам шаблона, находим фабрику нашего компонента Popover и затем создаем его, передавая на вход фабрику компонента, инжектор, позицию и вложенный контент, который будет вставлен на место ng-content в компоненте Popover. Также, поскольку наш компонент динамический, то надо передать в него необходимые параметры и сказать Change Detector-механизму, что данные изменились.

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

Во-первых, интересный факт в том, что ангуляр не вставляет View-элемент внутрь указанного контейнера, а добавляет его сразу после контейнера. Поэтому для вставки элементов в DOM удобно использовать ng-container элемент, который избавит нас от лишнего элемента в DOM. Лично для меня это было удивительным откровением, когда я начал отлаживать DOM-разметку и потратил кучу времени, чтобы понять, где ошибся, что мой элемент не вставляется внутрь.

Во-вторых, динамически добавляемые компоненты не поддерживают Input- и Output-декораторы и это самая печаль. Для нас это выливается в то, что ngOnChanges метод компонента не будет вызываться, когда мы присваиваем новое значение Input переменным компонента. Из этой ситуации есть два выхода — использовать setter-метод для переменной компонента или контролировать перерисовку компонента вручную из компонента родителя. Можно еще использовать ngDoCheck и в нем самим сравнивать атрибуты.

Но давайте усложним задачу и предположим, что мы хотим динамически создавать компонент, который находится в модуле, который лежит в отдельном файле на сервере. Прям «заяц в утке, утка в шоке». Но это еще не все, также мы хотим создавать наш компонент из сервиса, а не из существующего компонента. Итак, давайте разбираться по порядку как такое можно сделать.

Первое, что нам нужно, это достать фабрику нужного компонента из модуля и создать экземпляр компонента. Для загрузки js файла с сервера я буду использовать SystemJS загрузчик. Также пусть нужный модуль экспортируется в переменной module из нашего JS файла. Ниже представлен код, который решает первую часть нашей задачи:

В данном коде мы использовали JIT-компилятор ангуляра для того, чтобы скомпилировать загруженный модуль, и у данного подхода есть одна особенность. Так, если вы используете AOT-сборку вашего проекта, то компилятор будет не доступен во время выполнения приложения и указанный код работать не будет. Для решения данной проблемы можно создать отдельный модуль с сервисом, куда нужно будет вручную добавить компилятор, например, следующим образом:

Теперь у нас есть компонент и нам осталось понять, как вставить его в DOM из сервиса. Ведь тут у нас нет ViewContainerRef, который мы использовали ранее. Для вставки компонента в DOM нам надо сделать две вещи — найти корневой элемент нашего приложения и вставить в него созданный View-компонент. И тут воспользуемся ссылкой на наше angular-приложение, которая доступна средствами DI через переменную с типом ApplicationRef. Для этого в конструктор нашего класса необходимо добавить ApplicationRef:

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

Теперь соберем все вместе и получим следующий код, добавляющий наш компонент в DOM:

В данном коде есть пара моментов, которые надо пояснить. Кроме создания нашего компонента и поиска места для вставки его в DOM, мы также создали собственный Injector и добавили наш View компонента к приложению angular, вызвав метод attachView, чтобы он о нас знал и запускал ChangeDetector. Кроме этого, мы повесили обработчик на уничтожение компонента, в котором удаляем компонент из приложения.

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

Кстати, необязательно тянуть извне весь модуль целиком. Данная техника применима и в ситуациях, когда вы хотите создавать компоненты, имея только файл с содержимым шаблона. Например, у вас на сервере может лежать содержимое шаблона, и вы хотите на основе его создать View. Для этого вам нужно будет сделать следующее:

  • Создать модуль и добавить в него все используемые в шаблоне компоненты.
  • Создать компонент и указать загруженный шаблон.
  • Сделать то же, что мы делали в коде выше — скомпилировать полученный модуль, получить из него ваш компонент и вставить в DOM

Вот пример кода, который решает данную задачу:

Встроенные решения

Ангуляр в последних версиях предоставляет две директивы, которые упрощают создание динамического контента — ngTemplateOutlet и ngComponentOutlet. Первая директива позволяет вам создавать DOM-элементы на основе готовых шаблонов, а вторая директива используется при создании полноценных компонентов. Давайте рассмотрим два примера как это происходит. Начнем с директивы ngTemplateOutlet:

В данном примере будет вставлено три шаблона, в которые подставится значения, взятые из контекста. Ключевое слово $implicit определяет дефолтное значение переменной в случае отсутствия ее в контексте. По сути, данная директива под капотом просто делает вызов createEmbeddedView, используя переданный ей шаблон. Теперь рассмотрим директиву ngComponentOutlet.

Данная директива под капотом работает аналогично рассмотренным нами вариантам создания компонентов и просто вызывает createComponent-метод, используя фабрику компонента.

Итак, давайте подведем итоги. В новом ангуляре можно создавать динамический контент в различных вариантах: от простого создания DOM-элементов до формирования контента на основе готовых компонентов. Да кто-то скажет, что для этого надо написать довольно много кода, но тут в защиту могу сказать, что такой код пишется один раз и дальше просто используется. Как показывает мой опыт, другие фреймворки тоже не всегда дают простой способ создания, например, модальных окон, так что решение этой задачи в ангуляре для меня не было шокирующим, хотя и потребовало много времени.

В этой статье мы рассмотрим, как создавать всплывающие и перекрывающие элементы на React, Angular 1.5 и Angular 2. Реализуем создание и показ модального окна на каждом из фреймворков. Весь код написан на typescript. Исходный код примеров доступен на github.

Что такое "всплывающие и перекрывающие" элементы? Это DOM элементы, которые показываются поверх основного содержимого документа.

Это различные всплывающие окна (в том числе модальные), выпадающие списки и менюшки, панельки для выбора даты и так далее.

Как правило, для таких элементов применяют абсолютное позиционирование в координатах окна (для модальных окон) при помощи position: fixed , или абсолютное позиционирование в координатах документа — для меню, выпадающих списков, которые должны располагаться возле своих "родительских" элементов, — при помощи position: absolute .

Простое размещение всплывающих элементов возле "родителей" и скрытие/отображение их не работают полностью. Причина — это родительские контейнеры с overflow , отличным от visible (и fixed ). Все, что выступает за границы контейнера, будет обрезано. Также такие элементы могут перекрываться элементами ниже по дереву, и z-index не всегда тут поможет, так как работает только в пределах одного контекста наложения.

По-хорошему, эту проблему элегантно мог бы решить Shadow DOM (и то, не факт), но пока он не готов. Могло бы помочь CSS свойство, запрещающее обрезку и перекрытие, либо позиционирование относительно документа (а не родителя), но его нет. Поэтому используем костыль — DOM элементы для всплывающих компонентов помещаем в body , ну или, на худой конец, поближе к нему, в специальный контейнер, у родителей которого заведомо нет "обрезающих" стилей.

Замечание: мы не будем рассматривать позиционирование элементов с разными координатами и родителями — это отдельная сложная тема, полная костылями, javascript-ом и обработкой неожиданных событий браузера.

Например, ни один из существующих способов не решает идеально проблему позиционирования, если нам нужно сделать компонент типа select или autocomplete с выпадающим списком возле элемента.

Использование position: fixed , по-видимому, позволяет избежать обрезки родительским контейнером, но вынуждает обрабатывать скроллинг документа и контейнера (проще всего тупо закрывать выпадающий список). Использование position: absolute и помещение элемента в body обрабатывает прокрутку документа правильно, но требует пересчета позиции при прокрутке контейнера.

Все способы требуют обработки события resize. В общем, нет тут хорошего решения.

Все примеры содержат одинаковую верстку, и состоят из поля ввода с текстом и кнопки. По нажатию на кнопку введенный текст появляется в "модальном" окошке с кнопкой "Закрыть".

Все примеры написаны на typescript. Для компиляции и бандлинга используется webpack. Чтобы запустить примеры, у вас должен быть установлен NodeJS.

Если это делать лень, можно просто открыть в браузере index.html из папки соответствующего примера.

Компоненты

В версии 1.5 Angular приобрел синтаксический сахар в виде метода component у модуля, который позволяет объявлять компоненты. Компоненты — это на самом деле директивы, но код их объявления ориентирован на создание кирпичиков предметной области приложения, тогда как директивы больше ориентированы (идеологически, технически все идентично) на низкоуровневую и императивную работу с DOM. Это нововведение простое, но прикольное, и позволяет объявлять компоненты способом, схожим с Angular 2. Никаких новых фич этот способ не привносит, но может кардинально повлиять на структуру приложения, особенно, если раньше вы пользовались

.
.

От себя я могу добавить, что я в восторге от этой возможности. Она позволяет создавать компоненты с четким контрактом и высоким потенциалом для повторного использования. Более того, с этой возможностью я отказался от вынесения HTML разметки компонента в отдельный файл, т.к. разметка получается маленькая и аккуратная — в ней используются вложенные компоненты — и не загромождает исходник компонента.

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

Два способа

Наверное, существует больше способов поместить компонент в произвольное место DOM. Я покажу два из них, один при помощи сервиса $compile , второй — при помощи директивы с transclude .

Второй способ — декларативный, он позволяет встроить всплывающий элемент в шаблон компонента,
но при показе помещать его в body . Подходит для компонентов типа дроп-дауна, позволяя реактивно управлять видимостью.

Способ 1: $compile

Сервис $compile позволяет преобразовать строку с Angular разметкой в DOM элемент и связать его со $scope .
Получившийся элемент может быть добавлен в произвольное место документа.

Все довольно просто.

Вот документация сервиса. По ссылке полное руководство по API директив, интересующая нас часть в самом конце — использование $compile как функции.

Получаем доступ к $compile

Объявление static $inject=["$compile"] эквивалентно следующему Javascript коду:

$compile работает в две фазы. На первой он преобразует строку в функцию-фабрику. На второй нужно вызвать полученную фабрику и передать ей $scope . Фабрика вернет DOM элементы, связанные с этим $scope.

Результатом компиляции будет фабрика — функция, которая позволит связать строковый шаблон с любым $scope . Таким образом, задавая шаблон, можно использовать любые поля и методы вашего скоупа. Например, вот как выглядит код открытия всплывающего окна:

Обратите внимание на несколько вещей.

Во-первых, шаблон содержит компонент .
Во-вторых, шаблон содержит обращение к полю text контроллера: text="$c.text" .
$c — это алиас контроллера, заданный при объявлении компонента.

PopupService.open также возвращает фабрику, позволяющую связать шаблон со $scope . Для того, чтобы связать динамический компонент со $scope нашего компонента, приходится передавать $scope в контроллер.

Вот как выглядит PopupService.open :

В нашей функции мы оборачиваем переданный шаблон в разметку модального окна. Потом компилируем шаблон, получая фабрику динамических компонентов. Потом вызываем полученную фабрику, передавая $scope , и получаем HTML элемент, который представляет собой полностью рабочий фрагмент Angular приложения, связанный с переданным $scope . Теперь его можно добавить в любое место документа.

Хотя наш метод PopupService.open тоже возвращает фабрику для связи с $scope , он делает дополнительную работу. Во-первых, когда фабрика вызывается, он не только создает элемент, но и добавляет его в body . Во-вторых, он создает функцию, которая позволит "закрыть" поп-ап окно, удалив его из документа. PopupService.open возвращает эту функцию для закрытия окна.

Что ж, вариант не так плох. Хотя само отображение окна императивное, тем не менее, содержимое окна все еще реактивно, и может быть декларативно связано с родительским $scope . Хотя для отображения контента приходится использовать строки, но если сам контент окна сделать в виде компонента, то связывать нужно будет только input и output свойства, а не весь контент. Метод позволяет поместить поп-ап элемент в любое место документа, даже если оно вне ng-app .

Способ 2: Директива с transclude

Второй способ позволяет задавать содержимое всплывающего элемента прямо возле его "родителя". При показе элемент будет на самом деле добавлен в body .

Здесь искомая директива — . . Все, что внутри нее, будет показано во всплывающем окне, и расположено в body .

Небольшой недостаток этого метода в том, что показывать и прятать окно необходимо при помощи директивы ng-if , которая физически будет убирать/добавлять содержимое в DOM дерево.

transclude

transclude — это способ директив работать со своим содержимым. Под содержимым понимается то, что расположено между открывающимся и закрывающимся тегами директивы.

Это очень мощная возможность, на основе которой можно сделать много интересного. Мы же будем брать содержимое и помещать его в body .

Можно создавать сколько угодно клонов, переопределять им scope, добавлять в любое место документа, и так далее. Здорово.

Для директив, которые сами управляют содержимым (вызывают transclude function), необходимо реализовывать lifecycle методы для очистки содержимого. Эти методы реализуются в контроллере директивы. Удалять добавленное содержимое нужно в $onDestroy .

Осталось последнее — как получить доступ к transclude function. Она передается в нескольких местах, но мы ее заинжектим в контроллер. Для того, чтобы она передалась, в конфигурации директивы должно быть установлено transclude: true .

Итак, полный код:

Неплохо, всего 36 строк.

  • Полностью реактивное отображение и скрытие, реактивное содержимое
  • Удобно разносит "виртуальное" расположение в дереве компонентов и "физическое" расположение в DOM дереве.
  • Декларативно привязано к текущему scope.
  • В этом варианте реализации нужно использовать ng-if для управления отображением.

Новая версия Angular, отличающаяся от первого настолько, что, фактически, это новый продукт.
Мои впечатления от него двоякие.

С одной стороны, код компонентов несомненно чище и яснее. При написании бизнес-компонентов разделение кода и представления отличное, change tracking работает прекрасно, прощайте $watch и $apply , прекрасные средства для описания контракта компонента.

С другой стороны, не оставляет ощущение монструозности. 5 min quickstart выглядит издевательством. Множество дополнительных библиотек, многие из которых обязательны к использованию (как rxjs ). То, что я успеваю увидеть надпись Loading. при открытии документа с файловой системы, вселяет сомнения в его скорости. Размер бандла в 4.3MB против 1.3MB у Angular 1 и 700KB React (правда, это без оптимизаций, дефолтный бандлинг webpack-а). (Напоминаю, что webpack собирает (бандлит) весь код приложения и его зависимостей (из npm) в один большой javascript файл).

Минифицированный размер: Angular 1 — 156KB, Angular 2 — около 630KB, в зависимости от варианта, React — 150KB.

Angular 2 на момент написания еще RC. Код практически готов, багов вроде бы нет, основые вещи сделаны (ну кроме разве что переделки форм). Однако документация неполная, многие вещи приходится искать в комментариях к github issue
(как, например, динамическая загрузка компонентов, что, собственно, и подтолкнуло меня к написанию этой статьи).

Disclaimer

Тратить полтора часа на шаги, описанные в упомянутом 5 min quickstart, не хотелось, поэтому проект сконфигурирован не совсем, кхм, традиционно для Angular 2. SystemJS не используется, вместо этого бандлится webpack-ом. Причем Angular 2 не указывается как externals, а берется из npm пакета как есть. В результате получается гигантский бандл в 4.5MB весом. Поэтому не используйте эту конфигурацию в продакшене, если, конечно, не хотите, чтобы пользователи возненавидели ваш индикатор загрузки. Вторая странность, которая не знаю, чем вызвана, это отличающиеся названия модулей. Во всех примерах (в том числе в официальной документации) импорт Angular выглядит как import < >from "@angular/core" . В то же время, у меня так не заработало, а работает import <> from "angular2/core" .

Динамическая загрузка

К чести Angular 2, код динамической загрузки вызывает трудности только при поиске. Для динамической загрузки используется класс ComponentResolver в сочетании с ViewContainerRef.

ComponentResolver легко получить через dependency injection. ViewContainerRef , по-видимому, не может быть создан для произвольного DOM элемента, и может быть только получен для существующего Angular компонента. Это значит, что поместить динамически созданный элемент в произвольное место DOM дерева невозможно, по крайней мере, в релиз-кандидате.

Поэтому, наш механизм для показа поп-апов будет составным.

Во-вторых, у нас есть вспомогательный компонент, содержащий в себе разметку для поп-ап окна. Это OverlayComponent , в который оборачивается динамически создаваемый компонент.

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

Хост-компонент

Я приведу класс целиком, он не очень большой, и потом пройдусь по тонким местам:

Что делает этот код? Он динамически создает компонент, используя его тип (тип компонента — это его функция-конструктор). Предварительно создается компонент-обертка ( OverlayComponent ), наш запрошенный компонент добавляется уже к нему. Также мы подписываемся на событие destroy , чтобы уничтожить обертку при уничтожении компонента.

Честно говоря, я это нагуглил, и как по мне, это вообще неинтуитивно. Это одна из особенностей второго Angular-а, которая мне очень сильно бросилась в глаза, — в документации очень сложно, или же вообще невозможно, найти решения для типовых задач низкоуровневой разработки директив. Документация для создания именно бизнес-компонентов нормальная, да и ничего там особо сложного нет. Однако для сценариев написания контролов, низкоуровневых компонентов, невозможно найти документации. Динамическое создание компонентов, взаимодействие с шаблоном из класса — эти области просто не документированы. Даже в описании @ViewChild ничего не сказано о втором параметре.

Что ж, надеюсь, к релизу задокументируют.

Код OverlayHostComponent , который я привел выше, — это самое интересное в нашем примере. OverlayComponent содержит похожий код для динамического добавления содержимого, OverlayService перенаправляет вызовы открытия поп-апа к хост-компоненту. Я не привожу листинги по причине тривиальности, если интересно, посмотрите в исходниках.

Посмотрим теперь, как этим пользоваться:

OverlayService указан в providers Root компонента, в нашем компоненте его регистрировать не нужно.

После создания экземпляра компонента можно получить к нему доступ через ComponentRef.instance .

Потом начинается страшная императивщина: устанавливаем свойства компонента, подписываемся на события, все это руками. Ни о какой декларативности или реактивности речи не идет. Особенно весело, если нужно обеспечить двухстороннее связывание. Несколько вариантов с бесконечными циклами и флагами isRunning вам обеспечены.

Вывод

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

Я долго рассматривал исходник ngFor, но не смог решить проблему связывания. Я думал над фабрикой компонентов с динамическими шаблонами, но не уверен, что существует способ динамической регистрации компонентов в массиве directives .

Отсутствие способа помещать компоненты в произвольное место DOM это не очень хорошо, и может вносить ограничения, особенно если страница это несколько микро-приложений. Однако отсутствие динамического связывания компонентов это, на мой взгляд, гораздо более серьезная проблема.

В Реакте стандартный способ отображения компонента в DOM дерево — это метод render , который возвращает виртуальный узел виртуального DOM. Однако, это совсем не значит, что этот способ единственный. Для вставки компонента в произвольное место из метода render возвращается null , и перехватываются lifecycle-методы componentDidMount , componentWillUnmount , componentDidUpdate . В componentDidMount и componentDidUpdate , используя ReactDOM.render , можно отрендерить содержимое в любое место. В componentWillUnmount содержимое, соответственно, уничтожается.

Все просто и понятно. Видно, что такой сценарий создателями Реакта продумывался.

Ладно, теперь посмотрим, как это использовать. Привожу, для краткости, только метод render :

Ifc это костылик, который рендерит содержимое, только если condition истинно. Это позволяет избавиться от монструозных IIFE, если нужно отрендерить кусок компонента по условию.

В остальном все просто: если компонент есть в виртуальном дереве — поп-ап окошко показывается, если нет — то прячется. При этом физически в DOM дереве оно находится в body .

Как видим, очень похоже на второй способ с Angular 1.5, с директивой.

Итоги

В принципе, поп-ап в Реакте можно сделать и императивным способом, похожим на способ Angular с $compile . Это может упростить некоторые сценарии и не создавать флаг в состоянии приложения для показа каждого алерта. Принцип тот же (используя ReactDOM.render ), но только не в методах жизненного цикла компонента, а в методе openPopup . Это, конечно же, нарушает реактивность, но сделает код понятнее, увеличив связность.

Недостатки приведенного способа — не будет работать в серверном рендеринге.

Подходы, изначально заложенные в Реакте — однонаправленные потоки данных, компоненты, четкий input/output контракт компонента — нашли свое отражение и в Angular: by design в Angular 2, и в обновлениях Angular 1.5. Это изменение без сомнения пошло на пользу первому Angular.

Что касается показа всплывающих элементов — это пример костылей, которые возникли из-за несовершенства CSS, но повлияли на всю экосистему веб-разработки. Это яркий пример текущей абстракции, а также баланса между "чистой архитектурой" и "реальной жизнью" веб разработки. Как видим, разработчики Angular 2 либо не задумывались об этом сценарии, либо реализовали его, но никому не сказали. В то же время, первый Angular и React достаточно гибкие (а разработчики Реакта видимо еще и продуманные), чтобы можно было реализовать рендеринг элемента в отличное от его расположения в дереве компонентов.

Читайте также: