Динамические компоненты с ViewEncapsulation.Emulated + CDKPortal нельзя стилизовать (вообще) без использования ::ng-deep

Я создаю динамические компоненты, используя портал Angular Material Portal ComponentPortal.

ngAfterViewInit() {
     this.userSettingsPortal = new ComponentPortal(UserSettingsComponent, null, this.hostInjector);
}

Затем я показываю это так:

<ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>

Допустим, это генерирует мне компонент UserSettingsComponent, и я хочу применить маржу.

app-user-settings 
{
   margin: 20px;
   outline: 2px solid red;
}

Это не работает, если я не использую ::ng-deep, что очень неуклюже. Обычно ::ng-deep используется для "пробивания" черного ящика стиля элемента, но в данном случае я этого не делаю. Я просто хочу, чтобы хост-компонент (UserSettings) позиционировал его, но он не может.

Примечание. Это не относится к порталам — если я создам компонент вручную, проблема останется прежней.


person Simon_Weaver    schedule 01.07.2021    source источник


Ответы (1)


Примечание. Существует открытая проблема, связанная с этим, но он был открыт в течение почти 5 лет.

Основная проблема заключается в том, что средство визуализации не применяет атрибут _ngcontent-app-c123 к динамическому компоненту. Поэтому вам нужно ::ng-deep, чтобы избежать создания css, который нацелен (и требует) на этот селектор атрибутов.

Решение 1. Накройте его оберткой.

Это очевидное решение, но в итоге вы получите дополнительную обертку.

<div id="usersettings">
   <ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>
</div>

Затем вы можете стилизовать этот div, как и ожидалось, с помощью #usersettings.

Решение 2. Пользовательская директива для применения атрибута _ngcontent.

Если вы знаете, что портал будет отображаться только в одном месте (а не перемещаться), тогда будет работать следующее. Обратите внимание, что этот ответ является моим собственным ответом на вышеупомянутую проблему.


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

Создать ComponentPortal довольно просто:

ngAfterViewInit() {
    this.userSettingsPortal = new ComponentPortal(UserSettingsComponent);
}

Затем вы визуализируете это так:

<ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>

Вы можете внедрять зависимости с помощью механизма, описанного здесь. (Примечание для › Angular 10, вы не должны использовать устаревший ответ с WeakMap).

Важные соображения по проектированию/дереву инжекторов

Вы можете просто создать один динамический компонент или создать целое дерево. В любом случае вам действительно нужно передать инжектор вашего хост-компонента в конструктор ComponentPortal, чтобы получить «нормальное» поведение, которое вы ожидаете в дереве инжектора компонентов.

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

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

     const componentPortal = new ComponentPortal(component, null, parentInjector);

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

Мое решение проблемы ViewEncapsulation.Emulated

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

Модель определяется как дерево «визуализированных узлов», что-то вроде следующего. Как видите, у меня есть ComponentPortal в каждом узле:

export type RenderedPage =
{
    children: (RenderedPageNode | undefined)[];
}

// this corresponds to a node in the tree
export type RenderedPageNode =
{
    portal: ComponentPortal;
    children: RenderedPageNode[] | undefined;
}

КСТАТИ. Эта модель отображается компонентом, который перебирает children и рекурсивно вызывает сам себя, чтобы удалить дерево. В основном это *ngFor цикл из ng-template [cdkPortalOutlet]="node.portal".

Я начал (наивно) с энтузиазмом создавать все ComponentPortal для дерева. Проблема с этим способом заключается в том, что правильный инжектор экземпляра компонента недоступен во время создания дерева. Когда вы создаете ComponentPortal, ваш компонент фактически не создается. Это означает, что службы, внедряемые компонентами, особенно Renderer2, не являются теми, которые вам действительно нужны. На самом деле, когда я пробовал @SkipSelf() private renderer2: Renderer2, он переходил к самому внешнему динамическому компоненту.

Итак, я понял, что мне нужно избегать создания портала компонентов до тех пор, пока фактический хост-компонент не будет «запущен»:

Вот как выглядела первоначальная попытка (с жадно созданными portInstances):

    <ng-template [cdkPortalOutlet]="pagenode.portalInstance"></ng-template>

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

    <ng-template [dynamicComponentOutlet]="pagenode"></ng-template>

