[Из песочницы] Модальные окна на Angular, Angular 2 и ReactJS

В этой статье мы рассмотрим, как создавать всплывающие и перекрывающие элементы на 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.


Для запуска перейдите в папку с соответствующим примером и выполните в командной строке NodeJS один раз npm run prepare, чтобы установить глобальные и локальные пакеты. Потом выполните npm run server. После этого откройте в браузере адрес http://localhost:8080


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


Angular 1.5

Компоненты


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

...
.


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


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


Два способа


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


Первый способ императивный, и подходит больше для ad-hoc показа модальных окон, например, для вывода сообщений или запроса у пользователя каких-то параметров. Также этот способ можно применять, если тип компонента неизвестен, или разметка динамическая.


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


Способ 1: $compile


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


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


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


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


// popup.service.ts
import * as angular from "angular";

export class PopupService {
    static $inject = ["$compile"];

    constructor(private $compile: angular.ICompileService) {}
}

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


function PopupService($compile) {
    this.$compile = $compile;
} 

PopupService.$inject = ["$compile"];

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


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


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


/// test-popup.component.ts

export class TestPopupComponentController {
    static $inject = ["$scope", PopupService.Name];

    text: string = "Open popup with this text";

    constructor(
        private $scope: angular.IScope,
        private popupService: PopupService) {
    }

    openPopup() {
        const template = ` `
        this.popupService.open(template)(this.$scope);
    }
}

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


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


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


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


// popup.service.ts

open(popupContentTemplate: string): ($scope: angular.IScope) => () => void {
    const content = `
            
            `;

    return ($scope: angular.IScope) => {
        const element = this.$compile(content)($scope);
        const popupElement = document.body.appendChild(element[0]);

        return () => {
            body.removeChild(popupElement);
        };
    };
}

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


Как использовать transclude? Напрямую использовать контент (например, через $element.children) нельзя — он не связан с правильным scope, и не скомпилирован (не заменены директивы и т.д.). Для использования transclude нужно получить доступ к т.н. transclude function. Это фабрика, которая умеет создавать скомпилированные копии (клоны) содержимого. Эти клоны будут скомпилированы и связаны с правильным scope, и вообще, очень похожи на результат работы $compile. Transclude function, однако, не возвращает значение, как обычная фабрика, а передает его в коллбек-функцию.


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


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


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


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


import * as angular from "angular";

export class PopupDirectiveController {
    private content: Node;

    constructor(private transclude: angular.ITranscludeFunction) {
    }

    $onInit() {
        this.transclude(clone => {            
            const popup = document.createElement("div");
            popup.className = "popup-overlay";
            for(let i = 0; i < clone.length; i++) {
                popup.appendChild(clone[i]);
            }

            this.content = document.body.appendChild(popup);
        });
    }

    $onDestroy() {
        if (this.content) {
            document.body.removeChild(this.content)
            this.content = null;
        }
    }
}

export const name = "popup";

export const configuration: angular.IDirective = {
    controller: ["$transclude", PopupDirectiveController],
    replace: true,
    restrict: "E",
    transclude: true
};

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


Преимущества:


  • Полностью реактивное отображение и скрытие, реактивное содержимое
  • Удобно разносит «виртуальное» расположение в дереве компонентов и «физическое» расположение в DOM дереве.
  • Декларативно привязано к текущему scope.

Недостатки:


  • В этом варианте реализации нужно использовать ng-if для управления отображением.

Angular 2

Новая версия 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.



// Асинхронно создает новый компонент по типу.
// Тип - это класс компонента (его функция-конструктор)
 loadComponentDynamically(componentType: Type, container: ViewContainerRef) {
    this.componentResolve
        .resolveComponent(componentType)
        .then(factory => container.createComponent(factory))
        .then(componentRef => {
            // Получаем доступ к экземпляру класса компонента
            componentRef.instance;
            // Получаем доступ ElementRef контейнера, в который помещен компонент
            componentRef.location;
            // Получаем доступ к DOM элементу. 
            componentRef.location.nativeElement;
            // Удаляем компонент
            componentRef.destroy();
        });
 }

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


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


Во-первых, у нас будет компонент, в который будут динамически добавляться поп-ап элементы. Его нужно будет разместить где-нибудь в дереве компонентов, желательно поближе к корневому элементу. Кроме того, никакой из его родительских контейнеров не должен содержать стилей, обрезающих содержимое. В коде это overlay-host.component.ts.


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


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


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


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


import { Component, ComponentRef, ComponentResolver, OnInit, Type, ViewChild, ViewContainerRef } from "angular2/core";

