RxJS可观察对象:订阅丢失?

8
以下两个可观察映射之间有什么区别?
(如果以下代码中的某些内容对您来说看起来很奇怪:它源自于一个学习性质的业余项目;我仍在学习RxJS)
我有一个组件,其中包括一个getter和一个构造函数。两者都从应用程序的ngrx存储中读取信息并提取一个字符串(name)。
getter和构造函数之间唯一的区别: getter在HTML中使用,返回的observable通过async管道发送,而构造函数中的observable映射则通过subscribe完成订阅。我期望它们都会在新值变为可用时触发。
但实际上只有getter以这种方式工作,并在HTML中与name的新值一起使用async管道提供(console.log('A')对于每个名称更改都被调用)。subscribe订阅的回调仅被调用一次:console.log('B')和console.log('B!')都只被调用一次,然后再也没有调用过。
如何解释这种行为差异?
来自我的组件的片段:
// getter works exactly as expected:
get name$(): Observable<string> {
  console.log('getter called')
  return this.store
    .select(this.tableName, 'columns')
    .do(_ => console.log('DO (A)', _))
    .filter(_ => !!_)
    .map(_ => _.find(_ => _.name === this.initialName))
    .filter(_ => !!_)
    .map(_ => {
      console.log('A', _.name)
      return _.name
    })
}

// code in constructor seems to lose the subscription after the subscription's first call:
constructor(
  @Inject(TablesStoreInjectionToken) readonly store: Store<TablesState>
) {
  setTimeout(() => {
    this.store
      .select(this.tableName, 'columns')
      .do(_ => console.log('DO (B)', _))
      .filter(_ => !!_)
      .map(_ => _.find(_ => _.name === this.initialName))
      .filter(_ => !!_)
      .map(_ => {
        console.log('B', _.name)
        return _.name
      })
      .subscribe(_ => console.log('B!', _))
  })
}

额外信息:如果我添加了ngOnInit,则此生命周期钩子在整个测试期间仅调用一次。如果我将订阅从构造函数移至ngOnInit生命周期钩子中,则其与从构造函数中进行订阅一样无效。完全相同(意外的)行为。对于ngAfterViewInit和其他生命周期钩子也是如此。

更改名称的输出'some-name' -> 'some-other-name' -> 'some-third-name' -> 'some-fourth-name' -> 'some-fifth-name'

[更新] 如Pace在他们的评论中建议的那样,我添加了getter调用日志

[更新] 如Pace所建议的,添加了do

getter called
DO (A) (3) [{…}, {…}, {…}]
A some-name
DO (B) (3) [{…}, {…}, {…}]
B some-name
B! some-name
getter called
DO (A) (3) [{…}, {…}, {…}]
A some-other-name
getter called
DO (A) (3) [{…}, {…}, {…}]
A some-third-name
getter called
DO (A) (3) [{…}, {…}, {…}]
A some-fourth-name
getter called
DO (A) (3) [{…}, {…}, {…}]
A some-fifth-name
< p >在< code >do中使用console.log打印输出的示例内容:

[
  {
    "name": "some-name"
  },
  {
    "name": "some-other-name"
  },
  {
    "name": "some-third-name"
  }
]

似乎在第一次订阅调用后,subscribe 订阅就会丢失。但为什么呢?


2
这是一个很好的问题。在你展示的代码中,没有什么引起我的注意,可能会有问题。我假设这两个方法都是同一个类的一部分?我可以在getter内部放置一个调试打印语句,以查看getter被调用的次数。也许差异在于getter被调用的次数比一次多,并且每次都设置了一个新的可观察链? - Pace
你的假设是正确的。我刚试了一下并将getter调用添加到输出中。现在我需要找出是谁取消了我的订阅... - ideaboxer
1
你可以在select之后使用do来查看新事件是否被过滤掉了吗? - Pace
1
有什么东西改变了 initialName 的值吗? - bygrace
2
你能展示一下你的reducer代码吗?我在想你是否在reducer中改变了store的值而不是返回一个新的值?这可能意味着ngrx store从未发送任何更新(因此只有getter方法记录更改,因为它不断被重新订阅)。 - Miller
显示剩余7条评论
2个回答

11

你永远不应该像那样使用getter。不要从getter中返回Observable。

每次发生变更检测周期(这经常发生)时,Angular都会反复取消订阅/重新订阅。

