Angular 中的反应状态:Angular Effects 简介

2025-05-27

Angular 中的反应状态:Angular Effects 简介

Angular 是一个强大的框架。它有可观察对象、TypeScript 和依赖注入。太棒了!但是如何管理状态呢?这得靠我们自己去解决。

市面上有一些很棒的状态管理库,比如 NgRx。但它们只处理全局状态。一旦我们试图将它们粘贴到组件中,事情就会变得混乱。

我需要的是组件的状态管理。我希望它能够连接到全局状态,并且易于使用。我不希望它被一堆样板代码弄得杂乱无章。我希望我的模板是同步的,没有异步管道。而且我不想管理订阅。

类似的解决方案已经尝试过很多次,但至今仍未出现令人满意的结果。如果您已经考虑过这些,那么让我们来看一些代码。

Angular Effects简介:Angular 的反应式扩展

@Component({
    selector: "app-root",
    template: `
        <div>Count: {{count}}</div>
    `,
    providers: [Effects],
})
export class AppComponent {
    @Input()
    count: number = 0

    constructor(connect: Connect) {
        connect(this)
    }

    @Effect("count")
    incrementCount(state: State<AppComponent>) {
        return state.count.pipe(
            take(1),
            increment(1),
            repeatInterval(1000)
        )
    }
}

这个库是一组响应式原语,填补了 Angular 响应式 API 的空白。它使观察和响应组件状态变得既简单又容易。以下是一些亮点:

  • 您可以观察组件上任何属性的变化
  • 这使您可以编写完全反应式的应用程序
  • 当组件被销毁时,订阅会自动清除
  • 您可以通过管道输入和输出
  • 您可以将有状态的行为提取到服务中
  • 你可以抛弃异步管道
  • 你可能不需要生命周期钩子
  • 无论有无区域均可工作
  • 你可以组合所有可观察的来源
  • 您可以使用适配器进行扩展
  • 变更检测“有效”,你可以对其进行微调
  • 使用此库时不会损害任何组件(组合优于继承)

为什么要使用 Angular Effects

Angular Effects 生命周期

Angular Effects 生命周期

更简单的模板

Angular 中一个很大的复杂点在于模板中异步数据的处理方式。一些常见的问题如下:

默认值:使用异步绑定显示默认值

@Component({
    template: `
        <ng-container *ngIf="count$ | async as count">
            {{ count }}
        </ng-container>
    `
})
export class AppComponent {
    count$ = timer(1000).pipe(
        mapTo(10),
        startWith(0), // default value
    )
}

使用 Angular Effects 组件模板始终是同步的。

@Component({
    template: `
        {{ count }}
    `
})
export class AppComponent {
    count = 0

    @Effect("count")
    setCount(state: State<AppComponent>) {
        return timer(1000).pipe(
            mapTo(10)
        )
    }
}

多个订阅者:在模板的不同部分多次绑定异步源

因为每个订阅者都会触发可观察对象中的整个操作链,所以我们必须小心,不要意外地多次触发某些效果,例如 http 请求。

@Component({
    template: `
        <button *ngIf="count$ | async as count">{{ count }}</button>
        <a *ngIf="count$ | async as count">{{ count }}</a>
    `
})
export class AppComponent {
    count$ = this.http.get("/getCount").pipe(
        startWith(0)
    )

    constructor(private http: HttpClient) {}
}

此组件渲染时会进行两次 http 调用,每个订阅一次。这个问题可以通过将异步管道移至共同的祖先组件来缓解。

<ng-container *ngIf="count$ | async as count">
    <button>{{ count }}</button>
    <a>{{ count }}</a>
</ng-container>

或者使用共享运算符

export class AppComponent {
    count$ = this.http.get("/getCount").pipe(
        startWith(0),
        share()
    )

    constructor(private http: HttpClient) {}
}

然而,前者并不总是可行的,而且很难知道在何处或何时使用后者。

使用 Angular Effects,我们只需订阅一次。

@Component({
    template: `
        <button>{{ count }}</button>
        <a>{{ count }}</a>
    `
})
export class AppComponent {
    count = 0

