Dynamically bind model and template to at DOM node

2020-06-17 02:07发布

问题:

Short version

This Plunker defines a <view> component which can render an arbitrary model+template. This needs to be changed to replace the previously rendered contents rather than appending new peers.

EDIT: This is working now, thanks to the response by user3636086.

One problem still remains: unlike Angular 1, Angular 2 forces me to create a nested component to update a template (since templates are effectively a static property of a component's class), so I have a bunch of unnecessary DOM nodes being added.


Long Version

Angular 1

In our project we'd prefer most of our code to have no direct dependency on a UI framework. We have a viewmodel class which ties together a model and view. Here are simplified examples:

interface IView {
    template: string;
}

class SalesView implements IView  {
    sales: number = 100;
    get template() { return "<p>Current sales: {{model.sales}} widgets.<p>"; }
}

class CalendarView implements IView {
    eventName: string = "Christmas Party";
    get template() { return "<p>Next event: {{model.eventName}}.<p>"; }
}

class CompositeView implements IView  {
    calendarView = new CalendarView();
    salesView = new SalesView();
    get template() { return 
        `<div view='model.salesView'></div>
        <div view='model.calendarView'></div>`; 
    }
}

We have a view directive that can display one of these views:

<div view='viewInstance'></div>

If viewInstance changes, a new View object is rendered (model + template) at that location in the DOM. For instance, this Dashboard view can have an arbitrary list of views that it can render:

class Dashboard implements IView {
    views: Array<IView> = [ new SalesView(), new CalendarView(), new CompositiveView() ];
    activeView: View;
    get template() { return "<h1>Dashboard</h1>  <div view='model.activeView'>"; }
}

A crucial point is that this is composable. The <view> can contain a <view> which can contain a <view>, so on and so forth.

In Angular 1, our view directive looks something like this:

.directive("View", [ "$compile",
    ($compile: ng.ICompileService) => {
        return <ng.IDirective> {
            restrict: "A",
            scope: { model: "=View" },
            link(scope: ng.IScope, e: ng.IAugmentedJQuery, atts: ng.IAttributes): void {
                scope.$watch((scope: any) => scope.model, (newValue: any) => {
                    e.html(newValue.template);
                    $compile(e.contents())(scope.$new());
                });
            }
        };
    }
]);

Angular 2

I'm trying to port this to Angular 2, but dynamically loading a new template at a DOM location is very clunky, forcing me to create a new component type every time.

This is the best I've come up with (updated with feedback from user3636086):

@Component({
  selector: 'view',
  template: '<span #attach></span>',
})
export class MyView {
    @Input() model: IView;

    previousComponent: ComponentRef;

    constructor(private loader: DynamicComponentLoader, private element: ElementRef) {
    }

    onChanges(changes: {[key: string]: SimpleChange}) {
        var modelChanges = changes['model']
        if (modelChanges) {
            var model = modelChanges.currentValue;
            @Component({
                selector: 'viewRenderer',
                template: model.template,
            })
            class ViewRenderer {
                model: any;
            }
            if (this.previousComponent) {
                this.previousComponent.dispose();
            }
            this.loader.loadIntoLocation(ViewRenderer, this.element, 'attach')
                .then(component => {
                    component.instance.model = model;
                    this.previousComponent = component;
                });
        }
    }
}

Used something like this:

@Component({
    selector: 'app',
    template: `
        <view [model]='currentView'></view>
        <button (click)='changeView()'>Change View</button>
    `,
    directives: [MyView]
})
export class App {
    currentView: IView = new SalesView();
    changeView() {
        this.currentView = new CalendarView();
    }
}

EDIT: This had problems that have now been fixed.

The remaining problem is that it creates a bunch of unnecessary nested DOM elements. What I really want is:

<view>VIEW CONTENTS RENDERED HERE</view>

Instead we have:

<view>
      <span></spawn>
      <viewrenderer>VIEW CONTENTS RENDERED HERE</viewrenderer>
</view>

This gets worse the more views we have nested, without half the lines here being extraneous crap:

<view>
    <span></spawn>
    <viewrenderer>
        <h1>CONTENT</h1>
        <view>
            <span></spawn>
            <viewrenderer>
                <h1>NESTED CONTENT</h1>
                <view>
                    <span></spawn>
                    <viewrenderer>
                        <h1>NESTED NESTED CONTENT</h1>
                    </viewrenderer>
                </view>
            </viewrenderer>
        </view>
    </viewrenderer>
    <viewrenderer>
        <h1>MORE CONTENT</h1>
        <view>
            <span></spawn>
            <viewrenderer>
                <h1>CONTENT</h1>
            </viewrenderer>
        </view>
    </viewrenderer>
</view>

回答1:

Short version

see https://github.com/angular/angular/issues/2753 (the recent comments, not the original issue)


Long version

I have a similar use-case and have been keeping an eye on chatter about recommended approaches to it.

As of now, DynamicComponentLoader is indeed the de-facto tool for dynamic component compilation (read: stand-in for $compile) and the approach you've taken in your example is essentially identical to this one, which @RobWormald has posted in response to several similar questions on gitter.

Here's another interesting example @EricMartinez gave me, using a very similar approach.

But yes, this approach feels clunky to me too, and I've yet to find (or come up with) a more elegant way of doing this with DCL. The comments on the github issue I linked above contain a third example of it, along with similar criticisms that have so far gone unsanswered.

I have a hard time believing that the canonical solution for a use-case as common as this will be so clunky in the final release (particularly given then relative elegance of $compile), but anything beyond that would be speculation.

If you grep "DCL" or "DynamicComponentLoader" in the gitter thread, there are several interesting conversations on this topic there. One of the core team guys said something to the effect of "DCL is a power-tool that we only expect will be used by people doing really framework-ey things" - which I found... interesting.

(I'd have quoted/linked to that directly if gitter's search didn't suck)



回答2:

The correct behavior can be achieved with minor changes in your code: you have to "dispose" the previously created component before adding a new one.

savedComp: Component = null;
...
if (this.savedComp) {
  this.savedComp.dispose();
}
this.loader.loadIntoLocation(DynamicComponent, this.element, 'attach')
    then((res) => {res.instance.model = model; this.savedComp = res;});

Full solution here: http://plnkr.co/edit/KQM31HTubn0LfL7jSP5l

Hope it helps!