本文将与你一起探讨如何用不可变数据储存的方式进行Angular应用的状态管理 :ngrx/store——Angular的响应式Redux。本文将会完成一个小型简单的Angular应用,最终代码可以在这里下载。
Angular应用中的状态管理
近几年,大型复杂Angular/AngularJS项目的状态管理一直是个让人头疼的问题。在AngularJS(1.x版本)中,状态管理通常由服务,事件,$rootScope混合处理。在Angular中(2+版本),组件通信让状态管理变得清晰一些,但还是有点复杂,根据数据流向不同会用到很多方法。
注意:本文中,AngularJS特指1.x版本,Angular对应2.0版本及其以上。
有人用Redux来管理AngularJS或者Angular的状态。Redux是JavaScript应用的可预测状态容器,支持单一不可变数据储存。Redux最有名的就是结合React的使用,当然它可以用于任意的视图层框架。Egghead.io发布了一份非常优质的Redux免费视频教程,视频由Redux作者Dan Abramov本人讲解。
初识ngrx/store
本文将采用ngrx/store管理我们的Angular应用。那么,ngrx/store和Redux什么关系呢?为什么不用Redux呢?
与Redux的关系
ngrx/store的灵感来源于Redux,是一款集成RxJS的Angular状态管理库,由Angular的布道者Rob Wormald开发。它和Redux的核心思想相同,但使用RxJS实现观察者模式。它遵循Redux核心原则,但专门为Angular而设计。
ngrx/store中的基本原则
-
State(状态) 是指单一不可变数据
-
Action(行为) 描述状态的变化
-
Reducer(归约器/归约函数) 根据先前状态以及当前行为来计算出新的状态
-
状态用State的可观察对象,Action的观察者——Store来访问
我们会详细解释说明。先快速过一遍基础,然后在实战的过程中慢慢深入解释。
Actions(行为)
Actions是信息的载体,它发送数据到reducer,然后reducer更新store。Actions是store能接受数据的唯一方式。
在ngrx/store里,Action的接口是这样的:
// actions包括行为类型和对应的数据载体
export interface Action {
type: string;
payload?: any;
}
type描述我们期待的状态变化类型。比如,添加待办'ADD_TODO',增加'DECREMENT'等。payload是发送到待更新store中的数据。store派发action的代码类似如下:
// 派发action,从而更新store
store.dispatch({
type: 'ADD_TODO',
payload: 'Buy milk'
});
Reducers(归约器)
Reducers规定了行为对应的具体状态变化。它是纯函数,通过接收前一个状态和派发行为返回新对象作为下一个状态的方式来改变状态,新对象通常用Object.assign和扩展语法来实现。
// reducer定义了action被派发时state的具体改变方式
export const todoReducer = (state = [], action) => {
switch(action.type) {
case 'ADD_TODO':
return [...state, action.payload];
default:
return state;
}
}
开发时特别要注意函数的纯性。因为纯函数:
-
不会改变它作用域外的状态
-
输出只决定于输入
-
相同输入,总是得到相同输出
关于函数的纯性,可以点击这里进一步了解。开发时,要确保函数的纯性和状态不可变性,所以写reducers的时候要多加小心。
Store(存储)
store中储存了应用中所有的不可变状态。ngrx/store中的store是RxJS状态的可观察对象,以及行为的观察者。
我们可以利用Store来派发行为。当然,我们也可以用Store的select()方法获取可观察对象,然后订阅观察,在状态变化之后做出反应。
ngrx/store实战:个性宠物标签
目前我们熟悉了ngrx/store的基本工作原理,接下来我们来开发一个能让用户自定义宠物名称标签的应用。该应用将会有以下功能:
-
用户可以选择标签形状,字体,文案,以及附加特性
-
创建过程可以预览标签效果
-
完成后,可以继续创建
我们需要创建几个组件来组合成标签生成器和标签预览,还会添加登录,创建标签,完成创建的组件和路由。这个小应用的状态将会用ngrx/store来管理。
完成后的个性宠物标签app效果如下:
让我们开始吧!
Angular应用设置
安装依赖
确保你已经安装了NodeJS,推荐LTS版本。
用npm安装Angular CLI包,方便一键生成项目手脚架。运行以下命令来全局安装angular-cli。
$ npm install -g @angular/cli
创建项目
选好项目所在的文件夹,打开命令行,输入以下命令来创建一个新的Angular 项目:
$ ng new pet-tags-ngrx
进入新创建的文件夹,安装必要的包:
$ cd pet-tags-ngrx
$ npm install @ngrx/core @ngrx/store --save
一切准备就绪,可以开始开发了。
定制你的项目模板
让我们根据这个项目的需求,稍微改造一下项目模板。
创建src/app/core文件夹
首先,创建文件夹src/app/core。应用的根组件和核心文件都会放在这个文件夹下。将所有的app.component.*文件移动到这里。
注意:简洁起见,该教程不会包含测试。我们会忽略所有的*.spec.ts文件。如果你想写测试的话,可以自己写。所以,文中不会再提到这些文件。同样,出于清晰简洁性的考虑,github仓库的最终版代码删除了所有测试相关的文件。
更新App模块
接着,打开src/app/app.module.ts文件,更新app.component 的路径:
// src/app/app.module.ts
...
import { AppComponent } from './core/app.component';
...
静态资源整理
定位到src/assets文件夹。
在assets文件夹下新建一个images的文件夹,稍后我们会添加一些图片。然后,将根目录下的src/styles.css移动到src/assets下。
styles.css的移动需要我们修改.angular-cli.json的配置。打开这个文件,把styles属性改成如下:
// .angular-cli.json
...
"styles": [
"assets/styles.css"
],
...
集成Bootstrap
最后,在index.html中添加Bootstrap样式。在<link>标签上加上CDN地址。这里我们只用到样式,不需要脚本文件。顺便,更新一下标题,变成Custom Pet Tags:
<!-- index.html -->
...
<title>Custom Pet Tags</title>
...
<!-- Bootstrap CDN -->
<link
rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css"
integrity="sha384-rwoIResjU2yc3z8GV/NPeZWAv56rSmLldC3R/AZzGRnGxQQKnKkoFVhFQhNUwEyJ"
crossorigin="anonymous">
</head>
启动服务
我们可以在本地起个服务,然后监听文件变化实时更新:
$ ng serve
在浏览器中输入http://localhost:4200,程序成功运行。
App组件
现在开始创建新功能。从根组件app.component.*入手。不要担心,变化很小。
删除样式文件
删除app.component.css文件。该组件只用Bootstrap来定义样式,所以不需要额外样式。
根组件脚本
在app.component.ts文件中删除对上述样式文件的引用。我们也可以删除AppComponent类中的title 属性。
// src/app/core/app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html'
})
export class AppComponent {
}
根组件模版
在app.component.html中添加一些内容,变成如下:
<!-- src/app/core/app.component.html -->
<div class="container">
<div class="row">
<div class="col-sm-12">
<h1 class="text-center">Custom Pet Tags</h1>
</div>
</div>
<router-outlet></router-outlet>
</div>
我们用Bootstrap来添加珊格系统和标题。然后添加一个<router-outlet>指令,这是当这个单页面应用中添加路由后,视图会渲染的地方。到现在为止,程序会报错。等我们建好了路由和page组件的时候,就好了。
创建页面组件
如上所述,应用会包含三个路由:登录主页,创建预览页,以及完成页。
我们先创建好各页面手脚架,以便搭建路由。然后再回来完善各个组件。
在根目录下运行如下指令创建页面组件:
$ ng g component pages/home
$ ng g component pages/create
$ ng g component pages/complete
ng g命令可以快速生成组件,指令,过滤器和服务,同时也会自动把生成的文件导入到app.module.ts中。现在,我们有三个页面组件的脚手架,可以开始搭建路由了。
搭建路由
新建一个路由模块,在src/app/core文件夹下创建一个app-routing.module.ts文件:
// src/app/core/routing-module.ts
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HomeComponent } from '../pages/home/home.component';
import { CreateComponent } from './../pages/create/create.component';
import { CompleteComponent } from './../pages/complete/complete.component';
@NgModule({
imports: [
RouterModule.forRoot([
{
path: '',
component: HomeComponent
},
{
path: 'create',
component: CreateComponent
},
{
path: 'complete',
component: CompleteComponent
},
{
path: '**',
redirectTo: '',
pathMatch: 'full'
}
])
],
providers: [],
exports: [
RouterModule
]
})
export class AppRoutingModule {}
现在有三个路由/,/create,/complete,未知路由会重定向到首页。
打开根模块文件app.module.ts,添加新增路由模块AppRoutingModule至imports 属性。
// src/app/app.module.ts
...
import { AppRoutingModule } from './core/app-routing.module';
@NgModule({
...,
imports: [
...,
AppRoutingModule
],
...
到此,路由设置完毕。我们可以通过路由来访问不同页面,访问首页的时候,HomeComponent就会渲染在<router-outlet>所在的位置,如下图所示:
“Home”页面组件
HomeComponent会有简单的信息提示,以及登录按钮。点击登录按钮,直接跳转到/create页面。
现在,让我们添加提示信息和跳转到/create页面的按钮。打开home.component.html,替换内容如下:
<!-- src/app/pages/home/home.component.html -->
<div class="row">
<div class="col-sm-12 text-center">
<p class="lead">
Please sign up or log in to create a custom name tag for your beloved pet!
</p>
<p>
<button
class="btn btn-lg btn-primary"
routerLink="/create">Log In</button>
</p>
</div>
</div>
现在,首页效果如下:
宠物标签模型
开始实现个性宠物标签生成器功能和状态管理的工作了。首先,为我们的状态创建一个数据模型,该模型描述了当前的宠物标签。
新建文件src/app/core/pet-tag.model.ts:
// src/app/core/pet-tag.model.ts
export class PetTag {
constructor(
public shape: string,
public font: string,
public text: string,
public clip: boolean,
public gems: boolean,
public complete: boolean
) { }
}
export const initialTag: PetTag = {
shape: '',
font: 'sans-serif',
text: '',
clip: false,
gems: false,
complete: false
};
PetTag类声明了宠物标签的属性和类型,接着我们定义一个常量initialTag作为默认初始值,在初始化和重置状态时需要用到。
宠物标签行为
现在可以创建行为类型了。回顾之前说的,action被派发到reducer中,从而更新store。现在为我们想要的每种行为定义名字。
创建文件src/app/core/pet-tag.actions.ts
// src/app/core/pet-tag.actions.ts
export const SELECT_SHAPE = 'SELECT_SHAPE';
export const SELECT_FONT = 'SELECT_FONT';
export const ADD_TEXT = 'ADD_TEXT';
export const TOGGLE_CLIP = 'TOGGLE_CLIP';
export const TOGGLE_GEMS = 'TOGGLE_GEMS';
export const COMPLETE = 'COMPLETE';
export const RESET = 'RESET';
将行为定义为常量。我们也可以构造可注入的行为类,就像ngrx/example-app中那样。但我们这个例子很简单,用这种方法反而会增加复杂度。
宠物标签归约器
现在可以创建我们的归约函数了,这个函数接受action,更新store。
新建文件src/app/core/pet-tag.reducer.ts:
// src/app/core/pet-tag.reducer.ts
import { Action } from '@ngrx/store';
import { PetTag, initialTag } from './../core/pet-tag.model';
import { SELECT_SHAPE, SELECT_FONT, ADD_TEXT, TOGGLE_CLIP, TOGGLE_GEMS, COMPLETE, RESET } from './pet-tag.actions';
export function petTagReducer(state: PetTag = initialTag, action: Action) {
switch (action.type) {
case SELECT_SHAPE:
return Object.assign({}, state, {
shape: action.payload
});
case SELECT_FONT:
return Object.assign({}, state, {
font: action.payload
});
case ADD_TEXT:
return Object.assign({}, state, {
text: action.payload
});
case TOGGLE_CLIP:
return Object.assign({}, state, {
clip: !state.clip
});
case TOGGLE_GEMS:
return Object.assign({}, state, {
gems: !state.gems
});
case COMPLETE:
return Object.assign({}, state, {
complete: action.payload
});
case RESET:
return Object.assign({}, state, initialTag);
default:
return state;
}
}
首先从ngrx/store导入Action。同时也需要PetTag数据模型以及它的初始状态initialTag。还有上一步中创建的行为类型也需要导入。
然后创建petTagReducer()函数,该函数接收两个参数:上一个状态state和被派发的行为action。注意它是输入决定输出的纯函数,函数不会改变全局的状态。这就是说,从归约器返回的数据要么是新对象,要么是未修改的输入,比如default情况。
通常,我们可以借用Object.assign()从输入数据中得到全新的对象。输入数据是上一个状态以及包含行为载体(payload)的对象。
TOGGLE_CLIP和TOGGLE_GEMS切换initialTag状态中的布尔值,所以当我们派发这两种行为的时候,不需要行为载体,我们只需要简单取反即可。
COMPLETE行为需要一个载体,因为我们明确要将其设置为true,而且每个标签只能操作一次。我们也可以切换布尔值,但明确起见,我们还是会派发一个具体的值作为行为载体。
注意:注意RESET行为用到导入的initialTag。因为它是个不变量,所以在这里使用并不会违背归约函数的纯性。
根模块导入Store
完成了行为和归约函数的定义之后,我们要告诉应用程序有这些的存在。打开app.module.ts文件,更新如下:
// src/app/app.module.ts
...
import { StoreModule } from '@ngrx/store';
import { petTagReducer } from './core/pet-tag.reducer';
@NgModule({
...,
imports: [
...,
StoreModule.provideStore({ petTag: petTagReducer })
],
...
现在,我们可以用Store来实现状态管理了。
创建“Create”页面
之前创建的CreateComponent是个智能组件(Smart Component),它会有几个木偶子组件(Dumb Component)。
智能组件/木偶组件
智能组件也称容器组件,通常作为根级组件,包含业务逻辑,状态管理,订阅,处理事件。在这个例子中,就是那些可路由的页面组件。CreateComponent是智能组件,它将为标签生成器制定业务逻辑。同时,它会处理木偶子组件触发的事件,而这些子组件是标签生成器的一部分。
木偶组件又名展示组件,它只决定于父组件传递的数据。它可以触发事件,然后在父组件中处理,但它不会直接影响订阅或者store。木偶组件是可复用的模块化组件。比如,我们会同时在Create页面和Complete页面使用标签预览这个木偶组件(CreateComponent和CompleteComponent是智能组件)。
“Create”页面功能点
Create页面将会有以下几个功能:
-
标签形状选择
-
标签字体选择和文案输入
-
是否添加clip和gems
-
标签形状和文案的预览
-
结束操作的完成按钮
“Create”组件脚本
我们先从CreateComponent开始,打开文件create.component.ts:
// src/app/pages/create/create.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { Store } from '@ngrx/store';
import { SELECT_SHAPE, SELECT_FONT, ADD_TEXT, TOGGLE_CLIP, TOGGLE_GEMS, COMPLETE } from './../../core/pet-tag.actions';
import { PetTag } from './../../core/pet-tag.model';
@Component({
selector: 'app-create',
templateUrl: './create.component.html'
})
export class CreateComponent implements OnInit, OnDestroy {
tagState$: Observable<PetTag>;
private tagStateSubscription: Subscription;
petTag: PetTag;
done = false;
constructor(private store: Store<PetTag>) {
this.tagState$ = store.select('petTag');
}
ngOnInit() {
this.tagStateSubscription = this.tagState$.subscribe((state) => {
this.petTag = state;
this.done = !!(this.petTag.shape && this.petTag.text);
});
}
ngOnDestroy() {
this.tagStateSubscription.unsubscribe();
}
selectShapeHandler(shape: string) {
this.store.dispatch({
type: SELECT_SHAPE,
payload: shape
});
}
selectFontHandler(fontType: string) {
this.store.dispatch({
type: SELECT_FONT,
payload: fontType
});
}
addTextHandler(text: string) {
this.store.dispatch({
type: ADD_TEXT,
payload: text
});
}
toggleClipHandler() {
this.store.dispatch({
type: TOGGLE_CLIP
});
}
toggleGemsHandler() {
this.store.dispatch({
type: TOGGLE_GEMS
});
}
submit() {
this.store.dispatch({
type: COMPLETE,
payload: true
});
}