    constructor(private http: HttpClient) {}

    @Effect("count")
    getCount(state: State<AppComponent>) {
        return this.http.get("/getCount")
    }
}

异步组合:具有依赖于输入值的嵌套异步绑定的模板

@Component({
    template: `
        <ng-container *ngIf="author$ | async as author">
            <ng-container *ngIf="author">
                <div *ngFor="let book of books$ | async">
                    <p>Author: {{ author.name }}</p>
                    <p>Book: {{ book.title }}</p>
                </div>
            </ng-container>
        </ng-container>
    `
})
export class AppComponent {
    @Input()
    authorId = 1

    author$ = this.getAuthor()
    books$ = this.getBooks()

    getAuthor() {
        this.author$ = this.http.get(`/author/${this.authorId}`)
    }

    getBooks() {
        this.books$ = this.http.get(`/books?authorId=${this.authorId}`)
    }

    ngOnChanges(changes) {
        if (changes.authorId) {
            this.getAuthor()
            this.getBooks()
        }
    }
}

这段代码的一个问题是,由于嵌套在模板中,所以books$直到解析完成才会获取。这个问题可以通过将这些可观察对象合并到单个数据源中来解决,但这可能难以管理。我们希望分别订阅各个数据流,并且不阻塞模板。author$ngIf

使用 Angular Effects,我们可以并行订阅流并同步渲染它们。

@Component({
    template: `
        <ng-container *ngIf="author">
            <div *ngFor="let book of books">
                Author: {{ author.name }}
                Book: {{ book.title }}
            </div>
        </ng-container>
    `
})
export class AppComponent {
    @Input()
    authorId: number

    author?: Author = undefined

    books: Book[] = []

    @Effect("author")
    getAuthor(state: State<AppComponent>) {
        return state.authorId.pipe(
            switchMap(authorId => this.http.get(`/author/${authorId}`))
        )
    }

    @Effect("books")
    getBooks(state: State<AppComponent>) {
        return state.authorId.pipe(
            switchMap(authorId => this.http.get(`/books?authorId=${authorId}`))
        )
    }
}

你可能不需要生命周期钩子

我们可以观察组件的状态并围绕它们编写效果。这就是为什么你可能不需要生命周期钩子的原因。

OnInit

目的:允许在对传入组件的输入和静态查询进行任何逻辑处理之前处理它们的初始值。

由于我们只能在这些值发生变化时观察它们,因此我们可以丢弃这个钩子。

OnChanges

目的:每当组件的输入发生变化时得到通知。

由于我们只能在这些值发生变化时观察它们,因此我们可以丢弃这个钩子。

AfterContentInit

目的:等待内容子项初始化后再对它们进行任何逻辑处理。

我们可以观察两者@ContentChild()@ContentChildren()因为它们只是组件上的属性。我们可以丢弃这个钩子。

AfterViewInit

目的:等待视图子项初始化完毕后再对它们执行任何逻辑。此外,此时组件已完全初始化,DOM 操作也变得安全。

我们可以同时观察它们@ViewChild()@ViewChildren()因为它们只是组件上的属性。对于命令式 DOM 操作,效果可以延迟到组件渲染完成。我们可以放弃这个钩子。

OnDestroy

目的:组件销毁后清理变量以便垃圾回收,防止内存泄漏。

由于每个效果都是可观察对象的接收器,因此我们不会经常需要这个钩子。

可观察的主机监听器和模板事件

EventEmitterAngular Effects 提供了名为的扩展HostEmitter,可以用作替代品。HostEmitter这使得观察成为可能HostListener,并且通常使得使用 Angular Effects 变得更容易。

例如,这里有一个按钮,用于HostListener观察点击事件,如果未被禁用则传递这些事件。

@Component({
    selector: "button[ngfx-button]"
})
export class ButtonComponent {
    @Input()
    disabled = false

    @HostListener("click", ["$event"])
    clicked = new HostEmitter<MouseEvent>()

    @Output()
    pressed = new HostEmitter<MouseEvent>()

