21

Hot vs Cold Observables

理解冷热两种模式下的Observables对于掌握Observables来说至关重要,在我们开始探索这个话题之前,我们先来读一读RxJS官方的定义:

Cold Observables在被订阅后运行,也就是说,observables序列仅在subscribe函数被调用后才会推送数据。与Hot Observables不同之处在于,Hot Observables在被订阅之前就已经开始产生数据,例如mouse move事件。

OK,目前为止还不错,让我们来举几个例子。

首先我们以一个简单的只会发射数字1Observable开始。我们为这个Observable添加两个订阅并打印数据:

let obs = Rx.Observable.create(observer => observer.next(1));

obs.subscribe(v => console.log("1st subscriber: " + v));
obs.subscribe(v => console.log("2nd subscriber: " + v));

运行结果如下:

1st subscriber: 1
2nd subscriber: 1

现在的问题是,代码中的obs是冷模式还是热模式?假设我们不知道obs是如何创建的,而是通过调用某个getObservableFromSomewhere()获得的,那么我们将无法得知obs是哪种模式。因此对于一般的订阅者来说,并不是总能知道所处理的是哪一种Observable

现在回想下上面引用的官方定义,如果obs是冷模式,那么它被订阅后才会产生“新鲜”的数据。为了体现“新鲜”这一点,我们用Date.now()来替代数字1,看看会发生什么。重新运行上面的代码我们得到:

1st subscriber: 1465990942935
2nd subscriber: 1465990942936

我们注意到两次获得的数据并不相同,这意味着observer.next(Date.now())一定被调用了两次。换句话说,obs在每次订阅后开始产生数据,这使得它成为冷模式下的Observable

将Cold Observables转化为Hot Observable

现在我们知道obs是冷模式,那么给它加加温会如何?

let obs = Rx.Observable
            .create(observer => observer.next(Date.now()))
            .publish();

obs.subscribe(v => console.log("1st subscriber: " + v));
obs.subscribe(v => console.log("2nd subscriber: " + v));

obs.connect();

先不要管publishconnect这两个操作符是什么,我们来看下上面代码的输出:

1st subscriber: 1465994477014
2nd subscriber: 1465994477014

显然observer.next(Date.now())只被调用了一次(因为只产生了一次数据),那么obs已经是热模式了?Emmm,不完全是。我们可以这么说:现在的obs比冷模式下要“暖”,但是比热模式还要“凉”一些。说它暖是因为在不同的订阅里仅仅产生了一次数据,说它凉是因为它并没有在被订阅之前就开始产生数据。

我们可以稍微改动一下,将obs.connect()放到到所有订阅之前:

let obs = Rx.Observable
            .create(observer => observer.next(Date.now()))
            .publish();
obs.connect();

obs.subscribe(v => console.log("1st subscriber: " + v));
obs.subscribe(v => console.log("2nd subscriber: " + v));

然而,当我们运行这段代码时却没有任何输出。其实这是意料之中的,因为现在的obs已经是热模式,它会在创建后立即开始产生数据而不管有没有被订阅过。所以在我们添加两个订阅之前,obs已经完成了最初始(也是唯一一次)的推送,因此随后的订阅不会收到新的数据,也就不会被触发。

为了理解publish的作用,我们来创建一个可以持续产生数据的Observable

let obs = Rx.Observable
            .interval(1000)
            .publish()
            .refCount();

obs.subscribe(v => console.log("1st subscriber:" + v));
setTimeout(()
  // delay for a little more than a second and then add second subscriber
  => obs.subscribe(v => console.log("2nd subscriber:" + v)), 1100);

这比之前的稍微复杂一点,所以我们一步一步来分析:

  • 首先用inerval(1000)来创建一个每秒产生一个递增数字的Observable,以0开始。

  • 然后用publish操作符来使不同订阅者之间共享同一个生产环境(热模式的一个标志)。

  • 最后使第二个订阅延迟1秒。

稍后我们再解释refCount

运行上面代码得到:

1st subscriber:0
1st subscriber:1
2nd subscriber:1
1st subscriber:2
2nd subscriber:2
...

很显然我们可以看到第二个订阅者并没有从0开始,这是因为publish可以使不同订阅者共享同一个生产环境,第二个订阅者和第一个共享了环境,因此它不会获得之前产生的数据。

理解publishrefCountconnect

在上面的例子中,obs到底有多“热”?我们修改代码,使两次订阅都延迟2秒。如果obs足够“热”,那么我们应该看不到数字01,因为它们在被订阅之前就被发射出去了。

let obs = Rx.Observable
            .interval(1000)
            .publish()
            .refCount();

setTimeout(() => {
  // delay both subscriptions by 2 seconds
  obs.subscribe(v => console.log("1st subscriber:" + v));
  setTimeout(
    // delay for a little more than a second and then add second subscriber
    () => obs.subscribe(
          v => console.log("2nd subscriber:" + v)), 1100);

},2000);

然而有意思的是,我们得到了和修改前完全一样的输出。这意味着我们的obs更应该被称为“暖”模式而不是“热”模式。这是因为refCount在起作用。publish操作符创建了一个被称为ConnectableObservable的序列,该序列对于数据源共享同一个订阅。然而此时publish操作符还没有订阅到数据源。它更像一个守门员,保证所有的订阅都订阅到ConnectableObservable上面,而不是数据源本身。

