将 Angular 组件从许多输入/输出重构为单个配置对象

Posted

技术标签:

【中文标题】将 Angular 组件从许多输入/输出重构为单个配置对象【英文标题】:Refactoring Angular components from many inputs/outputs to a single config object 【发布时间】:2018-05-24 09:45:41 【问题描述】:

我的组件通常从具有多个 @Input@Output 属性开始。当我添加属性时,切换到单个配置对象作为输入似乎更简洁。

例如,这是一个具有多个输入和输出的组件:

export class UsingEventEmitter implements OnInit 
    @Input() prop1: number;
    @Output() prop1Change = new EventEmitter<number>();
    @Input() prop2: number;
    @Output() prop2Change = new EventEmitter<number>();

    ngOnInit() 
        // Simulate something that changes prop1
        setTimeout(() => this.prop1Change.emit(this.prop1 + 1));
    

及其用法:

export class AppComponent 
    prop1 = 1;

    onProp1Changed = () => 
        // prop1 has already been reassigned by using the [(prop1)]='prop1' syntax
    

    prop2 = 2;

    onProp2Changed = () => 
        // prop2 has already been reassigned by using the [(prop2)]='prop2' syntax
    

模板:

<using-event-emitter 
    [(prop1)]='prop1'
    (prop1Change)='onProp1Changed()'
    [(prop2)]='prop2'
    (prop2Change)='onProp2Changed()'>
</using-event-emitter>

随着属性数量的增加,似乎切换到单个配置对象可能会更干净。例如,这是一个采用单个配置对象的组件:

export class UsingConfig implements OnInit 
    @Input() config;

    ngOnInit() 
        // Simulate something that changes prop1
        setTimeout(() => this.config.onProp1Changed(this.config.prop1 + 1));
    

及其用法:

export class AppComponent 
    config = 
        prop1: 1,

        onProp1Changed(val: number) 
            this.prop1 = val;
        ,

        prop2: 2,

        onProp2Changed(val: number) 
            this.prop2 = val;
        
    ;

模板:

<using-config [config]='config'></using-config>

现在我可以通过多层嵌套组件传递配置对象引用。使用配置的组件会调用像config.onProp1Changed(...) 这样的回调,这会导致配置对象重新分配新值。所以看起来我们仍然有单向数据流。此外,添加和删除属性不需要更改中间层。

将单个配置对象作为组件的输入而不是多个输入和输出有什么缺点吗?像这样避免@OutputEventEmitter 会导致以后可能会遇到的任何问题吗?

【问题讨论】:

是的。每次更改都会触发更改检测。所以我推荐使用state-management 来跨组件共享数据。考虑阅读我的medium post 以开始使用 @Aravind 感谢您的文章。我不想在这里跨多个组件共享数据。我正在尝试将多个输入和回调传递给一个子组件。您是说如果子组件在配置对象上调用回调而不是发出事件,则更改检测将不起作用?另外 - 如果我正在构建一个开源组件并且我不想假设消费者正在使用商店怎么办? 如果它是一个开源项目,采用这种方式很好。但是输入和输出越多,就会触发变化检测。 @Aravind 我明白了,所以你关心的是一般有很多输入/输出。那么在某些情况下,使用单个配置对象而不是多个输入/输出可能会更高效。 “配置模型”是否假设子组件中没有使用ChangeDetectionStrategy.OnPush?因为如果设置了该策略,则单个对象模型does not appear to work,而不是具有多个输入/输出属性的模型still works。 【参考方案1】:

就个人而言,如果我发现我需要超过 4 个输入+输出,我会检查我的方法以再次创建我的组件,也许它应该不止一个组件而且我做错了什么。 无论如何,即使我需要那么多的输入和输出,我也不会在一个配置中实现它,原因如下:

1- 很难知道输入和输出中应该包含什么,如下所示: (考虑一个带有 to html 输入元素和标签的组件)

想象一下,如果你只有 3 个这个组件,并且你应该在 1 或 2 个月后回来从事这个项目,或者其他人会与你合作或使用你的代码!。 真的很难理解你的代码。

2- 缺乏表现。角度观察单个变量而不是观察数组或对象要便宜得多。除了考虑我第一个给你的例子,为什么你应该强制跟踪可能永远不会改变的标签以及总是在变化的值。

3- 更难跟踪变量和调试。角度本身带有难以调试的令人困惑的错误,我为什么要让它变得更难。跟踪和修复任何错误的输入或输出对我来说比在一个配置变量中的一堆数据中更容易。

