🖐️

takeWhile はメモリリーク (無駄なメモリ消費) の原因になり得る

2020/11/05に公開

takeWhile に参照透過でない式を渡すのは良くない。

上記が分かっていれば下記を読む必要はありません。

経緯

React 開発ノウハウメモ(随時更新)
上記記事を読んで unsubscribecomponentWillUnmount に書かない方法を知ったので調べてみた。[1]

takeWhile はメモリリークの原因になり得る。

takeWhile は値が来たときのみ実行されるので値が来なければ unsubscribe されない。
無駄なネットワーク通信や DB へのアクセスを続けてしまう。
takeWhile は値に応じて処理を続けるべきか決まる場合で使うべき。

Observable を流れる値と関係なく処理を止めたいなら unsubscribe を呼ぶか takeUntil を使う。

テストしてみる

実際に試してみるためのコードを書いた。
それらで下記の 3 つの関数を使っている。

utils.ts
// s 秒待つ
const sleep = (s: number) => new Promise(r => setTimeout(r, s * 1000))
// タイムスタンプをつけてログを出す
const log = (...v: any[]) => {
  const ts = (performance.now() / 1000) | 0
  console.log(...v, `(${ts})`)
}
// キャンセルできる Observable を生成
const make = (name: string) => {
  return new Observable<number>(s => {
    let canceled = false
    const push = (v: number) => {
      log(name, 'sub1', v, '- canceled', canceled)
      s.next(v)
    }
    Promise.resolve().then(async () => {
      push(12) // すぐに 12 を渡す
      await sleep(10) // 10 秒待ってから
      push(13) // 13 を渡す
      push(14) // 14 を渡す
    })
    return () => {
      log(name, 'unsub1')
      canceled = true
    }
  })
}

テスト内容

上記 makeObservable を生成したのち 1 秒後に処理を止める。

unsubscribe する

一般的なやりかたであると思われる。
問題なく動いている。

test1.ts
const test1 = async () => {
  const read = make('test1').pipe(
    tap(v => log('test1', 'sub2', v)),
  ).subscribe()
  await sleep(1)
  read.unsubscribe()
}
test1() /*
test1 sub1 12 - canceled false (0)
test1 sub2 12 (0)
test1 unsub1 (1)
test1 sub1 13 - canceled true (10)
test1 sub1 14 - canceled true (10)
*/

takeWhile を使う

1 秒後に止めようとしたが、その後 9 秒経って次の値が来るまで止まっていない。

test2.ts
const test2 = async () => {
  let alive = true
  make('test2').pipe(
    takeWhile(() => alive),
    tap(v => log('test2', 'sub2', v)),
  ).subscribe()
  await sleep(1)
  alive = false
}
test2() /*
test2 sub1 12 - canceled false (0)
test2 sub2 12 (0)
test2 sub1 13 - canceled false (10)
test2 unsub1 (10)
test2 sub1 14 - canceled true (10)
*/

takeUntil を使う

問題なく動く。

test3.ts
const test3 = async () => {
  const unsub = new Subject<void>()
  make('test3').pipe(
    takeUntil(unsub),
    tap(v => log('test3', 'sub2', v)),
  ).subscribe()
  await sleep(1)
  unsub.next()
  unsub.complete()
}
test3() /*
test3 sub1 12 - canceled false (0)
test3 sub2 12 (0)
test3 unsub1 (1)
test3 sub1 13 - canceled true (10)
test3 sub1 14 - canceled true (10)
*/

unsubscribetakeUntil 、どちらを使うべきか?

こちらの記事 (英語)では takeUntil がオススメされている。

手続き的な unsubscribe よりも宣言的な takeUntil が良いという判断だろうか?

補足

ReactRxJS を組み合わせる場合

大規模なアプリなら redux-observable を、小規模なら rxjs-hooks を使うことを勧める。
古い React を使っているなら recompose も良い。

脚注
  1. useEffect のある今の ReactcomponentWillUnmount のような古い方法を使う必要はないと思う。 ↩︎

GitHubで編集を提案

Discussion