connect操作符使ConnectableObservable实际订阅到数据源。在上面的例子中,我们用refCount来代替connectrefCount是建立在connect之上的,它可以使ConnectableObservable在有第一个订阅者时订阅到数据源,并在没有订阅者时解除对数据源的订阅。这实际上对ConnectableObservable的所有订阅进行了记录。

如果我们想obs变成真正的热模式,我们就需要手动调用connect

let obs = Rx.Observable
            .interval(1000)
            .publish();
obs.connect();

setTimeout(() => {
  obs.subscribe(v => console.log("1st subscriber:" + v));
  setTimeout(
    () => obs.subscribe(v => console.log("2nd subscriber:" + v)), 1000);

},2000);

输出如下,我们的两个订阅者都会得到2之后的数字。

1st subscriber:2
1st subscriber:3
2nd subscriber:3

此时的obs是完完全全的热模式,它会在创建后立即产生数据而不管有没有订阅者。

如何使用

根据上面的讨论,我们知道很难说一个Observable是完全冷的还是热的。实际上我们还有一些推送数据给订阅者的方法没有讨论。通常来说,当我们处理一些会自动产生数据而不管有没有人监听的数据源时,我们应该使用热模式。当我们订阅这样的Observable时是得不到数据源发射的历史数据,而只能获得未来产生的值。

一个典型的例子就是mouse move事件。mouse move不管有没有人监听它都会发生。当我们开始监听它时,我们只能获得未来产生的mouse move事件。

冷模式下的Observable属于懒加载。只有当有人订阅时才会产生数据。但是别忘了,这不是绝对的。在之前的例子中我们知道,一个完全冷模式的Observable对于每个订阅者都会独立的重新生成数据。但是,一个仅在首次订阅后才开始产生数据并使之后的订阅者共享同一个生产环境发送同样数据的Observable,我们又该如何称呼呢?事情变得模棱两可,简单的将Observable分为冷热是不够的。

不管怎样,这儿有一条经验:当你有一个冷模式的Observable而又想不同的订阅者订阅它时获得之前产生过的数据时,你可以使用publish和它的小伙伴们。

注意:Http下的Observables

Angular2中的Http服务会返回一个冷模式的Observable,这里我们需要注意一下其潜在的行为。

首先我们来创建一个组件,该组件会从服务器获取contacts.json文件并渲染为页面上的一个列表。

...
@Component({
  ...
  template: `
    <ul>
      <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
    </ul>
    `
  ...
})
export class AppComponent {
  contacts: Observable<Array<any>>;
  constructor (http: Http) {
    this.contacts = http.get('contacts.json')
                        .map(response => response.json().items);
  }
}

在上面的例子中,我们将一个Observable<Array<any>>传递到模板中并使用AsyncPipe

contacts.json文件如下所示:

{
  "items": [
    { "name": "John Conner" },
    { "name": "Arnold Schwarzenegger" }
  ]
}

现在,如果我们再添加一个列表会怎样?

1st List
<ul>
  <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
</ul>
2st List
<ul>
  <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
</ul>

运行上述代码并观察浏览器控制台的网络面板,我们会发现浏览器对contacts.json发送了两次请求,这与我们用Promise的情况大相径庭。实际上,为我们的Observable添加toPromise操作符就可以消除第二次请求。

不过我们先别急着回到Promise。用我们之前学到的冷热模式,我们可以很轻松的将例子中的Observable转化成对所有订阅都共享同一生产环境的Observable,这里的环境就是HTTP调用。

this.contacts = http.get('contacts.json')
                    .map(response => response.json().items)
                    .publish()
                    .refCount();

这样就达到了我们想要的效果吗?不完全是。我们可以看到此时已经没有了第二次网络请求,但是这与使用Promise的情况还存在一些区别。

假如我们希望第二个列表延迟500ms再出现,修改代码如下:

@Component({
  ...
  template: `
    1st List
    <ul>
      <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
    </ul>
    2st List
    <ul>
      <li *ngFor="let contact of contacts2 | async">{{contact.name}}</li>
    </ul>
    `,
  ...
})
export class AppComponent {
  contacts: Observable<Array<any>>;
  contacts2: Observable<Array<any>>;
  constructor (http: Http) {
    this.contacts = http.get('contacts.json')
                        .map(response => response.json().items)
                        .publish()
                        .refCount();

    setTimeout(() => this.contacts2 = this.contacts, 500);
  }
}

注意到我们的Observable还是只有一个,并在500ms后将其赋值给contacts2。运行上面代码后,我们发现第二个列表并没有出现。

如果你仔细想想,这其实是符合逻辑的。当使用publish后我们将例子中的Observable变为共享的 - 即热模式,但是我们有点加热过了度。500ms后添加第二个订阅者时,它只会在有新的数据推送时才会触发,我们期望的是新订阅者能够获得之前发射过的数据。幸运的是我们可以用publishLast替代publish来实现这一需求。

this.contacts = http.get('contacts.json')
                    .map(response => response.json().items)
                    .publishLast()
                    .refCount();

现在运行上面的代码,我们能在500ms后看到列表并且没有发生第二次网络请求。换句话说,我们创建了一个能够在被订阅后触发并共享第一次发射的数据的Observable

实用技巧

publishrefCount是一个很常用的组合,而操作符share可以同时完成这两步。

this.contacts = http.get('contacts.json')
  .map(response => response.json().items)
  .share();

Demos


TonyZhu
764 声望56 粉丝

蚂蚁金服大安全风控+团队招人,前端后台都要,简历发送至huitong.zht@antfin.com