Angular 应用中的状态管理
Posted 灵雀云
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Angular 应用中的状态管理相关的知识,希望对你有一定的参考价值。
译者注:
Angular开发组最近将SemVer版本命名方式引入了Angular2中,参见(http://angularjs.blogspot.jp/2017/01/branding-guidelines-for-angular-and.html)。译文中出现的Angular指的是Angular2框架。本文原作者Victor Savkin为前谷歌Angular项目核心成员,负责了Angular的依赖注入、状态监测、列表和路由模块的实现。译文将根据作者的原意,以中文文法稍加润色。
状态管理是个大难题。我们总是需要协同多个后端服务、Web Worker 脚本或 UI 组件,这些都要更新状态。借助 Redux 这样已有的解决方案,实现状态的协同更新是显而易见的,但并不能让我们高枕无忧。实际上,状态管理的需求要更广泛。
在内存里和 URL 分别存什么?如何处理本地的UI 组件状态?如何同步持久化状态、URL 以及后端服务状态?以上这些问题都需要在设计应用的状态管理时解答。
状态类型的种类
一个典型的网页应用有以下六种状态类型:
服务器端状态
(储存在客户端的)持久化状态
URL 和路由状态
客户端状态
瞬时客户端状态
本地 UI 状态
接下来我们将深入探讨每种类型。
显然,服务器端状态是存在服务器上的,可以通过诸如 REST 的方式获取。持久化状态是服务器状态的一个子集,存储在客户端内存。 简单来说,持久化状态可以看做服务器端状态的一个缓存副本。不过在实际应用中,我们通常会优化缓存的更新过程,来增强用户的体验。
客户端状态不会储存在服务器上。用来筛选用户可见数据项的过滤条件就是一个不错的例子。数据项存储在服务端的某个数据库里,过滤条件的值却不是。
应用状态通常被存储在客户端,但不会在 URL 里。比如,YouTube 会记住我在哪里停止观看视频,并在下次观看时从上次停止观看的位置开始播放。由于这个信息没有存储在 URL,如果我把视频链接发给另外某个人,他就只能从头开始观看这个视频。这种场景中的状态就是瞬时状态。
最后,每个独立的 UI 组件都可能含有决定各自行为的内部状态。比方说,要不要把一个可折叠项目展开?按钮的颜色应该是什么?这就是本地 UI 状态。
状态同步
持久化状态和服务器端状态存储着相同信息;对于客户端状态和 URL 也需要存储相同信息。因此,我们必须解决状态同步的问题。选择状态的同步策略通常也就成为设计应用状态管理最重要的决定之一。
是否可以做到某些同步是严格同步的?哪些状态必须是异步的?套用分布式系统的术语: 我们应该选择使用严格(strict)一致性还是最终(eventual)一致性?
我们将在这篇文章里研究这些问题。
我们先从一个看起来构建得还算合理的系统开始。这个应用展示了一个演讲列表,用户可以删选列表,或是选择观看、评价某个演讲。
这个应用中有两大路由:一个用来展示演讲列表,另外一个用来展示演讲的详情。
// routes.ts
RouterModule.forRoot([
{ path: 'talks', component: TalksAndFiltersCmp },
{ path: 'talk/:id', component:TalkDetailsCmp }
])
下面是应用的简略架构图:
下面是应用的数据模型信息:
// model.ts
exportinterfaceTalk {
id:number;
title:string;
speaker:string;
description:string;
yourRating:number;
rating:number;
}
exportinterfaceFilters {
speaker:string;
title:string;
minRating:number;
}
以及两个主要组件:
// talks-and-filters.ts
@Component({
selector: 'app-cmp',
templateUrl: './talks-and-filters.html',
styleUrls: ['./talks-and-filters.css']
})
exportclassTalksAndFiltersCmp {
constructor(publicbackend:Backend) {}
handleFiltersChange(filters:Filters):void {
this.backend.changeFilters(filters);
}
}
// talk-detail.ts
@Component({
selector: 'talk-details-cmp',
templateUrl: './talk-details.html',
styleUrls: ['./talk-details.css']
})
exportclassTalkDetailsCmp {
talk:Talk;
constructor(privatebackend:Backend,
publicwatchService:WatchService,
privateroute:ActivatedRoute) {
route.params
.mergeMap(p=>this.backend.findTalk(+p['id']))
.subscribe(t=>this.talk = t);
}
handleRate(newRating:number):void {
this.backend.rateTalk(this.talk.id, newRating);
}
handleWatch():void {
this.watchService.watch(this.talk);
}
}
两个组件本身不会处理任何实际的业务逻辑。这一部分处理被委托给了 Backend 和 WatchService 服务。
// backend.ts
@Injectable()
exportclassBackend {
_talks: {[id:number]:Talk} = {};
_list:number[] = [];
filters:Filters= {speaker: null, title: null, minRating: 0};
constructor(privatehttp:Http) {}
get talks():Talk[] {
returnthis._list.map(n=>this._talks[n]);
}
findTalk(id:number):Observable<Talk> {
returnof(this._talks[id]);
}
rateTalk(id:number, rating:number):void {
const talk =this._talks[id];
talk.yourRating = rating;
this.http.post(`/rate`, {id: talk.id, yourRating:rating}).forEach(() => {});
}
changeFilters(filters:Filters):void {
this.filters = filters;
this.refetch();
}
private refetch():void {
const params =newURLSearchParams();
params.set("speaker", this.filters.speaker);
params.set("title", this.filters.title);
params.set("minRating", this.filters.minRating.toString());
this.http.get(`/talks`, {search:params}).forEach((r) => {
const data = r.json();
this._talks = data.talks;
this._list = data.list;
});
}
}
每当过滤条件改变时,Backend服务将会重新获取演讲数组。因此每当用户浏览某项单独的演讲时,Backend将会从内存中获取所需的信息。(译者注:也就是说查看某个演讲的详情时不会重新获取数据。)
WatchService的实现十分简单:
// watch-service.ts
exportclassWatchService {
watched: {[k:number]:boolean} = {};
watch(talk:Talk):void {
console.log("watch", talk.id);
this.watched[talk.id] =true;
}
isWatched(talk:Talk):boolean {
returnthis.watched[talk.id];
}
}
我们来看下有哪些东西在管理应用的各项状态。
Backend管理持久化状态(演讲数组)和客户端状态(过滤条件)
路由管理 URL 和路由状态
WatchService管理瞬时状态(已经观看过的演讲)
每个组件管理自己的本地 UI 状态
在乍一看,这个应用的实现是合理的:应用逻辑由依赖注入的服务处理,每个方法短小精悍,代码风格不错。但只要往细里看,就能从中发现不少问题。
首先,当载入一个演讲的详情时,我们会调用 Backend.findTalk 函数,从内存里的数据集合中取出数据。当用户从列表页开始浏览时,这样的做法行得通。但如果用户直接访问演讲详情的 URL,Backend中的数据集合还是空的,应用因此无法正确工作。此处可以做个变通,在获取演讲详情前先检查数据集合中是否有正确的演讲数据,如果没有,就从服务器端获取数据。
// backend.ts
exportclassBackend {
//...
findTalk(id:number):Observable<Talk> {
if (this._talks[id]) returnof(this._talks[id]);
const params =newURLSearchParams();
params.set("id", id.toString());
returnthis.http.get(`${this.url}/talk/`, {search: params})
.map(r=>this._talks[id] = r.json()['talk']);
}
//...
}
其次,为了获得更好的用户体验,评分方法想当然地修改了传入的talk对象。但问题在于它没有处理潜在错误:如果服务端更新演讲数据失败,客户端会显示错误信息(译者:指的是前端显示的数据与后端不一致)。解决办法是,当出现数据更新异常时,把评价值重置为null。
// backend.ts
exportclassBackend {
//...
rateTalk(talk:Talk, rating:number):void {
talk.yourRating = rating;
this.http.post(`${this.url}/rate`, {id: talk.id, yourRating:rating}).catch((e:any) => {
talk.yourRating =null;
throw e;
}).forEach(() => {});
}
//...
}
修改之后,持久化状态和服务端数据就可以正确地同步了。
我们还发现更改过滤条件并没有更新URL,可以通过手动同步的方式解决。
// talks-and-filters.ts
Import {paramsToFilters, filtersToParams} from './utils';
@Component({
selector: 'app-cmp',
templateUrl: './talks-and-filters.html',
styleUrls: ['./talks-and-filters.css']
})
exportclassTalksAndFiltersCmp {
constructor(publicbackend:Backend,
privaterouter:Router,
privateroute:ActivatedRoute) {
route.params.subscribe((p:any) => {
// the url changed => update the backend
this.backend.changeFilters(paramsToFilters(filters));
});
}
handleFiltersChange(filters:Filters):void {
this.backend.changeFilters(filters);
// the backend chagned => update the URL
this.router.navigate(["/talks", filtersToParams(filters)]);
}
}
技术上讲,这段代码可以解决问题(URL 和客户端状态是同步的),但这个方案本身也有问题:
Backend.refetch被调用了两次。过滤条件改变时会调用refetch,并且会触发一次导航,而每次导航最终又会调用refetch。
路由和Backend状态的同步是异步实现的。这意味着,路由无法从Backend获取任何可靠信息;同样,Backend也无法从路由或 URL 获取可靠信息 - 这些信息也许并不在那里。
没有处理路由守卫拦截导航的情况(译者注:Angular 原生路由的功能,在路由触发前可以先检查当前状态可否跳转到新状态)。无论如何客户端状态都会更新,就好像导航成功了一样。
该方案是特殊化的,只能处理一个特定路由和客户端状态之间的同步。如果我们需要新加一个路由,不得不重复这块逻辑。
最后,我们的模型是可变的。这意味着,在不更新 URL 的情况下可以更新模型数据。这正是导致错误的普遍原因之一。
从这个小应用我们找到了好多问题。为什么状态管理这么难?我们犯了哪些错?
没有把状态管理从业务逻辑和后端交互服务中抽离出来。 Backend 在与服务器交互的同时,也在维护状态。WatchService也有同样问题。
客户端持久化状态和后端服务器状态之间的同步策略不够清晰。就算后面修复了,解决方案也似乎过于特殊化,没有站在全局的角度考虑。
客户端状态和 URL 之间的同步策略不够清晰。虽然问题解决了,由于没有路由守卫,而且refetch是幂等的,因此不是一个可持续改进的方案。
数据模型是可变的,意味着保持应用状态的可依赖性变得困难。
重构一:分离状态管理
将后端交互与业务逻辑从状态管理中分离
Redux 介绍参考链接
首先,从定义应用可执行的每种动作开始:
// model.ts
exporttypeShowDetail= { type:'SHOW_DETAIL', talkId:number };
exporttypeFilter= { type:'FILTER', filters:Filters };
exporttypeWatch= { type:'WATCH', talkId:number };
exporttypeRate= { type:'RATE', talkId:number, rating:number };
exporttypeAction=Filter|ShowDetail|Watch|Rate|Unrate;
然后是状态:
// model.ts
// all non-local state of the application
exporttypeState= {
talks: { [id:number]:Talk },
list:number[],
filters:Filters,
watched: { [id:number]:boolean }
};
// init state
exportconst initState:State= {
talks: {},
list: [],
filters: {speaker: null, title: null, minRating: 0},
watched: {}
};
// model.ts
// a factory to create reducer
exportfunction reducer(backend:Backend, watch:WatchService) {
return (store:Store<State, Action>, state:State, action:Action) => {
switch (action.type) {
case'FILTER':
return backend.findTalks(action.filters).
map(r=> ({...state, ...r, filters:action.filters}));
case'SHOW_DETAIL':
if(state.talks[action.talkId]) return state;
return backend.findTalk(action.talkId).
map(t=> ({...state, talks: {...state.talks, [t.id]: t}}));
//...
default:
return state;
}
}
}
操作非本地状态的的任务有且只有如上 reducer 处理,Backend和WatchService才能变成无状态。
// watch-service.ts
exportclassWatchService {
watch(talk:Talk):void {
console.log("watch", talk.id);
}
}
前面我们已经有了一个特殊策略处理乐观更新。当然,我们还能做得更好。我们引入另外一个叫做UNRATE的动作,用来处理服务器拒绝更新的情况。
// model.ts
exportfunction reducer(backend:Backend, watch:WatchService) {
return (store:Store<State, Action>, state:State, action:Action) => {
switch (action.type) {
//...
case'RATE':
backend.rateTalk(action.talkId,action.rating).catch(e=>
store.dispatch({type: 'UNRATE', talkId:action.talkId, error: e})
).forEach(() => {});
const talkToRate =state.talks[action.talkId];
const ratedTalk = {...talkToRate,yourRating: action.rating};
const updatedTalks = {...state.talks,[action.talkId]: ratedTalk};
return {...state, talks:updatedTalks};
case'UNRATE':
const talkToUnrate =state.talks[action.talkId];
const unratedTalk = {...talkToUnrate,yourRating: null};
constupdatedTalksAfterUnrating = {...state.talks, [action.talkId]: unratedTalk };
return {...state, talks:updatedTalksAfterUnrating};
default:
return state;
}
}
}
这个更新很有必要。这将保证所有操作按顺序执行,避免交错的可能性。
乐观更新要求独立的操作处理错误
不可变数据
最后,我们把数据模型改为不可变的类型。这会带来很多有益的后果,稍后会聊一聊这个话题。
为持久性状态和客户端状态使用不可变数据
更新后的组件
重构后的组件变得简单了。现在他们只需负责状态的查询和动作的派送。
@Component({
selector: 'talk-details-cmp',
templateUrl: './talk-details.html',
styleUrls: ['./talk-details.css']
})
exportclassTalkDetailsCmp {
constructor(privatestore:Store<State, Action>, privateroute:ActivatedRoute) {
route.params.forEach(p=> {
this.store.dispatch({
type: 'SHOW_DETAIL',
talkId: p['id']
});
});
}
get talk():Talk {
returnthis.store.state.talks[+this.route.snapshot.params['id']];
}
get watched():boolean {
returnthis.store.state.watched[+this.route.snapshot.params['id']];
}
handleRate(newRating:number):void {
this.store.dispatch({
type: 'RATE',
talkId: this.talk.id,
rating: newRating
});
}
handleWatch():void {
this.store.dispatch({
type: 'WATCH',
talkId: this.talk.id
});
}
}
状态管理与逻辑处理/服务交互被分离开来。reducer 成为改变本地客户端状态的惟一方式。前后端交互、观看视频等现在由无状态服务处理。
不再为持久化状态和客户端状态使用可变对象数据类型。
客户端持久化数据与服务器状态的同步有了新策略。现在UNRATE用来处理错误,使得动作可以按顺序进行处理。
不过这里需要说明的是,我们的目的不是为了在系统中用上 Redux。就算使用了 Redux,我们仍然有可能将逻辑处理与状态管理混杂在一起,在没有处理错误的情况下进行乐观更新,或者使用可变状态。Redux 帮助我们解决以上问题,但它不是万能的,也不是解决问题的唯一途径。
使用 Redux 是为了解决问题而非目的
另外你可能注意到,在重构过程中我们完全没有碰任何本地 UI 状态。这是因为本地 UI 的状态几乎从来都不会成为问题。 组件可以有别人访问不到的可变属性,不需要我们过于关注。
尽管进行了重构,我们依然是以手动方式管理客户端与服务端的状态同步,而这可能使我们犯错。我们有可能会忘了处理异常,或者使缓存失效。
GraphQL 和 Apollo 在全局的高度解决了上述问题,但也意味着我们需要投入更多精力在后端的基础架构上。这两个库可以和 Redux 一起使用,参考这个项目: Hongbo-Miao/apollo-chat。
如果你能平衡好研发成本,推荐你去调研一下 Apollo。
重构二:路由和数据仓库
我们的设计还遗留了以下问题:
路由无法可靠地从Backend获取信息
Backend也无法可靠地从路由或是 URL 获得信息
如果路由守卫拒绝了导航行为,客户端状态依然以导航成功的情况进行更新
reducer 不能阻止导航行为
路由和后端状态的同步是临时的。如果增加一个新路由,还得把同步逻辑重写一遍。
解决方式之一是,构建一个通用库来同步数据和路由。这不会解决所有问题,不过至少解决方案不再是特殊的。另外一种方式是,将导航作为更新状态的一部分。再或者,可以把状态更新作为导航的一部分。那么应该选择哪种方式呢?
永远将路由器作为状态的事实来源
既然用户总是直接和 URL 交互,那么我们就应该把路由作为状态处理的事实来源和动作的发起者。换个角度说,应该是路由调用 reducer,而不是 reducer 调用路由。
在这种架构下,路由首先解析 URL,创建新的路由器状态快照,再把快照交给 reducer 处理,在 reducer 在处理完后才进行真正的导航行为。
实现这种模式并不困难。可以引入RouterConnectedToStoreModule :
// app.ts
@NgModule({
declarations: [
//...
],
imports: [
//...
RouterConnectedToStoreModule.forRoot(
"reducer",
[
{ path: '', pathMatch: 'full', redirectTo: 'talks' },
{ path: 'talks', component: TalksAndFiltersCmp },
{ path: 'talk/:id', component:TalkDetailsCmp }
]
)
],
providers: [
Backend,
WatchService,
{ provide: "reducer", useFactory:reducer, deps: [Backend, WatchService]}
]
})
exportclassAppModule { }
RouterConnectedToStoreModule 会帮我们设置路由器:在 URL 解析完,生成新的路由状态后,路由器将会派发 ROUTER_NAVIGATION动作。
// reducers.ts
exportfunction reducer(backend:Backend, watch:WatchService) {
return (store:Store<State, Action>, state:State, action:Action) => {
switch (action.type) {
case'ROUTER_NAVIGATION':
const route = action.state.root.firstChild.firstChild;
if(route.routeConfig.path ==="talks") {
const filters =createFilters(route.params);
return backend.findTalks(filters).
map(r=> ({...state, ...r, filters}));
} elseif(route.routeConfig.path ==="talk/:id") {
const id =+route.params['id'];
if (state.talks[id]) return state;
return backend.findTalk(id).
map(t=> ({...state, talks: {...state.talks, [t.id]: t}}));
} else {
return state;
}
//...
default:
return state;
}
}
}
就像你所看到的,这个 reducer 可能会返回 observable,在这种情况下会使得路由器等待 observable 完成后才进行页面跳转。如果 reducer 抛出异常,路由将取消掉这次导航。
借助以上方法我们就不需要 Filter 动作了。现在,我们可以通过路由的导航变化来触发正确的动作。
// talks-and-filters.ts
@Component({
selector: 'app-cmp',
templateUrl: './talks-and-filters.html',
styleUrls: ['./talks-and-filters.css']
})
exportclassTalksAndFiltersCmp {
constructor(privaterouter:Router, privatestore:Store<State, Action>) {}
get filters():Filters {
returnthis.store.state.filters;
}
get talks():Talk[] {
returnthis.store.state.list.map(n=>this.store.state.talks[n]);
}
handleFiltersChange(filters:Filters):void {
this.router.navigate(["/talks", filtersToParams(filters)]);
}
}
分析
这次重构使得客户端状态与 URL 紧密相联。路由导航行为将会调用 reducer;一旦reducer 执行完毕,导航将根据新的状态继续进行。
以下是我们重构带来的结果:
reducer可靠地利用新的URL和新的路由状态进行状态计算
路由守卫或路由解析器(resolver,用以在跳转到某个新的路由之前准备惰性状态的解析)也能可靠地利用 reducer 创建的新状态
reducer可以中止导航行为
没有任何状态是并发更新的。我们总是清楚每项数据的来源。
这个解决方案也是全局性的。如果这个做好了,我们就不需要在增加新路由时关心如何同步两个状态(指的路由和 Backend)
也就是说,我们已经把以上列举的所有问题都解决了。
在这篇文章中,我有意没用任何已有的 Redux 库来构建应用,而是实现了自己的数据仓库,并把它连接到路由器上(加起来也就是几百行的代码)。
这样做的目的是为了展示:认真思考问题非常重要,而不是盲目使用最新的某个第三方库。
话虽如此,我认为 @ngrx/store 是个Angular 非常好的 Redux 实现,如果没有特别理由,你应该使用它。如果你正在使用,可以试试 。这个库为 @ngrx/store 实现了路由连接器,应该很快会成为 ngrx 的一部分。
总结
我们从一个简单应用开始。初看实现是合理的:函数短小精悍,代码优雅;随后我们深入研究后发现了很多潜在问题。如果没有一双训练有素的眼睛,这些问题很可能会被忽视。我们尝试用一些变通方案解决了问题,但仍然很多没有解决,并且解决方案不能很好的拓展到全局。
这个应用有如此多的问题,是因为我们没有从头到尾仔细想清楚应用状态管理的策略。每种解决方案都是特殊的。当我们需要去处理一个并发分布式系统时,特殊的解决方案会迅速击垮整个系统。
之后我们开始着手重构系统,引入了类似 Redux 的数据仓库和不可变数据类型。但这并不是我们的目的,而是实现目标的手段。为了解决剩下的问题,我们实现了将 reducer 连接到路由的策略。
在整个重构过程中,我们总结了几个有用的规则。
这篇文章主要目的是,让你知道应该有意地思考如何进行状态管理。这是一个大难题。不要相信任何人说“有一个简单的模式/第三方库”可以解决它,因为它根本不存在。
鉴定应用中的状态类型,管理好不同类型的状态,保证好状态的一致性,这都需要你用心设计。
本文已经过作者授权翻译
以上是关于Angular 应用中的状态管理的主要内容,如果未能解决你的问题,请参考以下文章
在 Angular 2/4/6 应用程序中管理状态的最佳实践
Angular 状态管理 - 存储还是有状态服务? [关闭]