ส่วนประกอบแบบไดนามิกที่มี ViewEncapsulation.Emulated + CDKPortal ไม่สามารถจัดสไตล์ได้ (เลย) โดยไม่ใช้ ::ng-deep

ฉันกำลังสร้างส่วนประกอบแบบไดนามิกโดยใช้ ComponentPortal ของ Angular Material Portal

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) ไม่ได้ทำเช่นนี้ ฉันคิดว่าเหตุผลคือหนึ่งในการใช้งานหลักสำหรับพอร์ทัลคือการนำส่วนประกอบที่กำหนดมาไว้ในที่เดียวและวางไว้บนเพจของคุณทุกที่ที่คุณต้องการ ดังนั้นในกรณีนั้น parent injector ก็ไม่สมเหตุสมผล

หากคุณกำลังสร้างส่วนประกอบแบบไดนามิกและวางไว้ในส่วนประกอบเดียวกัน คุณควรใช้ตัวสร้างต่อไปนี้:

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

อย่างไรก็ตาม หากคุณกำลังสร้างแผนผังส่วนประกอบแบบไดนามิก สิ่งนี้จะกลายเป็นปัญหาด้านลอจิสติกส์! คุณต้องทำให้ส่วนประกอบโฮสต์ของคุณยุ่งเหยิงด้วยรหัส parentInjector ทั้งหมดนี้

วิธีแก้ปัญหาของฉันสำหรับปัญหา ViewEncapsulation.Emulated

แอปพลิเคชันของฉันคือ UI แบบกราฟิกเพื่อออกแบบเพจจากส่วนประกอบต่างๆ เช่น กริด ตาราง รูปภาพ วิดีโอ ฯลฯ

โมเดลนี้ถูกกำหนดให้เป็นแผนผังของ 'โหนดที่เรนเดอร์' ดังต่อไปนี้ อย่างที่คุณเห็นฉันมี 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 มันจะกระโดดไปจนถึงส่วนประกอบไดนามิกด้านนอกสุด

ดังนั้นฉันจึงรู้ว่าฉันต้องหลีกเลี่ยงการสร้างพอร์ทัลส่วนประกอบจนกว่าองค์ประกอบโฮสต์จริงจะถูก 'ทำงาน':

นี่คือลักษณะของความพยายามดั้งเดิม (ด้วยพอร์ทัลอินสแตนซ์ที่สร้างขึ้นอย่างกระตือรือร้น):

    <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