Angular 中的响应式编程 -- 浅淡 Rx 的流式思维

kmjstc 8年前
   <p> 今天我们一起通过一个具体的例子来理解响应式编程设计的思路。最后会看看刚刚发布的 Angular 4 的新特性给响应式编程带来了什么新鲜的元素。</p>    <h2>为什么要做响应式编程?</h2>    <p>我给出的答案很简单:响应式编程可以让你把程序逻辑想的很清楚。为什么这么说呢?让我们先来看一个小例子,比如我们有这样一个需求,在生日的控件之前添加一个年龄的选择,用以辅助生日的输入。虽然很变态,其实直接输入赶脚比这种方式快啊,但真的有客户提出过这种需求,不管怎样我们来看一下好了。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/da9cdbda5b4e8390a165ddea1b6c351f.png"></p>    <p>有年龄和单位选择的日期输入</p>    <p>首先分析一下需求:</p>    <ul>     <li>年龄可以按岁、月、天为单位。</li>     <li>其中如果年龄小于等于3个月,按天为单位,如果小于等于2岁按月为单位,其余情况按岁为单位。其实就是考虑幼儿的情况啦。</li>     <li>填年龄时,出生日期随之变化,因为无法精确,所以只需精确到选择的单位即可。</li>    </ul>    <p>如果按传统方式编程的话,我们可能需要在年龄和年龄单位的两个处理输入改变的 event handler 去对数据进行处理,具体我们就不展开了。我们来看一下用响应式编程如何处理这个逻辑。</p>    <p>理解 Rx 的关键是要把任何变化想象成数据流,数据流分为几种:</p>    <ol>     <li>永远不会结束的</li>     <li>有限次的,比如执行若干次结束的(包括只发生一次的)</li>     <li>当然还有一些特殊的,比如永远不会发生的(这个是为了解决某些特定场景问题存在的)</li>    </ol>    <p>这么说好像比较抽象,那么还是回到例子来看这个问题。就这个需求来看的话,年龄和年龄单位这两个数据要一起来考虑,</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/7b7e9aed56e532651a6d0e37ca8d76e0.png"></p>    <p>数据流的合并</p>    <p>上图中(由于太懒,后面的合并虚线就没有画了),上面两个流为原始数据流,一个是年龄的数据流,每次更改年龄数时,这个数据流就产生一个数据:比如一开始初始值为 33,我们删掉个位数的 3,这时由于其变化,产生第二个值 3 (原十位的3),然后我们添加了5,新值变成35,因此流中的第三个数据是35,以此类推。另一个数据流反映了年龄单位的变化,按照“岁-月-岁-天”的次序产生新的数据。一个人的最终的年龄是通过年龄值和年龄单位联合确定的,这也就是说我们需要对这两个流做合并计算。</p>    <p>那么选择什么样的合并方式呢?其实我们需要的是任何一个流的值变化的时候,新的合并流都应该有一个对应数据,这个数据包括刚刚变化的那个值和另一个流中最新的值。比如:如果年龄数据从 33 删掉个位变成 3,此时我们没有改变年龄单位,合并流中的新数据应该是 3岁 。接下来我们改变单位为 月 ,那这时候年龄数据的最新值仍然是 3 ,所以新流的数据应为 3月 等等以此类推。</p>    <p>这样的一种合并方式在 Rx 中专门有一个操作符来处理,那就是 combineLatest 。如果我们使用 age$ 代表年龄数据流(那个 $ 代表 Stream -- 流的意思,约定俗成的写法,不强制要求),用 ageUnit$ 代表年龄单位数据流的话,我们可以写出如下的合并逻辑,为了简化问题,我们这里合并后都使用 天 作为单位:</p>    <pre>  <code class="language-javascript">// 这里前面两个参数都是参与合并的数据流,第三个是个处理函数  // 这个处理函数接受两个流中的最新数据,然后经过运算输出新值  this.computed$ = Observable.combineLatest(age$, ageUnit$, (a, u)=>{        // 非法数字就都按初始值处理,这里就简单粗暴了        if(a === undefined || a <= 0 ) return initialAge;        // 全部转化为天数        switch (parseInt(u)) {          case AgeUnit.Day.valueOf():            return a;          case AgeUnit.Month.valueOf():            return a * 30;          case AgeUnit.Year.valueOf():          default:            // 别问我闰年大小月啥的,只是个例子而已            return a * 365;         }      })</code></pre>    <p>合并之后呢,由于我们最终需要向生日那个输入框中写入一个日期,而我们合并之后的流给出的是按天数计算的年龄,所以这里显然需要一个转换。</p>    <p>在 Rx 中这种数据的转换再容易不过了,最常用的一个就是 map 转换操作符,接着上面的代码继续来一个 map 函数,这里使用了 momentjs 的按当前日期减去刚刚的以天数为单位的年龄值,就得到一个大概估算的出生日期。</p>    <pre>  <code class="language-javascript">.map(a => {        const date = moment().subtract(a, 'days').format('YYYY-MM-DD');        return date;      });</code></pre>    <p>但是到这里,你会发现我们还没有定义两个原始数据流呢,别急,留到后面是为了引出 Angular 对于 Rx 的良好支持。</p>    <h2>响应式表单中的 Rx</h2>    <p>Angular 的表单处理非常强大,有模版驱动的表单和响应式表单两类,两种表单各有千秋,在不同场合可以分别使用,甚至混合使用,但这里就不展开了。我们这里使用了响应式表单,也非常简单,就是一个 form 里面 3 个控件,这里我采用了官方的 Material 控件,如果你觉得不爽,可以直接用基础的 HTML 控件搭配样式即可。</p>    <pre>  <code class="language-javascript"><form   [formGroup]="form"   (ngSubmit)="onSubmit()">    <md-input-container align="end">        <input mdInput           formControlName="age"           type="number"           placeholder="年龄"           max="200"           min="1" />    </md-input-container>    <md-button-toggle-group formControlName="ageUnit">      <md-button-toggle value="0" >岁</md-button-toggle>      <md-button-toggle value="1" >月</md-button-toggle>      <md-button-toggle value="2" >天</md-button-toggle>    </md-button-toggle-group>    <md-input-container>        <input mdInput           formControlName="dateOfBirth"           type="date"           placeholder="出生日期"           max="2100-12-31"           min="1900-01-01"          [value]="computed$ | async"          />        <md-hint align="start">YYYY/MM/DD格式输入</md-hint>    </md-input-container>  </form></code></pre>    <p>Angular 中处理响应式表单只有 3 个步骤:</p>    <ol>     <li>在组件的 HTML 模版中给要处理的控件加上 formControlName="blablabla"</li>     <li>form 标签中添加 [formGroup]="xxx" 指令,这个 xxx 就是你在组件中声明的 FormGroup 类型的成员变量:比如下面代码中的 form: FormGroup;</li>     <li>在组件的构造函数中取得 FormBuilder 后(比如下面代码中的 constructor(private fb: FormBuilder) { } ),用 FormBuilder 构造表单控件数组并赋值给刚才的类型为 FormGroup 的成员变量。</li>    </ol>    <pre>  <code class="language-javascript">import { Component, OnInit } from '@angular/core';  import { FormGroup, FormBuilder, FormControl, Validators } from '@angular/forms';  import { AgeUnit } from '../../domain/entities.interface';  import * as moment from 'moment/moment';    @Component({    selector: 'app-reactive',    templateUrl: './reactive.component.html',    styleUrls: ['./reactive.component.scss']  })  export class ReactiveComponent implements OnInit {    form: FormGroup;    computed$: Observable<string>;    ageSub: Subscription;    dateOfBirth$: Observable<string>;    dateOfBirthSub: Subscription;    constructor(private fb: FormBuilder) { }      ngOnInit() {      this.form = this.fb.group({        age: ['', Validators.required],        ageUnit: ['', Validators.required],        dateOfBirth: ['', Validators.compose([Validators.required, this.validateDate])]      });        const initialAge = 33;      const initialAgeUnit = AgeUnit.Year;      this.form.controls['age'].setValue(initialAge);      this.form.controls['ageUnit'].setValue(initialAgeUnit);    }      validateDate(c: FormControl): {[key: string]: any}{      const result = moment(c.value).isValid           && moment(c.value).isBefore()          && moment(c.value).year()> 1900;      return {        "valid": result      }    }      onSubmit() {      if(!this.form.valid) return;    }  }</code></pre>    <p>现在这个表单就建立好了,但你可能会问,这也没看出来响应式啊,别急,接下来我们就要看看它的响应式支持了。我们再回到一开始的小题目,我们的两个原始数据流: age$ 和 ageUnit$ 怎么构建?这两个数据流其实是来自于两个控件的值的变化,而响应式表单获取值的变化是非常简单的就一行:</p>    <pre>  <code class="language-javascript">this.form.controls['age'].valueChanges</code></pre>    <p>上面这行代码的意思是从表单的控件数组中取得 formControlName 为 age 的这个控件然后监听其值的变化。这个 valueChanges 返回的其实就是一个 Observable ,见下面的 TypeScript 定义:</p>    <pre>  <code class="language-javascript">/**   * Emits an event every time the value of the control changes, in   * the UI or programmatically.   */  readonly valueChanges: Observable<any>;</code></pre>    <p>既然我们得到了这个原始数据流,剩下的工作就比较简单了。但我们可能需要对这个原始数据流再做点处理。首先,我们并不希望每次改这个值都去监听,因为输入是一个连续事件,每一次按键都监听是不太划算的。这就需要一个滤波器的处理 .debounceTime(500) ,我们不去处理 500 毫秒内的变化,而是等待其输入停顿时再发送数据。第二,如果用户采用了拷贝粘贴的方式,我们希望同样的数据不重复发送,所以滤掉相同的数据。最后,我们采用 startWith 给这个流一个初始值,这是由于如果一开始我们什么都不做,两个流就都没有数据;或者只改变其中一个,另一个由于一直没有变就不会产生数据,这样的话,合并流也不会有数据。</p>    <pre>  <code class="language-javascript">// 省略其它引入  import 'rxjs/add/operator/debounceTime';  import 'rxjs/add/operator/distinctUntilChanged';  // 省略其它部分  const age$ = this.form.controls['age'].valueChanges        .debounceTime(500)        .distinctUntilChanged()        .startWith(initialAge);  const ageUnit$ = this.form.controls['ageUnit'].valueChanges        .distinctUntilChanged()        .startWith(initialAgeUnit);</code></pre>    <h2>Async 管道</h2>    <p>到目前为止,我们还没有进行对 Observable 的订阅,如果不订阅的话,写的再漂亮的语句也不会执行的。按常规套路来讲,我们得声明 Subscription 对象,因为 Observable 是一直监听的,即使页面销毁,它也还在,这会造成内存泄漏。所以,我们需要再页面销毁( ngOnDestroy 中)的适合取消订阅。 需要订阅的 Observable 少的时候还好,一旦多起来,处理时也挺麻烦,像下面的代码那样。</p>    <pre>  <code class="language-javascript">// 省略其它引入  import { Subscription } from 'rxjs/Subscription';  // 省略其它部分  ageSub: Subscription;  // 省略其它部分  this.ageSub = this.computed$.subscribe(date => this.form.controls['dateOfBirth'].setValue(date));  // 省略其它部分  onNgDestroy(){    if(this.ageSub !== undefined || !this.ageSub.closed)      this.ageSub.unsubscribe();  }</code></pre>    <p>所幸的是,Angular 提供了对于响应式编程非常友好的设计,我们完全可以不在代码中做 <strong>订阅或取消订阅的动作</strong> 。那么问题来了,不订阅的话,值怎么获得呢?答案是 Async 管道。Async 会在组件初始化时自动的订阅以及在组件销毁时自动取消订阅,太爽了。因此,我们可以删掉上面的代码了,然后在组件模版中给生日的那个 input 添加一个指令 [value]="computed$ | async" ,这就是说该 input 的 value 就是 computed$ 订阅后的值,那么 | async 是说 computed$ 是一个 Observable,请对他采用异步处理,即初始化时自动的订阅以及在组件销毁时自动取消订阅。</p>    <pre>  <code class="language-javascript"><input mdInput           formControlName="dateOfBirth"           // 省略其它属性          [value]="computed$ | async"          /></code></pre>    <h2>对于响应式编程方式的思考</h2>    <p>上面的例子,我不知道大家发现没有,当然 Rx 提供了好多方便的操作符。但更重要的是,写 Rx 的时候,我们需要对流程理解的足够清晰,或者说 Rx 逼着我们对流程反复梳理。其实有的时候,写 Rx 不一定很快,但一旦业务梳理清楚了,接下来就是几行代码的事情。如果你有时候觉得用现有的 Rx 操作符写不出,那多半是你的对需求中涉及的数据流的关系没有弄清楚。</p>    <h2>Angular 4 中的 NgIf 的改进</h2>    <p>Angular 4 中的 ngIf 现在可以携带 else 了,如果你曾经使用过 Angular 就知道,原来我们是得写两个 ngIf 来完成类似的功能的。这个 else 可以携带一个模版的引用。比如下面例子中:如果用户登录成功显示用户名,否则显示登录链接。</p>    <pre>  <code class="language-javascript"><span *ngIf="auth$ else login">    <a routerLink="/profile">{{(auth$|async).user.name}}</a>    <a routerLink="/blablabla">{{(auth$|async).visits}}</a>  </span>  <ng-template #login>    <a routerLink="/login">登录</a>  </ng-template></code></pre>    <p>另一个改进是 ngIf 中现在可以将评估表达式的结果赋值给一个变量,好处是什么呢?可以让你少写很多 (auth$|async)</p>    <pre>  <code class="language-javascript"><span *ngIf="auth$ | async as auth else login">    <a routerLink="/profile">{{auth.user.name}}</a>    <a routerLink="/blablabla">{{auth.visits}}</a>  </span>  <ng-template #login>    <a routerLink="/login">登录</a>  </ng-template></code></pre>    <p> </p>    <p> </p>    <p> </p>    <p>来自:http://www.jianshu.com/p/925adede7c60</p>    <p> </p>