📜 ⬆️ ⬇️

Dynamic Angular or manipulate correctly

image

Any project created is not complete without the dynamic creation of elements. Sooner or later you will need to either create a tooltip for the item, show a modal window, or even form some blocks dynamically loading them from the server. When solving such problems, I often determine the maturity of the framework I use: how simple I can create dynamic content in it, and what opportunities it offers me for this. In this article we will talk about dynamic content creation in the new Angular and consider the various approaches that it provides us.


Before we proceed to creating content, we need to consider a number of abstractions that exist in Angular - what they are and what they are used for. Since Angular is designed as a solution that can work on various platforms — in the browser, on a mobile device, and on the server — then direct work with the DOM is not very welcome in it, although it is possible. For example, the following example will work well in a browser, but it may stop working if you are using Web Worker or your code runs on a mobile device.


import { Component, AfterComponentInit, ViewChild } from '@angular/core'; @Component({ selector: 'some-component', template: '<input type="text" #input>' }) export class SomeComponent implements AfterContentInit { @ViewChild('input') input; ngAfterContentInit() { this.input.nativeElement.focus(); } } 

Instead of working directly with the DOM element, Angular provides us with the following abstractions — Renderer, TemplateRef, ElementRef, and ViewContainerRef. Let's look at them in order and see how we can use them to create dynamic content.


Renderer


I will talk about Renderer2 (hereinafter simply Renderer), since the first version is already marked as deprecated. Renderer is mainly used to manipulate already existing elements, for example, to change the element styles, attributes and parameters of an element. Most often its use can be found when creating directives. But it also allows you to create new elements and insert them into the DOM, which is suitable for our task.


Let's take a look at the methods Renderer provides:



This is not all that Renderer provides, but even using these methods, you can dynamically create and modify DOM elements.


But before using the features of Renderer, we need to consider one more thing - how to find DOM elements-containers in an angular and add dynamic content to it. To do this, we have two ways: use Dependency Injector or use a set of decorators - @ ViewChild / @ ViewChildren and> @ ViewChildren / @ ContentChildren. Let's look at both options and start with the simplest.


Element access via DI


This method is often used when creating your own directives. In order to get access to an element (container) of a directive, you need to add a private variable with the type ElementRef to the directive constructor. Let's take a look at how adding items will look like using the Renderer service in this case:


 input { Directive, Renderer2, ElementRef, Input} from '@angular/core'; @Directive({ selector: 'someDirective' }) export class SomeDirective { constructor( private renderer: Renderer2, private elementRef: ElementRef ) {} @Input() set content(value: string) { let buttonElement = this.renderer.createElement('button'); const text = this.renderer.createText('Text'); this.renderer.appendChild(buttonElement, text); this.renderer.appendChild(this.elementRef.nativeElement, buttonElement); } } 

In this example, we create a new button and insert it into the DOM. An example, of course, contrived, but allows us to see the main points for working with the DOM. The ElementRef reference points to the element to which our directive was applied. Everything is quite simple, but, unfortunately, this method is convenient only for directives and is not very convenient when you create components with dynamic content. Let us now consider a more universal method.


@ ViewChildren / @ ContentChildren


To search for items in the DOM, the angular provides a set of decorators - @ ViewChild / @ ViewChildren and> @ ViewChildren / @ ContentChildren. The @ViewChild directive differs from @ViewChildren in that the first one will always return only one element to you, while the second one allows you to find several elements, returning an object of type QueryList to you.


QueryList is an iterative interface, and also allows you to subscribe to change items through the Observable mechanism. The @ViewChildren and @ContentChildren decorators must be used in the ngAfterViewInit handler of the component's life cycle, since earlier QuryList will simply not be defined.


The pair of directives> @ ViewChildren / @ ContentChildren behaves in a similar way and differs from the bundle @ ViewChild / @ ViewChildren only in that> @ViewChildren looks for elements in the DOM tree, while @ViewChild looks for elements in ShadowDom. In this article, for simplicity, we will not consider a bunch of> @ ViewChildren / @ ContentChildren, and also confine ourselves to @ ViewChild-decorator, since we will not use several elements. To search for items, we will use the following syntax @ViewChild:


 @ViewChild('[query params]', { read: [referenceType], descendants: boolean }); 

Where



When searching for elements, the specified decorators return a variable of type ElementRef — a top-level abstraction that contains a reference to the “native” DOM element:


 class ElementRef { constructor(nativeElement: any) nativeElement: any } 