Обратите внимание, как я передаю узел, а не экземпляр портала.

Итак, что будет делать эта директива:

  • Возьмите предварительно визуализированный pagenode, представляющий только определение динамического компонента (и его дочерние элементы).
  • В первый раз, когда он попытается подключить портал, он фактически создаст экземпляр ComponentPortal с правильным родительским инжектором.
  • Поскольку контекст инжектора выхода dynamicComponentOutlet является хост-компонентом, он также может генерировать и применять атрибут _ngcontent-app-c338 (о чем и идет речь в этом выпуске!).

Вот мое решение:

  1. Сначала мне нужно было создать LazyComponentOutlet, который содержит заполнитель для ComponentPortal, а также любые данные, необходимые для его создания. Я только что назвал это params, потому что решать вам. Я также не включаю определение ComponentPortalParams по той же причине. Как минимум, он должен включать тип компонента.
// this corresponds to a node in the tree
export type RenderedPageNode =
{
    // lazily instantiated portal
    lazyPortal: LazyComponentPortal;
    children: RenderedPageNode[] | undefined;
}

export type LazyComponentPortal =
{
    // the actual ComponentPortal which initially is undefined until the directive initializes it
    componentPortal: ComponentPortal<any> | undefined;

    // whatever we need to create a component
    params: ComponentPortalParams   // this is application specific to whatever you need 
}

Затем атрибут DynamicComponentPortalHost (переименуйте его, как вам угодно):

Обратите внимание, что это вдохновлено тем, как они наследуют портал в portal-directives.ts

@Directive({
    selector: '[dynamicComponentOutlet]',
    exportAs: 'rrDynamicComponentHost',
    inputs: ['dynamicComponentOutlet: rrDynamicComponentHost'],
    providers: [{
        provide: CdkPortalOutlet,
        useExisting: DynamicComponentPortalHostDirective
    }]
})
export class DynamicComponentPortalHostDirective extends CdkPortalOutlet {

    constructor( 
        
        // parameters required by CdkPortalOutlet constructor (passed via super)
        _componentFactoryResolver: ComponentFactoryResolver,
        _viewContainerRef: ViewContainerRef,
        @Inject(DOCUMENT) _document: any,
        
        // renderer inherited from host component (where the ng-template is defined)
        private renderer2: Renderer2,
        
        // injector (from parent) to use as a parent injector for our ComponentPortal
        private injector: Injector,

        // my own service to create a ComponentPortal
        // it's up to you how you create a ComponentPortal inside this
        private componentPortalFactory: ComponentPortalFactoryService)
    {
        super(_componentFactoryResolver, _viewContainerRef, _document);

        // need to subscribe immediately because ngOnInit is too late
        // when the component is attached we can immediately grab its element 
        this._subscription.add(this.attached.subscribe((component: ComponentRef<any> | null) => {

            if (component)  
            {
                // use parent renderer to determine the correct content attribute for us
                // to do this we just render a fake element and 'borrow' it's first (and only) attribute
                // _ngcontent-app-c338
                const contentAttr = this.renderer2.createElement('div').attributes[0].name;
                renderer2.setAttribute(component.location.nativeElement, contentAttr, '');
            }
        }));
    }

    _subscription = new Subscription()

    ngOnDestroy()
    {
        this._subscription.unsubscribe();
    }

    @Input('dynamicComponentOutlet')
    set dynamicComponentOutlet(pageNode: RenderedPageNode) 
    {
        // if we haven't yet instantiated a ComponentPortal instance create one
        if (!pageNode.lazyPortal.componentPortal)
        {
            // create component portal
            // how you do this is up to you, just be sure to use the constructor that includes injector
            const componentPortal = this.componentPortalFactory.createComponentPortal(pageNode.lazyPortal.params, this.injector);

            // we now have an actual instance of ComponentPortal, so save a reference
            value.portal.componentPortal = componentPortal;
        }

        // set the ComponentPortal on the actual 'inherited cdkPortal'
        this.portal = value.portal.componentPortal!;
    }
}

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

И это все! Конечно, если они исправят (или изменят инкапсуляцию) это в будущем, вам нужно будет обновить это только в одном месте.

person Simon_Weaver    schedule 01.07.2021