Angular 中的动态组件

399 阅读4分钟

我们来看以下几个例子:

openDialog(enterAnimationDuration: string, exitAnimationDuration: string): void {
    this.dialog.open(DialogAnimationsExampleDialog, {
      width: '250px',
      enterAnimationDuration,
      exitAnimationDuration,
    });
  }
  
// dialog
@Component({
  selector: 'dialog-animations-example-dialog',
  templateUrl: 'dialog-animations-example-dialog.html',
})
export class DialogAnimationsExampleDialog {
  constructor(public dialogRef: MatDialogRef<DialogAnimationsExampleDialog>) {}
}

熟悉 angular 的话应该知道,所有的 dialog, 都必须建一个 dialog component 用来显示 dialog。这个 component 中有一套获取 dialog 参数的方法。假设我现在需要在 dialog 显示某个已经存在的组件,比如说:

class UserComponnet {
  @Input() userName: string;
  //.....
}

我们唯一能做的事情,就是将 UserComponent 包裹在一个叫做 UserDialogComponent 的组件中,这个组件只是简单的将 dialog 中的参数传递给子组件的 input。

这种事情做了很多遍以后,很容易就能够想到,有没有可能,我们写一个 dynamicDialogComponnet, 然后将 component 和 props 作为参数传递给它,这样就省去了创建 container component 的过程。

类似:

this.dialog.open(DynamicDialogComponnet, {
      width: '250px',
      data: {
        widgetConfig: {
          component: UserComponent,
          props: {
            userName: 'xxxxxxx'
          }}
      },
    });

那么可能你就要问了,为什么不直接把 userComponent 写成 userDialogComponnet, 要多此一举的抽离呢。有两个原因,将 component 的逻辑与具体的使用场景抽离是有好处的,比如说,当你写 dialog 的时候,userComponent 可能已经存在了。或者 userComponent 可能会用在 dialog 或者页面中,这样使得你不得不写单独的 dialog wrapper 的逻辑。

如果是 react 这里的逻辑就非常简单,直接传递一个 JSX 就行,在 angular 中 component 的本质是 create instance。基本上,我们只能写类似的逻辑

{
  component: xxx
  props: xx 
}

如何实现我们先按下不表。

类似的需求。必须说,我们需要 userComponent 显示一个单独的页面,userName 通过 URL 传递。这时候我们想到了可以直接用 routing.

{
  path: 'user',
  component: UserComponent
}

这里你会发现,我们遇到了类似的问题,需要在 userComponet 中通过 ActivatedRoute 获取路由信息。或者相同的,我们给 userComponent 包一个 wrapper, userPageComponent。里面只是把 userName 传递给 userComponent。

写多了以后,我们一样会觉得,这个 wrapper 应该被一个组件替代,比如:RouteWrapperComponent,使用方法可以类似于:

{
  path: 'user',
  component: RouteWrapperComponent,
  data: {
    widgetConfig: {
      component: UserComponent,
      props: (activatedRouter: ActivatedRouteSnapshot) => {
        return ({
          userName: activatedRouter.params.userName,
        });
      },
    }
  }
}

如果你用过 AgGrid 的话,会发现,它在渲染 component 的时候巨麻烦。

{
  headerName: 'Days of Air Frost',
  cellRenderer: DaysFrostRenderer,
  cellRendererParams: { rendererImage: 'frost.png' },
}

// DaysFrostRenderer
export class DaysFrostRenderer implements ICellRendererAngularComp {
  params!: ICellRendererParams & { rendererImage: string };
  rendererImage!: string;
  value!: any[];

  agInit(params: ICellRendererParams & { rendererImage: string }): void {
    this.params = params;
    this.updateImage();
  }
  
