使用ngrx / store在Angular 2应用中管理状态

Sebastian SeitzMark BrownVildan Softic同行评议了使用ngrx / store在Angular 2 Apps中管理状态。 感谢所有SitePoint的同行评审人员使SitePoint内容达到最佳状态!

两位科学家打开拿着薛定inger猫的盒子

我们为Web应用程序构建的组件通常包含状态。 连接组件可能导致共享可变状态:这很难管理并且导致不一致。 如果我们有一个地方可以改变状态并让消息完成其余的工作,该怎么办? ngrx / store是使用RxJS的Redux for Angular的实现,它将强大的模式引入了Angular世界。

在本文中,我将介绍共享可变状态的问题,并展示如何使用ngrx / store库解决此问题,并将单向数据流体系结构引入Angular 2应用程序。 在此过程中,我们将构建一个示例应用程序 ,允许用户使用YouTube API搜索视频。

注意:您可以在此GitHub存储库中找到本文随附的代码。

并发问题

建立相互通信的组件是涉及状态的典型任务。 我们经常必须与具有相同状态的不同Angular组件保持最新联系:当多个组件访问并修改该状态时,我们称其为共享可变状态

要了解共享可变状态为何代表问题,请考虑由两个不同用户使用的计算机。 有一天,第一个用户将操作系统更新为最新版本。 一天后,第二个用户打开了计算机,并且由于用户界面没有明显的变化而感到困惑。 发生这种情况是因为两个用户可以在不互相交谈的情况下修改同一个对象(在本例中为计算机)。

实践中的共享可变状态

共享状态的一个常见示例是我们正在执行的操作的属性集。 如果我们正在执行数据库搜索,则将该功能集称为当前搜索 。 从现在开始,我将把这样的集合称为search对象

想象一个页面,它使您可以按名称搜索某些内容,还可以按地理位置限制搜索。 该页面将至少具有两个可以修改当前搜索属性的不同组件。 最有可能的是,将有一个服务负责执行实际搜索。

规则是:

  • 如果名称字段为空,请清除搜索结果
  • 如果仅定义了名称,则按名称执行搜索
  • 如果同时定义了名称和位置,请按名称和位置执行搜索
  • 为了按位置搜索,必须同时提供坐标(纬度/经度)和半径

可用方法

解决共享可变状态问题的一种方法可能是在组件和服务之间来回转发搜索对象,并允许每个对象都对其进行修改。

这将导致更加冗长和复杂的测试,这非常耗时且容易出错:对于每个测试,您都需要模拟对象,仅更改某些属性以仅测试特定行为。 所有这些测试和模拟也都需要维护。

每个组件访问状态

同样,与状态交互的每个组件都需要托管逻辑来做到这一点。 这损害了组件的可重用性,并且违反了DRY原则

一种替代方法是将搜索对象封装到服务中,并公开一个基本API来修改搜索值。 但是,该服务将负责三项不同的工作:

  • 执行搜索
  • 保持状态一致
  • 应用参数规则

单一责任原则相距甚远,该服务现在已成为应用程序本身,不能轻易重用。

即使将该服务拆分为较小的服务,仍然会导致我们拥有不同的服务或组件来修改相同数据的情况。

一项服务负责一切

此外,组件正在消耗服务,因此没有服务就无法使用它们。

一种不同且经常使用的模式是将所有逻辑放入应用程序层,但是最终我们仍然要花费大量代码来保证状态的一致性。

我的意见是,应用程序层(这是真正的独特特征)应仅应用规则。 基础架构可以处理其他任务,即消息传递,存储和事件。

Redux方法

此方法基于Facebook近年来开发的Flux应用程序架构模型以及Elm Architecture

AngularJS开发人员也可以在多种实现中使用此模式。 在本教程中,我们将使用ngrx / store,因为它是ngrx包的一部分,该包是Reactive Extensions的正式Angular 2包装器。 此外,它使用Observables实现Redux模式,从而与Angular 2架构保持一致。