就我个人而言,我更喜欢将我的组件分解为尽可能小并测试每个组件。然后用小的组件来制造更大的组件,而不是仅仅拥有一个大组件。

更新: 我使用这种方法一次输入并且没有更改数据(如标签)

@Component(
selector: 'icon-component',
templateUrl: './icon.component.html',
styleUrls: ['./icon.component.scss'],
inputs: ['name', 'color']
);

export class IconComponent implements OnInit 
 name: any;
 color: any;

 ngOnInit() 
 

HTML:

<icon-component name="fa fa-trash " color="white"></icon-component>

使用这种方法,angular 不会跟踪组件内部或外部的任何变化。 但是使用@input 方法,如果你的变量在父组件中发生变化,你也会在组件内部得到变化。

【讨论】:

感谢您的回复。 1 - 除了我没有使用既定做法这一事实之外,还有其他难以理解的原因吗?这似乎类似于将一个接受许多参数的方法重构为一个接受一个对象的方法。当然,现在有一些开销,因为您必须查看对象的类型才能理解属性。但是杂乱无章被消除了,所以这对我来说似乎是一个不错的权衡(但你是对的,也许对其他人来说更糟)。 2 - 通常我会使用@Input@Output 创建小组件,但有时外部组件会将它们分组并最终需要大量参数。这个外部组件是我喜欢重构的组件。它通常不需要跟踪每个值的更改,因为它只是将它们转发到其他小组件。但是在它确实需要跟踪每个属性的情况下,你是对的,现在它也在跟踪回调方法,这是不必要的。但是,当您说“为什么要强制跟踪可能永远不会改变的标签”时,如何转换这些标签 标签到@Input参数修复性能问题? Angular 不会继续跟踪它们吗? 3 - 这是一个很好的观点。也许 Angular 的错误可以帮助您使用 @Input@Output 更快地查明错误的位置。 “角度观察单个变量比观察数组或对象便宜得多”它并不便宜,它是一样的。它只是不适用于更改的数组/对象(假设 OnPush 策略)。【参考方案2】:

将单个配置对象作为组件的输入而不是多个输入和输出有什么缺点吗?

是的,当您想切换到 onpush change detection strategy(大型项目中通常需要它来缓解由于渲染周期过多导致的性能问题)时,Angular 将不会检测到发生在内部的更改你的配置对象。

像这样避免 @Output 和 EventEmitter 会导致以后可能会遇到的任何问题吗?

是的,如果您开始远离@Output 并在您的模板中直接对配置对象本身进行操作,那么您会在您的视图中造成副作用,这将是难以发现的错误的根源将来。你的视图不应该修改它注入的数据。从这个意义上说,它应该保持“纯粹”,并且只通过事件(或其他回调)通知控制组件发生了什么事。

更新:再次查看帖子中的示例后,您似乎并不是要直接对输入模型进行操作,而是直接通过配置对象传递事件发射器.通过@input(这是您隐式执行的操作)传递回调也有it's drawbacks,例如:

您的组件变得更难理解和推理(其输入与输出是什么?) 不能再使用banana box syntax

【讨论】:

这毫无意义。 Config 是一个输入,即使使用 OnPush 我也会触发更改检测 @ritaj 是的,使用 onpush,如果配置对象本身发生变化,它将触发重新渲染。但是 Angular 只会对输入进行浅层比较,这就是为什么如果配置对象本身的引用保持不变,配置对象的属性更改将被忽略。 (我现在在回答中将“内部”一词加粗,因为您似乎过度阅读了它......) @ritaj - 您可以在问题的my comment 中提到的 stackblitz 演示中看到他的意思。 您编辑的答案符合我的意思。我不是在尝试修改子对象中的对象——我几乎是在将回调作为输入传递。关于不再能够使用香蕉盒语法的好点。至于让推理变得更难,有时我会想“那又怎样,我会去配置的类型定义并在那里找到定义,这很糟糕吗?”。但这是开销,所以这是一个公平的观点。 @B12Toaster 我在 React 中的回调也没有问题,这让我思考为什么我们在 Angular 中使用事件。最后,正如您所说,最好坚持团队成员的习惯。【参考方案3】:

我想说为Inputs 使用单个配置对象是可以的,但您应该始终坚持使用Outputs。 Input 定义了您的组件从外部需要什么,其中一些可能是可选的。但是,Outputs 完全是组件的业务,应该在其中定义。如果您依赖用户传递这些函数,您要么必须检查 undefined 函数,要么继续调用函数,就好像它们总是在配置中传递一样,如果有的话,使用您的组件可能会很麻烦许多事件要定义,即使用户不需要它们。所以,总是在你的组件中定义你的Outputs 并发射你需要发射的任何东西。如果用户不绑定那些事件的函数,那很好。