  //,,,

假设,我们要渲染 UserComponent 的话,一样有类似的问题,需要先写一个 wrapper component 实现 ICellRendererAngularComp, 然后再调用 userComponent 传递参数。如果不想每次都创建一个 wrapper component,我们也可以创建一个类似的组件,

{
  headerName: 'Days of Air Frost',
  cellRenderer: DynamicWidgetRenderer,
  cellRendererParams: { 
    widgetConfig: {
      component: UserComponent,
      props: (data) => {
        return data.userName;
      }
    }
  },
}

综合以上三个例子,我们可以只创建一个 userComponet, 在三个不同的场景下被调用。并且不需要专门为它创建一个 wapper component。

下面,我们来看一下这个 dynamicWidgetComponent 该如何实现:

我们知道,如果是 React,这里的调用可能就一行:<user userName={'zhangsan'} /> 对于 Angular,其实原理也很简单:在 dynamicComponent 中动态 create component, 挂载到当前的 component 中。并且动态的修改 instance,然后出发点 change detection 即可。

// Angular 13 后可能有更简单的方法。
 private createComponent(component: ComponentType<C>, ngContents?: NgContents) {
    const viewContainerRef = this.insertionDirective.viewContainerRef;
    viewContainerRef.clear();

    const componentFactory = this.componentFactoryResolver.resolveComponentFactory(component);
    this.componentRef = viewContainerRef.createComponent(componentFactory, 0, undefined);
    return this.componentRef.instance;
  }

那么我们应该如何设置 componet 的参数呢。如果是固定的 props 值,并且只关心 input 的话,很简单:

private setPropsToComponent(componentInstance: C, props: Props<C>,) {
  getKeys(props).forEach((propKey) => {
    const propValue = props[propKey];

    if (this.componentRef?.instance) {
      this.componentRef.instance[propKey] = propValue;
    }
  });
}

如果想像一个普通的 component 一样,props 变化后重新渲染 component, 并且支持 output,就比较麻烦了。我们先看第一个问题。

如何在 props 变化后重新渲染 component。当然,因为 component 本身也是参数,说明它也是可能变化的。我们只需要简单的 dintinctUntilChanged 一下 component 和 props 的值,变化了重新执行对应的 method 就行了。(注意,如果 component 变化了,应该重新 createComponet 并且 重新 setProps)

接下来就是比较棘手的问题,如何处理 output。在 props 中 output 该如何处理呢。我们先看 HTML 中我们是如何调用 output 呢。假设 userComponet 中存在 output userChange: EventEmit

<user userName="zhangsan" (userChange)="setUser($event)"></user>

不难理解,在这里,output 等价于 function 的调用。

($event) => setUser($event) 

这也和 react, vue 中使用 function 作为向上传递数据方式的一致。 Angular 通过这种避免 function 传递的方式,有效的避免了 this 的问题。避免 react class component 中需要大量的 bind(this) 的行为。

那么很明显,在作为参数时,我们只能将 function 作为 props 的参数传递,实现 output 的行为。

{
  component: UserComponet,
  props: {
    userName: 'zhangsan', 
    userChange: (user) => {
      setUser(user);
    }
  },
}

搞清楚用 function 来表示 output 时,我们只需要简单的 subscribe instance 对应的 props 就可以了。

const propValue = props[propKey];
const propValueInComponent = (componentInstance)?.[propKey];
    
if (typeof propValue === 'function' && propValueInComponent && isObservable(propValueInComponent)) {
  this.propsSubscriptions[propKey] = propValueInComponent.subscribe(($event) => {
    propValue($event);
  });
// ...

当然,我们还需要有效的处理 subscription。

if (typeof propValue === 'function' && propValueInComponent && isObservable(propValueInComponent)) {
  const subscription = this.propsSubscriptions?.[propKey];

  if (subscription) {
    subscription.unsubscribe();
  }

  this.propsSubscriptions[propKey] = propValueInComponent.pipe(
    observeOn(asyncScheduler),
  ).subscribe(($event) => {
    propValue($event);
  });

  return;
}

为什么要加 asyncScheduler?因为我们希望等 props 都设置完以后再开始执行 subscribe 中的方法,而不是在设置某一个 output 的时候直接触发。至于原理,我们可以单开一遍讲。

以上。