So, let's see how we can find an element in a component and, using Renderer, change its contents:


 @Component({ selector: 'some-component', template: '<div #elem>Element text</div>' }) export class SomeComponent implements AfterViewInit { @ViewChild('elem') _elem: ElementRef; constructor(private _renderer: Renderer2) {} ngAfterViewInit() { const buttonElement = this._renderer.createElement('button'); const text = this._renderer.createText('Text'); this._renderer.appendChild(buttonElement, text); this._renderer.appendChild(this._elem.nativeElement, buttonElement); } } 

As in the example above, we create a button with the given text and add it to the DOM. Only this time we insert the button into the container we need inside the component. This approach is too low-level and is used quite rarely, so let's go ahead and consider what else Angular gives us.


TemplateRef


The idea of ​​using templates to insert new elements is not new and has been used for a long time by JS developers. When using the template tag from HTML5, the browser will create a DOM tree for the contents of the tag, but will not insert it into the DOM. Here is an example of using the template tag in the classic, “native” JS:


 <template id="some_template"> <div>Template contrent text</div> </template> <div id="container"></div>  <script> let tpl = document.querySelector('#some_template'); let container = document.querySelector('#container'); insertAfter(container, tpl.content); </script> 

Angulyar provides its own template description notation, and also allows you to manipulate the template and its contents. You could get acquainted with this abstraction if you created your own structural directives like ngIf and ngFor. To access the template, we will use the TemplateRef type - this is a link to the ng-template element in your component or directive. You have two ways to access the template — using the ng-template and Dependency Injection tags or using the search for elements through the Query decorators, which we described above. Let's look at both ways and start with the simplest:


 @Directive({ selector: '[isAdmin]' }) export class IsAdminDirective { @Input() set isAdmin(value: boolean) { if (value) { this.viewContainerRef.createEmbeddedView(this.templateRef); } else { this.viewContainerRef.clear(); } } constructor( private templateRef: TemplateRef<any>, private viewContainerRef: ViewContainerRef ) {} } 

In the example above, we used Dependency Injection to access the template of our directive and dynamically insert it into the DOM using the ViewContainerRef. We'll talk about ViewContainerRef later, until you pay attention to it, but now let's look at how we can dynamically create DOM elements using the @ViewChild decorator:


 @Component({ selector: 'some-component', template: ` <ng-template #tpl1><span>Some template content 1</span></ng-template> <ng-template #tpl2><span>Some template content 2</span></ng-template> <div #container></div> ` }) export class SomeComponent { @Input() set isAdmin(value: boolean) { if (value) { this.view = this.viewContainerRef.createEmbeddedView(this._tpl); } else { this.view.destroy(); } } @ViewChild('tpl1') _tpl: TemplateRef; private view: EmbeddedViewRef<Object>; constructor(private viewContainerRef: ViewContainerRef) {} } 

In this example, using the @ViewChild decorator, we find the template we need as a variable of type TemplateRef and insert it into the DOM in the same way as in the example with the designer.


By the way, the angular will remove the ng-template tag and its contents from the DOM and post a comment instead of it <! - ng-template bindings = {} ->. This method allows you to create simple dynamic content based on ready-made templates. But let's go ahead and see what else is available to us.


ViewContainerRef


It's time to talk about ViewContainerRef, which we have repeatedly seen in the examples above. ViewContainerRef is a link to a component container or directive and, in addition to accessing an element, allows you to create two types of View - Host Views (View elements created on the basis of components) and Embedded Views (View elements created on the basis of ready-made templates). All created elements have a base View type, which is the main building block for Angular applications and represents grouped DOM elements with which the angular is working as a whole and allows you to bind this group to the Change Detection mechanism. ViewContainerRef contains quite a few methods, let's consider them:



How createEmbeddedView works we have already seen in the examples above, now let's look at how to create View elements using the createComponent method. This method allows you to dynamically create elements based on finished components. But first, we need to learn how to find the factory of the component we need and the ComponentFactoryResolver will help us with this. I will not describe here the entire code for creating the entire component, but I will make a number of assumptions.


First, suppose that we have a Popover component in the project, which looks like this, for example:


 @Component({ selector: 'iw-popover', template: ` <div class="popover popover-{{placement}}"> <h3 class="popover-title">{{title}}</h3> <div class="popover-content"> <ng-content></ng-content> </div> </div> ` }) export class Popover { @Input() placement: string; @Input() title: string; } 