另外,我认为为Inputs 设置单个config 并不是最佳做法。它隐藏了真正的输入,用户可能必须查看您的代码或文档内部以找出他们应该传递的内容。但是,如果您的 Inputs 是单独定义的,用户可以使用 Language Service 等工具获得一些智能感知

另外,我认为它也可能会破坏变更检测策略。

我们来看下面的例子

@Component(
    selector: 'my-comp',
    template: `
       <div *ngIf="config.a">
           config.b + config.c
       </div>
    `
)
export class MyComponent 
    @Input() config;

让我们使用它

@Component(
    selector: 'your-comp',
    template: `
       <my-comp [config]="config"></my-comp>
    `
)
export class YourComponent 
    config = 
        a: 1, b: 2, c: 3
    ;

对于单独的输入

@Component(
    selector: 'my-comp',
    template: `
       <div *ngIf="a">
           b + c
       </div>
    `
)
export class MyComponent 
    @Input() a;
    @Input() b;
    @Input() c;

让我们使用这个

@Component(
    selector: 'your-comp',
    template: `
       <my-comp 
          [a]="1"
          [b]="2"
          [c]="3">
       </my-comp>
    `
)
export class YourComponent 

正如我上面所说,您必须查看YourComponent 的代码以查看传入的值。此外,您必须在任何地方键入config 才能使用这些Inputs。另一方面,您可以清楚地看到在第二个示例中传递了哪些值。如果您使用 Language Service

,您甚至可以获得一些智能感知

另一件事是,第二个例子会更好地扩展。如果您需要添加更多Inputs,则必须一直编辑config,这可能会破坏您的组件。但是,在第二个示例中,添加另一个 Input 很容易,您无需接触工作代码。

最后但并非最不重要的一点是,您无法真正提供双向绑定。你可能知道,如果你在Input 中调用dataOutput 调用dataChange,你的组件的消费者可以使用双向绑定糖语法和简单类型

<your-comp [(data)]="value">

当您使用

发出事件时,这将更新父组件上的 value
this.dataChange.emit(someValue)

希望这能澄清我对单身Input的看法

编辑

我认为单个Input 有一个有效的案例,其中还定义了一些functions。如果您正在开发类似图表组件的东西,它通常需要复杂的选项/配置,实际上最好使用单个 Input。这是因为,该输入设置一次并且永远不会更改,并且最好将图表的选项放在一个地方。此外,用户可以传递一些函数来帮助您绘制图例、工具提示、x 轴标签、y 轴标签等。 在这种情况下,像下面这样的输入会更好

export interface ChartConfig 
    width: number;
    height: number;
    legend: 
       position: string,
       label: (x, y) => string
    ;
    tooltip: (x, y) => string;


...

@Input() config: ChartConfig;

【讨论】:

“另外,我认为它也可能会破坏变更检测策略。”这是我最希望的一点。你有任何证据吗?否则很好的答案,谢谢! @ritaj 正如 cmets 中提到的其他人一样,角度检查对象的引用是否已更改以触发更改检测。如果父组件启用了OnPush 策略,则config 内的任何更改都可能不会触发子组件内的更改检测。我还没有测试过自己,但我可以尝试让你知道 哦,好吧,每个 Input 属性都会发生这种情况。尽管如此,人们一直使用配置对象进行输入。问题更多是关于将输出也放入配置中。据我了解,是这样的。 那些configs 通常就像常量,设置一次,以后不太可能更改。如果是这种情况,可以使用单个 Input。但是,如果您的输入可能会随着时间而改变,那么使用单独的 Inputs 会更好。此外,除了极少数情况外,在 config 中包含 Outputs 是个坏主意,可能会使用户感到困惑。 @ritaj 我根据您的输入编辑了我的答案,谢谢。【参考方案4】:

如果您想将输入参数捆绑为一个对象,我建议您这样做:

export class UsingConfig implements OnInit 
    @Input() config: any;
    @Output() configChange = new EventEmitter<any>();


    ngOnInit() 
        // Simulate something that changes prop1
        setTimeout(() => 
          this.configChange.emit(
              ...this.config, 
              prop1: this.config.prop1 + 1
          );
        );
    

您在更改属性时正在创建一个新的配置对象。 您正在使用输出事件来发出更改的配置对象。