它是如何工作的?

  1. 组件发出动作
  2. 操作被调度到状态存储
  3. 减速器功能基于这些动作得出新状态
  4. 通知订户新状态

因此,我们可以分担责任,因为Ngrx / store负责状态一致性,而RxJS带来了消息总线。

一项服务负责一切

  • 我们的组件不会知道服务或应用程序逻辑:它们只是发出操作。
  • 我们的服务没有状态:它只是基于来自外部的搜索对象执行搜索。
  • 我们的应用程序组件仅侦听状态更改并决定要做什么。
  • 新条目减速器实际上将对动作做出反应,并在必要时修改状态。
  • 突变的一个切入点。

示例:YouTube搜索组件

我们将编写一个小型应用程序,以使用YouTube API搜索视频。 您可以在下面看到运行的最终演示

克隆入门仓库

克隆存储库的此处开始版本。 在app/文件夹中,我们将找到要使用的实际应用程序文件:

project
├── app
│   ├── app.module.ts
│   ├── app.component.ts
│   └── main.ts
├── index.html
├── LICENSE
├── package.json
├── README.md
├── systemjs.config.js
├── tsconfig.json
└── typings.json

现在,在app文件夹下,我们创建了两个名为models and components文件夹。 我们需要定义的第一件事是要使用的模型。

定义模型

鉴于需要搜索查询,我们需要决定如何表示它。 这将允许按名称位置进行搜索。

/** app/models/search-query.model.ts **/
export interface CurrentSearch {
    name: string;
    location?: {
        latitude: number,
        longitude: number
    },
    radius: number
}

由于位置将是一个选项,因此将其定义为搜索对象的可选属性。

还需要搜索结果的表示形式。 这将包括视频的ID标题缩略图,因为这将在用户界面中显示。

/** app/models/search-result.model.ts*/
export interface SearchResult {
    id: string;
    title: string;
    thumbnailUrl: string;
}

搜索框组件

第一个搜索参数是“按名称”,因此必须创建一个组件,该组件将:

  • 显示文字输入
  • 每次修改文本时调度一个动作

让我们使用组件的定义在app/components下创建一个新文件:

/** app/components/search-box.component.ts **/
@Component({
    selector: 'search-box',
    template: `
    <input type="text" class="form-control" placeholder="Search" autofocus>
    `
})

该组件还需要对动作进行去抖半秒钟,以避免在快速键入时触发多个动作:

export class SearchBox implements OnInit {

    static StoreEvents = {
        text: 'SearchBox:TEXT_CHANGED'
    };

    @Input()
    store: Store<any>;

    constructor(private el: ElementRef) {}

    ngOnInit(): void {
        Observable.fromEvent(this.el.nativeElement, 'keyup')
            .map((e: any) => e.target.value)
            .debounceTime(500)
            .subscribe((text: string) =>
                this.store.dispatch({
                    type: SearchBox.StoreEvents.text,
                    payload: {
                        text: text
                    }
                })
            );
    }

}

可以将其分解如下:为了从DOM事件中获取Observable ,可以使用辅助函数Observable.fromEvent(HTMLNode, string)将键入转换为字符串流,然后使用RxJS工具包对其进行处理。

注意将store定义为输入。 它代表我们的调度员来交付行动。 该组件将不了解消费者,搜索过程或服务。 它只是处理输入字符串并分派它。

请注意调度程序的使用方式:其签名为dispatch(action: Action): void其中Action是具有强制type字段(字符串)和可选payload 。 由于操作的类型是string ,所以我更喜欢将它们定义为组件内具有适当名称空间的常量,以便该操作的任何使用者都可以导入并与其匹配。

邻近选择器组件

提供的第二种搜索控制是“按地理位置”,从而提供了纬度和经度坐标。 因此,我们需要一个组件,它将:

  • 显示一个复选框以打开本地化
  • 每次修改本地化时调度一个动作
  • 显示半径的范围输入
  • 每次半径改变时都派一个动作

逻辑仍然相同:显示输入,触发动作。