and it is added to the entryComponents attribute of our module. The latter, by the way, is important, since without adding a component to entryComponents nothing will work, the angular will simply not know about the component, because it will not meet it in the templates.


Also suppose that the call to our directive will look like this:


 <ng-template #popoverContent> Popover content </ng-template> <button [popover]="popoverContent" title="Popover title" placement="right"> Show popover </button> 

That is, our directive receives three parameters as input - a reference to a TemplateRef type template, the header value and the position where popover should be shown.


Based on these assumptions, the code of our directive showing popover will look like this:


 @HostListener('mouseover') show() { if (this._componentRef) { this._componentRef.destroy(); } this._contentViewRef = this.popover.createEmbeddedView(); const componentFactory = this._cfResolver.resolveComponentFactory(Popover); this._componentRef = this._vcRef.createComponent( componentFactory, this._injector, 0, [this._contentViewRef.rootNodes] ); this._componentRef.instance.placement = this.placement; this._componentRef.instance.title = this.title; this._contentViewRef.detectChanges(); } @HostListener('mouseleave') hide() { if (this._componentRef) { this._componentRef.destroy(); this._componentRef = null; } } constructor( private _injector: Injector, private _cfResolver: ComponentFactoryResolver ) {} 

As you can see, there are two mouse event handlers on the component — one showing component and one removing it from the DOM. In the code showing the component, we first create a View based on the template passed to us, find the factory of our Popover component and then create it by passing the component factory, injector, position and embedded content to be inserted in place of ng-content in the Popover component. . Also, since our component is dynamic, we need to transfer the necessary parameters to it and tell the Change Detector mechanism that the data has changed.


Everything is quite simple and, having understood this once, you will easily create components. It would seem that we have learned how to create dynamic content based on templates and components. And it seems we have everything to solve our problems, but there are a couple of points that I would like to draw attention to.


First, an interesting fact is that the angular does not insert the View-element inside the specified container, but adds it immediately after the container. Therefore, to insert elements into the DOM it is convenient to use the ng-container element, which will save us from the unnecessary element in the DOM. For me personally, this was an amazing revelation when I started debugging DOM markup and spent a lot of time to figure out where I was mistaken that my element was not inserted inside.


Secondly, dynamically added components do not support Input- and Output-Decorators and this is the most sad thing. For us, this translates into the fact that the component's ngOnChanges method will not be called when we assign a new Input value to the component variables. There are two ways out of this situation - to use the setter method for a component variable or to control the component redrawing manually from the parent component. You can also use ngDoCheck and compare attributes by yourself.


But let's complicate the task and suppose that we want to dynamically create a component that is in a module that lies in a separate file on the server. Straight "hare in a duck, duck in shock." But that's not all, we also want to create our component from the service, and not from the existing component. So, let's understand in order how this can be done.


The first thing we need is to get the factory of the desired component from the module and create an instance of the component. To download the js file from the server, I will use the SystemJS bootloader. Also, let the desired module be exported to the variable module from our JS file. Below is the code that solves the first part of our problem:


 (<any>window).System.import('module.js') .then((module: any) => module.module) .then((exportedModule: any) => this.compiler.compileModuleAndAllComponentsAsync(exportedModule)) .then((moduleWithFactories: ModuleWithComponentFactories<any>) => { const factory = moduleWithFactories.componentFactories.find((component) => { return component.componentType.name === componentName }); this.componentRef = this.content.createComponent(this.componentFactory, 0, this.injector); }) 

In this code, we used an Angular's JIT compiler to compile the loaded module, and this approach has one feature. So, if you use the AOT build of your project, the compiler will not be available at runtime and the specified code will not work. To solve this problem, you can create a separate module with a service where you will need to manually add a compiler, for example, as follows:


 import { NgModule, ModuleWithProviders, Compiler, COMPILER_OPTIONS, CompilerOptions, Optional } from '@angular/core'; import { JitCompilerFactory } from '@angular/compiler'; export function createJitCompiler(options?: CompilerOptions[]) { options = options || []; return new JitCompilerFactory([{ useDebug: false, useJit: true }]).createCompiler(options); } @NgModule({ ... }) export class DynamicComponentModule { static forRoot(metadata: NgModule): ModuleWithProviders { return { ngModule: DynamicComponentModule, providers: [ { provide: Compiler, useFactory: createJitCompiler, deps: [Optional(), COMPILER_OPTIONS] } ] } } } 