这两点确保 ChangeDetection 能够正常工作(假设您使用更高效的 OnPush 策略)。此外,在调试时更容易遵循逻辑。

编辑: 这是父组件中最明显的部分。

模板:

<using-config [config]="config" (configChange)="onConfigChange($event)"></using-config>

代码:

export class AppComponent 
    config = prop1: 1;

    onConfigChange(newConfig: any)
      // if for some reason you need to handle specific changes 
      // you could check for those here, e.g.:
      // if (this.config.prop1 !== newConfig.prop1)...

      this.config = newConfig;
    
  

【讨论】:

但是我不知道父组件如何知道哪个属性发生了变化。 当然,这基本上是推荐的做事方式(同时也强制执行不变性)。我知道我可以(也许应该)这样做,但我更多的是寻找我建议的另一种方式的缺点。您简要地谈到它,说这种方式可以确保更改检测工作(但我很确定它也可以按我的方式工作)并且更容易调试(可能是一个公平的观点)。 你是对的,除非我也让配置不可变,否则我无法切换到“On Push”策略。这对我来说通常不是问题,因为using-config 组件通常只是将配置属性传递给另一个较小的组件,它自己进行更改跟踪。但情况并非总是如此,所以你说得有道理。【参考方案5】:

Input 除了其明显的功能外,还有一个意义在于使您的组件具有声明性和易于理解。

将所有配置放在一个巨大的对象中,这肯定会增长(相信我)是一个坏主意,出于上述所有原因以及测试。

使用简单的input 属性来测试组件的行为要比提供一个巨大的令人困惑的对象要容易得多。

1234563你应该提供或不提供,然后你继续复制粘贴这个未知且不断增长的对象到你的组件中,他们可能甚至不需要它们

使用简单的Inputs 创建默认值非常简单明了,而创建默认值的对象则有点混乱。

如果你有太多类似的InputOutputs,可以考虑如下:

1- 您可以创建一个Base 类并放置所有相似的Input/Outputs,然后从中扩展您的所有组件。

export class Base
    @Input() prop1: number;
    @Output() prop1Change = new EventEmitter<number>();
    @Input() prop2: number;
    @Output() prop2Change = new EventEmitter<number>();


@Component()
export class MyComponent extends from Base
      constructor()super()

2- 如果你不喜欢这样,你可以使用组合并创建一个可重复使用的mixin,然后像这样应用你所有的Input/Outputs。

下面是一个可用于应用 mixins 的函数示例,NOTE 不一定是您想要的,您需要根据需要进行调整。

export function applyMixins(derivedCtor: any, baseCtors: any[]) 
  baseCtors.forEach(baseCtor => 
    Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => 
      derivedCtor.prototype[name] = baseCtor.prototype[name];
    );
  );

然后创建你的 mixins:

export class MyMixin
    @Input() prop1: number;
    @Output() prop1Change = new EventEmitter<number>();
    @Input() prop2: number;
    @Output() prop2Change = new EventEmitter<number>();


applyMixins(MyComponent, [MyMixin]);

3- 您可以为输入设置默认属性,以便仅在需要时覆盖它们:

export class MyComponent
    @Input() prop1: number = 10; // default 

【讨论】:

基类方法有点好,因为它稍微清理了组件的内部实现,同时坚持使用原生的@Input/@Output。有趣的是,共识似乎是理解一个对象比理解众多参数更难。我实际上喜欢所有东西都包裹在一个物体中。它看起来很整洁,如果我想了解细节,我可以去类型定义。但我认为是混乱的,其他人认为是清晰和透明的。很高兴得到其他人的看法,感谢您的回复。 在某些情况下,默认参数可以减少混乱(包装具有许多属性的表单元素的组件,甚至可能是其他人提到的图表示例)。 Mixins 也是一个很酷的想法——你在团队环境中使用过吗? @FrankModica,不用担心。我还添加了关于默认值的另一条注释,因为创建默认值的对象更难且性能更低。您必须一直克隆它并且...

以上是关于将 Angular 组件从许多输入/输出重构为单个配置对象的主要内容,如果未能解决你的问题,请参考以下文章

有没有办法为 Angular 1.5 组件动态渲染不同的模板

如何从 rxjs 流中过滤单个值以在 Angular 2 组件中打印

Angular2 2方式绑定中同名的自定义输入和输出

Angular 1.5 组件依赖注入

将 React 组件从函数重构为 ES6 类

通过将组件名称指定为输入参数,将 Angular 组件导入另一个组件