    @Effect("pressed")
    handleClick(state: State<AppComponent>) {
        return state.clicked.pipe(
            withLatestFrom(state.disabled, (event, disabled) => disabled ? false : event),
            filter(Boolean)
        )
    }
}

这是一个使用按钮的组件,从模板中观察其事件并在单击时禁用该按钮。

@Component({
    template: `
        <button ngfx-button [disabled]="buttonDisabled" (pressed)="buttonPressed($event)">
            Click me
        </button>
    `
})
export class AppComponent {
    buttonDisabled = false
    buttonPressed = new HostEmitter<MouseEvent>()

    @Effect("buttonDisabled")
    disableButton(state: State<AppComponent>) {
        return state.buttonPressed.pipe(
            mapTo(true)
        )
    }
}

无渲染组件

无渲染组件由 Vue 推广,它是一种没有视图的组件,没有模板的行为。我们称之为 mixin。但在 Angular 中使用 mixin 并不容易。Angular Material向我们展示了我们需要克服多少障碍。

Angular Effects 最终让这一切变得简单易行。它通过将组件中的所有状态行为提取到可注入的服务中来实现。

让我们看看它如何变得简单。

@Component({
    selector: "button[ngfx-button]"
})
export class ButtonComponent {
    @Input()
    disabled = false

    @HostListener("click", ["$event"])
    clicked = new HostEmitter<MouseEvent>()

    @Output()
    pressed = new HostEmitter<MouseEvent>()

    @Effect("pressed")
    handleClick(state: State<AppComponent>) {
        return state.clicked.pipe(
            withLatestFrom(state.disabled, (event, disabled) => disabled ? false : event),
            filter(Boolean)
        )
    }
}

我们可以将效果提取到服务中。我们还会进行一些调整,以摆脱 HostListener。

interface ButtonLike {
    disabled: boolean
    pressed: HostEmitter<MouseEvent>
}

function registerOnClick(elementRef, renderer) {
    return function(handler) {
        return renderer.listen(elementRef.nativeElement, "click", handler)
    }
}

@Injectable()
export class Button {
    constructor(private elementRef: ElementRef, private renderer: Renderer2) {}

    @Effect("pressed")
    handleClick(state: State<ButtonLike>) {
        return fromEventPattern(registerOnClick(this.elementRef, this.renderer)).pipe(
            withLatestFrom(state.disabled, (event, disabled) => disabled ? false : event),
            filter(Boolean)
        )
    }
}

这是我们的无渲染按钮。用户只需实现接口、提供 token 并编写模板即可使用它。

@Component({
    selector: "button[ngfx-button]",
    providers: [Effects, Button]
})
export class ButtonComponent implements ButtonLike {
    @Input()
    disabled = false

    @Output()
    pressed = new HostEmitter<MouseEvent>()

    constructor(connect: Connect) {
        connect(this)
    }
}

前面的示例省略了使效果运行所需的连接。这里简单解释一下,每个组件Effects至少需要提供 ,然后connect()在属性初始化后在构造函数中调用。可以通过将 添加到 来添加更多效果providers

现在我们有了一个可复用的Button“特征”,可以用来构建不同类型的按钮,或者与其他效果组合起来实现更有趣的功能。例如,一个 select 组件可以由、 和Button特征Select组合而成。OptionDropdown

替代文本

Angular Effects 为 Angular 提供了无渲染组件。

反应式应用程序

我们只是粗略地了解了 Angular Effects 的功能。在接下来的文章中,我将带你深入探索该 API、它的工作原理,并提供更多示例,展示如何使用它来使用 Angular 构建更好的响应式应用程序。

您现在就可以开始使用 Angular Effects,让您的应用程序更具响应性。欢迎反馈。借助 Angular Effects,我们可以从上到下编写真正的响应式应用程序。

感谢阅读!

npm install ng-effects

致谢

如果没有Michael Hladky其他人在此 RFC 中提供的出色研究和演示,我就不可能创建这个库

进一步阅读

本系列的下一篇

文章来源:https://dev.to/stupidawesome/reactive-adventures-in-angular-introducing-angular-effects-1epf
PREV
最终 React 项目
NEXT
2024 年你应该尝试的 8 款开发者工具