Now we have a component and it remains for us to understand how to insert it into the DOM from the service. After all, here we do not have ViewContainerRef, which we used earlier. To insert a component into the DOM, we need to do two things - find the root element of our application and insert the created View component into it. And here we will use the link to our angular-application which is accessible by means of DI through a variable with type ApplicationRef. To do this, we need to add the ApplicationRef to our class constructor


 constructor( private applicationRef: ApplicationRef, private injector: Injector ) {} 

So, to insert our component, you need to find a place to insert the View, and also do not forget to copy the necessary parameters into our component as we did before. Let's write a series of auxiliary functions to solve these problems:


 private getRootViewContainer(): ComponentRef<any> { const rootComponents = this.applicationRef['_rootComponents']; if (rootComponents.length) { return rootComponents[0]; } throw new Error('View Container not found!'); } getComponentRootNode(componentRef: ComponentRef<any>): HTMLElement { return (componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement; } projectComponentInputs(component: ComponentRef<any>, options: any): ComponentRef<any> { if (options) { const props = Object.getOwnPropertyNames(options); for (const prop of props) { component.instance[prop] = options[prop]; } } return component; } 

Now let's put it all together and get the following code that adds our component to the DOM:


 let location: Element = this.getComponentRootNode(this.getRootViewContainer()); let componentFactory = this.componentFactoryResolver.resolveComponentFactory(SomeComponent); let componentRef = componentFactory.create(this.injector); let appRef: any = this.applicationRef; let componentRootNode = this.getComponentRootNode(componentRef); const injector = ReflectiveInjector.resolveAndCreate([{ provide: 'dialog', useValue: componentRef }], this.injector); this.projectComponentInputs(componentRef, { options: { ... }, injector: injector }); appRef.attachView(componentRef.hostView); componentRef.onDestroy(() => { appRef.detachView(componentRef.hostView); componentRef = null; }); location.appendChild(componentRootNode); 

In this code there are a couple of points that need to be clarified. In addition to creating our component and finding a place to insert it into the DOM, we also created our own Injector and added our View component to the angular application by calling the attachView method so that it knows about us and runs the ChangeDetector. In addition, we hung a handler to destroy the component in which we remove the component from the application.


So, we have made a code that allows you to create a dynamic component that lies in an external file, and also insert it into the DOM from our service. I use this code in our product to create modal dialogs.


By the way, it is not necessary to pull the entire module from the outside. This technique is applicable in situations where you want to create components, having only a file with the contents of the template. For example, you can have the contents of a template on a server, and you want to create a View based on it. To do this, you will need to do the following:



, :


 private createComponentFromTemplateString(template: string) { @Component({ selector: 'some-selector', template: template }) class RuntimeComponent {} NgModule({ imports: [imports], providers: [providers], declarations: [RuntimeComponent] }) class RuntimeComponentModule {} this.compiler.compileModuleAndAllComponentsAsync(RuntimeComponentModule) .then((moduleWithFactories: ModuleWithComponentFactories<any>) => { ... }) } 

Embedded Solutions


, — ngTemplateOutlet ngComponentOutlet. DOM- , . . ngTemplateOutlet:


 @Component({ selector: 'some-component', template: ` <ng-container *ngTemplateOutlet="greet"></ng-container> <ng-container *ngTemplateOutlet="eng; context: myContect"></ng-container> <ng-container *ngTemplateOutlet="svk; context: myContect"></ng-container> <hr> <ng-template #greet><span>Hello</span></ng-template> <ng-template #eng let-name><span>Hello {{name}}!</span></ng-template> <ng-template #svk let-person="localSk"><span>Ahoj {{person}}!</span></ng-template> ` }) class NgTemplateOutletExample { myContext = {$implicit: 'World', localSk: 'Svet'} } 

, , . $implicit . , createEmbeddedView, . ngComponentOutlet.


 @Component({ selector: 'some-component', template: ` Hello World! ` }) class HelloWorldComponent {} @Component({ selector: 'component-outlet-example', template: ` <ng-container *ngComponentOutlet="HelloWorld"></ng-container> ` }) class ComponentOutletExample { HelloWorld = HelloWorldComponent } 

createComponent-, .


, . : DOM- . - , , , . , , , , , .


')

Source: https://habr.com/ru/post/330030/


All Articles