/** app/components/proximity-selector.component.ts **/
@Component({
    selector: 'proximity-selector',
    template: `
    <div class="input-group">
        <label for="useLocation">Use current location</label>
        <input type="checkbox"
            [disabled]="disabled"
            (change)="onLocation($event)">
    </div>
    <div class="input-group">
        <label for="locationRadius">Radius</label>
        <input type="range" min="1" max="100" value="50"
            [disabled]="!active"
            (change)="onRadius($event)">
    </div>
    `
})

它与前面的搜索框组件非常相似。 但是,模板是不同的,因为现在必须显示两个不同的输入。 此外,如果位置关闭,我们希望禁用半径。

这是实现:

/** app/components/proximity-selector.component.ts **/
export class ProximitySelector {

    static StoreEvents = {
        position: 'ProximitySelector:POSITION',
        radius: 'ProximitySelector:RADIUS',
        off: 'ProximitySelector:OFF'
    };

    @Input()
    store: Store<any>;

    active = false;

    // put here the event handlers

}

现在,这两个事件处理程序需要实现。 首先,将处理该复选框:

/** app/components/proximity-selector.component.ts **/
export class ProximitySelector {
    // ...

    onLocation($event: any) {
        this.active = $event.target.checked;
        if (this.active) {
            navigator.geolocation.getCurrentPosition((position: any) => {
                this.store.dispatch({
                    type: ProximitySelector.StoreEvents.position,
                    payload: {
                        position: {
                            latitude: position.coords.latitude,
                            longitude: position.coords.longitude
                        }
                    }
                });
            });
        } else {
            this.store.dispatch({
                type: ProximitySelector.StoreEvents.off,
                payload: {}
            });
        }
    }
}

第一步是检测本地化是打开还是关闭:

  • 如果开启,将分派当前职位
  • 如果关闭,将发送相应的消息

这次使用回调,因为数据不像数字流,而是单个事件。

最后,添加了半径的处理程序,无论位置如何,都将分派新值,因为我们有disabled属性为我们工作。

/** app/components/proximity-selector.component.ts **/
export class ProximitySelector {
    // ...

    onRadius($event: any) {
        const radius = parseInt($event.target.value, 10);
        this.store.dispatch({
            type: ProximitySelector.StoreEvents.radius,
            payload: {
                radius: radius
            }
        });
    }
}

减速器

这与调度程序一起是新系统的核心。 减速器是一种功能,该功能处理动作和当前状态以产生新状态。

归约器的一个重要特性是它们是可组合的,允许我们在保持状态原子的同时将逻辑拆分为不同的函数。 因此,它们必须是纯函数 :换句话说,它们没有副作用。

这给我们带来了另一个重要的推论:测试纯函数是微不足道的,因为给定相同的输入将产生相同的输出。

我们需要的化简器将处理组件中定义的动作,为应用程序返回新状态。 以下是图形说明:

该图显示了SearchReducer如何采用CurrentSearch状态和一个操作来产生新状态

减速器应在app/reducers/下的新文件中创建:

/** app/components/search.reducer.ts **/
export const SearchReducer: ActionReducer<CurrentSearch> = (state: CurrentSearch, action: Action) => {
    switch (action.type) {

        // put here the next case statements

        // first define the default behavior
        default:
            return state;
    }
};

我们必须处理的第一个动作是非动作:如果该动作不影响状态,则reducer会将其返回未经修改的状态。 这对于避免破坏模型非常重要。

接下来,我们处理文本更改操作:

/** app/components/search.reducer.ts **/
    switch (action.type) {
        case SearchBox.StoreEvents.text:
            return Object.assign({}, state, {
                name: action.payload.text
            });
        // ...
   }

如果该动作是SearchBox组件公开的动作,则我们知道有效负载包含新文本。 因此,我们只需要修改state对象的text字段。

根据最佳做法 ,我们不会改变状态,而是创建一个新状态并将其返回。

最后,处理与本地化有关的动作:

  • 对于ProximitySelector.StoreEvents.position我们需要更新位置值
  • 对于ProximitySelector.StoreEvents.radius我们只需要更新半径值
  • 如果消息是ProximitySelector.StoreEvents.off我们只需将位置和半径都设置为null