import { OverlayComponent } from "./overlay.component";
import { IOverlayHost, OverlayService } from "./overlay.service";

@Component({
    selector: "overlay-host",
    template: ""
})
export class OverlayHostComponent implements IOverlayHost, OnInit {

    @ViewChild("container", { read: ViewContainerRef }) container: ViewContainerRef;

    constructor(
        private overlayService: OverlayService,
        private componentResolver: ComponentResolver) {
    }

    openComponentInPopup(componentType: Type): Promise {
        return this.componentResolver
            .resolveComponent(OverlayComponent)
            .then(factory => this.container.createComponent(factory))
            .then((overlayRef: ComponentRef) => {

                return overlayRef.instance
                    .addComponent(componentType)
                    .then(result => {

                        result.onDestroy(() => {
                            overlayRef.destroy();
                        });

                        return result;
                    });
            });
    }

    ngOnInit(): void {
        this.overlayService.registerHost(this);
    }
}

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


Первое, на что нужно обратить внимание, это как получается ViewContainerRef при помощи запроса к содержимому.
Декоратор @ViewChild() позволяет получать ViewContainerRef по имени template variable template: .
#container — это и есть template variable, переменная шаблона. К ней можно обращаться по имени, но только в самом шаблоне. Чтобы получить доступ к ней из класса компонента, используется упомянутый декоратор.


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


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


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


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


import { Component, Input } from "angular2/core";

import { OverlayService } from "./overlay";
import { PopupContent } from "./popup-content.component";

@Component({
    selector: "test-popup",
    template: `
    

` }) export class TestPopup { text: string = "Show me in popup"; constructor(private overlayService: OverlayService) { } openPopup() { this.overlayService .openComponentInPopup(PopupContent) .then(c => { const popup: PopupContent = c.instance; popup.text = this.text; popup.close.subscribe(n => { c.destroy(); }); }); } }

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


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


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


Вывод


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


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


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


ReactJS

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


Собственно, код:


import * as React from "react";
import * as ReactDOM from "react-dom";

export class Popup extends React.Component<{}, {}> {
    popup: HTMLElement;

    constructor() {
        super();
    }

    render() {
        return ();
    }

    componentDidMount() {
        this.renderPopup();
    }

    componentDidUpdate() {
        this.renderPopup();
    }

    componentWillUnmount() {
        ReactDOM.unmountComponentAtNode(this.popup);
        document.body.removeChild(this.popup);
    }

    renderPopup() {
        if (!this.popup) {
            this.popup = document.createElement("div");
            document.body.appendChild(this.popup);
        }

        ReactDOM.render(
            
{ this.props.children }
, this.popup); } }

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


Вообще Реакт после Angular производит очень приятное впечатление. Отсутствуют костыли отслеживания изменений, которые вроде бы не нужно использовать, но всегда приходится. Простой доступ к DOM элементам, если он нужен. Простой доступ к содержимому реакт-элемента через children, причем это не строка и не HTMLElement, а структура, содержащая в себе полноценные реакт-элементы (для работы с ними нужно использовать React.Children).


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


render() {
        return (
            
this.setText(e.target.value) } type="text" />

this.state.isPopupVisible } >

{ this.state.text}

) }

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 достаточно гибкие (а разработчики Реакта видимо еще и продуманные), чтобы можно было реализовать рендеринг элемента в отличное от его расположения в дереве компонентов.

Комментарии (4)

  • 18 июля 2016 в 23:03

    0

    Вот! Ну вот же именно то, чего мне и не хватало. Спасибо за статью. Все доходчиво расписано.
  • 19 июля 2016 в 00:17

    0

    У второго ангуляра тоже есть multi transcluding https://toddmotto.com/transclusion-in-angular-2-with-ng-content#angular-2-content-projection
    • 19 июля 2016 в 00:30

      0

      Да, но как с их помощью поместить контент в body? В первом ангуляре можно сделать transclude вручную, во втором, похоже, нельзя.

  • 19 июля 2016 в 01:55

    0

    В то же время, у меня так не заработало, а работает import {} from «angular2/core».

    А что в package.json? И rm -rf node_modules && npm i не помешает. Импорт из «angular2/что-то» работает только для бета-релизов, а тем временем уже 4-й RC вышел. А так как вы пользуетесь бетой (уже устаревшей), отсюда и могут возникать некоторые проблемы.
    Например, разрабы говорили, что в RC подчистили код и его общий размер уменьшился. Если не включать в бандл всю rxjs и прогнать вебпаковским оптимизатором, то размер бандла вполне можно уменьшить до сотен килобайт.


    А для динамического размещения компонентов в DOM есть DynamicComponentLoader.

© Habrahabr.ru