从现在开始,我将用“CD”代替“变更检测”

简单的演示:

拿一个非常简单的组件:

// only here to mock a part of the store
const _obsSubject$ = new BehaviorSubject('name 1');

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  get obs$() {
    return _obsSubject$
      .asObservable()
      .pipe(tap(x => console.log('getting a new value')));
  }

  randomFunction() {
    // we don't care about that, it's just
    // to trigger CD from the HTML template
  }
}

你会在控制台中看到getting a new value,每次点击注册了(click)事件的“单击以触发变更检测”按钮时,都会触发新的CD周期。
无论你点击该按钮多少次,你都会看到两次getting a new value。(这是因为我们不在生产模式下,Angular执行2个CD周期,以确保变量在第一次和第二次变更检测之间没有改变,这可能会导致问题,但这是另一个故事)。 可观察对象的重点在于它可以长时间保持打开状态,你应该利用这一点。为了重构先前的代码以保持订阅打开并避免再次取消订阅/订阅,我们只需摆脱getter并声明一个公共变量(可以由模板访问):
// only here to mock a part of the store
const _obsSubject$ = new BehaviorSubject('name 1');

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  obs$ = _obsSubject$
    .asObservable()
    .pipe(tap(x => console.log('getting a new value')));

  randomFunction() {
    // we don't care about that, it's just
    // to trigger CD from the HTML template
  }
}

现在,无论您点击按钮多少次,您都将只看到一个获取新值(直到可观察对象发出新值为止),但变化检测不会触发新的订阅。

这是一个关于Stackblitz的实时演示,所以您可以玩一下并查看console.log正在发生=) https://stackblitz.com/edit/angular-e42ilu

编辑: getter是一个函数,因此,Angular必须在每个CD上调用它,以检查是否有新值从中出现,应该在视图中更新它。这耗费很多,但它是框架的原则和“魔力”。这也是为什么您应该避免在可能在每个CD上触发的功能中运行密集的CPU任务。如果它是一个纯函数(相同的输入相同的输出且没有副作用),请使用管道,因为它们默认被认为是“纯”的并且缓存结果。对于相同的参数,它们将仅在管道中运行一次函数,缓存结果,然后立即返回结果而不再次运行函数。


你昨天对我关于从变量和从getter获取可观察对象行为的问题给出了非常详细的答案,非常感谢! - Raven
不客气。我还添加了有关getter/pure pipe和性能的更多信息,请查看编辑部分。 - maxime1992
请告诉我这是否有助于解决您当前的问题,或者您需要进一步的信息。 - maxime1992
getter有一个非常特殊的含义:它是一种避免在可观察链中过早执行的同时避免对值进行任何写入访问的实验。如果我按照您的建议编写它,select调用将会抛出错误,因为当组件实例构造时存储库还没有准备好。如果我稍后分配可观察对象,比如在ngOnInit中,我就不能将name$作为readonly成员,这会破坏我尽可能使类不可变的努力。 - ideaboxer
尽管使用 getter 可能不是最佳的选择,但它是将 name$ 成员变为 readonly 的唯一方法。只要保持这个可观察引用的不可变性并且不会导致应用程序崩溃,更好的解决方案总是受欢迎的。 - ideaboxer
我必须补充说明的是,getter 方法不会导致任何明显的性能损失;这通常是代码需要改进的指标。 - ideaboxer

1
ngrx.select()返回的Observable仅在存储中的数据发生更改时触发。
如果您希望Observable在initialName更改时触发,则建议将initialName转换为RXJS Subject并使用combineLatest:
initialNameSubject = new BehaviorSubject<string>('some-name');

constructor(
  @Inject(TablesStoreInjectionToken) readonly store: Store<TablesState>
) {
  setTimeout(() => {
    this.store
      .select(this.tableName, 'columns')
      .combineLatest(this.initialNameSubject)
      .map(([items, initialName]) => items.find(_ => _.name === initialName))
      .filter(_ => !!_)
      .map(_ => {
        console.log('B', _.name)
        return _.name
      })
      .subscribe(_ => console.log('B!', _))
  })
}

好的,那就是解决方案。非常感谢。因为我之前预期的是名称会改变,而不是columns字段被改变,只有selectedColumn字段被改变(我在问题中没有提到它对问题的影响),所以存储器不会触发columns字段的选择事件。我没有考虑到由于订阅,它仍然被调用一次。 - ideaboxer

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接