/** app/components/search.reducer.ts **/
    switch (action.type) {
        case ProximitySelector.StoreEvents.position:
            return Object.assign({}, state, {
                location: {
                    latitude: action.payload.position.latitude,
                    longitude: action.payload.position.longitude
                }
            });
        case ProximitySelector.StoreEvents.radius:
            return Object.assign({}, state, {
                radius: action.payload.radius
            });
        case ProximitySelector.StoreEvents.off:
            return Object.assign({}, state, {
                location: null
            });
        // ...
    }

一起布线

至此,我们有两个分派动作的组件和一个处理消息的reducer。 下一步是连接所有元素并进行测试。

首先,让我们将新组件导入应用程序模块app/app.module.ts

/** app/app.module.ts **/
import {ProximitySelector} from "./components/proximity-selector.component";
import {SearchBox} from "./components/search-box.component";
import {SearchReducer} from "./reducers/search.reducer";

// the rest of app component

接下来,我们修改模块的元数据以将SearchBoxProximitySelector为指令:

/** app/app.module.ts **/
@NgModule({
    // ... other dependencies
    declarations: [ AppComponent, SearchBox, ProximitySelector ],
    // ...
})

然后,我们需要提供一个商店,该商店将负责调度动作,并针对状态和动作运行化简器。 可以使用StoreModule模块的provideStore函数来创建。 我们传递一个带有商店名称和处理它的化简器的对象。

/** app/app.module.ts **/
// before the @Component definition
const storeManager = provideStore({ currentSearch: SearchReducer });

现在,我们将商店经理放入提供商列表中:

/** app/app.module.ts **/
@NgModule({
    imports:      [ BrowserModule, HttpModule, StoreModule, storeManager ],
    // ...
})

最后,但非常重要的是,我们需要将组件放置在模板中,并将它们作为输入传递给store

/** app/app.component.ts **/
@Component({
    // ...same as before
    template: `
    <h1>{{title}}</h1>
    <div class="row">
        <search-box [store]="store"></search-box>
        <proximity-selector [store]="store"></proximity-selector>
    </div>
    <p>{{ state | json }}</p>
    `
})

该类需要更新以符合新模板:

/** app/app.component.ts **/
export class AppComponent implements OnInit {

    private state: CurrentSearch;
    private currentSearch: Observable<CurrentSearch>;

    constructor(
        private store: Store<CurrentSearch>
    ) {
        this.currentSearch = this.store.select<CurrentSearch>('currentSearch');
    }

    ngOnInit() {
        this.currentSearch.subscribe((state: CurrentSearch) => {
            this.state = state;
        });
    }
}

在这里,我们定义了一个私有属性,该属性表示要公开的状态(对于UI)。 存储服务被注入到我们的构造函数中,并用于获取currentSearch的实例。 OnInit接口用于获取init阶段的挂钩,从而允许组件使用商店的实例订阅状态的更新。

下一步是什么?

现在,可以实现一个简单的服务,该服务接受CurrentSearch并像实时示例中一样调用后端API(例如,可以是YouTube )。 可以更改服务,而无需更改一行组件或应用程序的实现。

此外, ngrx不仅限于商店: effectsselectors等几种工具可用于处理更复杂的情况,例如处理异步HTTP请求。

结论

在本教程中,我们看到了如何使用ngrx / storeRxJs在Angular 2中实现类似Redux的流程。

最重要的是,由于突变是许多问题的根源,因此将其放在一个受控制的位置将有助于我们编写更具可维护性的代码。 我们的组件与逻辑分离,应用程序不知道其行为的详细信息。

值得一提的是,我们使用的模式与ngrx官方文档中显示的模式不同,因为这些组件是直接分派操作的,而没有使用事件和其他智能组件层。 关于最佳实践的讨论仍在不断发展。

您是否尝试过ngrx,还是更喜欢Redux? 我很想听听您的想法!

From: https://www.sitepoint.com/managing-state-angular-2-ngrx/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值