Suspenseとは何か?

koba04氏:よろしくお願いします。「Suspense」というタイトルで話していきたいと思います。Suspenseを聞いたことがある方はどれぐらいいますか?

(会場挙手)

数人ぐらいですね。ありがとうございます。

最初に自己紹介をさせていただきます。「koba04」というアカウントでやっています。

今はサイボウズで働いていて、フロントエンド周りいろいろやっています。OSS関連では、React系にコントリビュートしていることが多いです。

最初にSuspenseとはなにかという話です。2月の後半のJSConf IcelandでDan Abramovによって発表されたのが最初です。

もう1つ、(React)17の目玉の機能として「Time Slicing」というものがあります。そっちは低スペックなモバイルでもUIをブロッキングせずに更新できるようになる機能で、Suspenseは非同期の更新処理をいい感じに扱うための機能というイメージです。

非同期の更新処理というのはなにかというと、例えばHTTP Requestとか、最近だとDynamic importなど、基本的にPromiseをprimitiveとして扱うようなもの。なので、Promiseを返すようなものだったらだいたいなんでも扱えます。

非同期の更新処理って面倒くさいなと思っているかと思います。例えばReduxとかで実装すると、Start、Success、Failureとかのアクションを3つぐらい発行してloadingのstateを管理してみたいな、「あぁ、つらい……」みたいな感じになると思います。

(会場笑)

あと、そこをちゃんとしようとすると面倒くさくて。よくある場面としては、いろんなコンポーネント単位でLoadingのスピナーがバーって5〜6個出ちゃったりとか、APIが100msぐらいで返ってきてるのに「loading...」というのが一瞬だけちらっと出たりして、すごいジャンクな感じ。

このあたりをちゃんとやろうとすると面倒くさいのはけっこう同意できる点じゃないかと思います。そのあたりを解決するための機能がSuspenseです。

Suspenseはどう動くか?

では、これはPromise supportかと思うと思いますが、ComponentがPromiseを返すのをsupportするというわけではありません。

ただPromiseを返すComponentだけsupportした場合でもこの問題は解決できません。例えば、子や孫のComponentが非同期の依存関係を持っているときは、全部親のComponentや、Loading出したいところまでリフトアップしてこないとダメで、それを手動でやるとするとだいぶ厳しい感じのコードになります。

例えばこのUserListPageで、その非同期の処理が全部終わるまでは「Loading...」を出したいときも、その中のComponentのPromiseの依存を全部上に持ってこないとダメになってしまうところが問題です。

では、Suspenseはどう動くのかというところです。16でError Boundaryという機能が入りました。これはなにかというと、renderやライフサイクルメソッドでエラーが起きたときに、それを親のComponentのcomponentDidCatchというライフサイクルメソッドでキャッチして、エラー用の表示をするための機能です。これと同じ仕組みでSuspenseは動きます。

Error Boundaryの場合はエラーオブジェクトをキャッチするんですが、Suspenseの場合はPromiseがthrowされるので、それをキャッチして。この場合もTimeoutというComponentでキャッチして処理するみたいなかたちになります。実際にはthenableなオブジェクトだったら大丈夫です。

なので、例えばここでUserというComponentがPromiseをthrowしたら、Timeoutでキャッチして。Timeoutは、指定されたミリ秒が経つかPromiseがresolveするかどちらかで、Timeoutが先に来たらfallbackの表示を出し、PromiseがresolveしたらUserを表示する、みたいなフローになります。

Promiseのキャッシュをうまく扱う仕組み

というので、もう1個要素があります。Promiseのキャッシュをうまく扱うための仕組みが必要になるんですが、そのためのsimple-cache-providerというライブラリもReactが提供しています。

これはReactのリポジトリの中に入っていて、コード自体もかなり小さく、ほかの依存もないので、Reactのリポジトリの中にあるコードの中ではかなり読みやすいほうだと思います。

最近はキャッシュにLRUの実装が入って400行ぐらいになったんですが、それでもかなり短いので、簡単に読めるコードになっています。こいつはキャッシュのコンテナみたいなイメージです。

どのように遷移するかというと、最初はなにもないのでEmptyの状態になっています。そこでreadとかpreloadをすると、非同期処理のPromiseを作成します。そうするとPendingという状態になります。

このときにreadしようとすると全部Promiseをthrowするかたちになります。Promiseがresolveしたら、それをデータをキャッシュとして持って、それ以降は同期的にデータを返すという挙動になります。

じゃあどう使うのかというところなんですが、最初になにかキャッシュを作って、あとはResourceというPromiseの依存のもの。この場合だとfetchですね。このfetchをしたものとキャッシュを組み合わせてuserFetcherを作って、それをComponentのrenderメソッドの中で読みます。

なので、最初になにもデータがないときは、このFetcher.readでPromiseがthrowされるというかたちになって、レンダリングがここで止まります。

このComponentはPlaceholderというComponentで囲まれていて、その中でさらにReact.TimeoutというComponentがキャッチします。この場合だと、200ms以上だったら「loading...」という表示をfallbackとして出す、という実装になっています。

なので、こんな感じで状態を明示的には管理せずに書けるようになったり、このTimeoutのところを柔軟に制御できるようになるのがSuspenseの機能です。

Promiseがprimitiveなので、先ほどはfetchでしたが、当然Dynamic importなども同じ感じで扱うことができます。

Suspenseを使ったデモ

ということで、ここからはデモをします。ネットワークに依存するのでうまくいくかわからないですけど(参考:React Suspense Demo)。

これは、APIのwaitやTimeoutの秒数を動的に切り替えられるような実装になっています。例えば普通に表示すると……。これはユーザーのstar一覧のリポジトリを取ってくるアプリなんですけど、今見たように「loading...」がチラって一瞬出てきました。

Suspenseを使うと例えば、1秒以上経ったら「loading...」を出したいということも可能です。逆にTimeoutまでにAPIのデータがloadしたら、「loading...」という表示は出ずに普通に出るという感じです。それで先ほどの問題が解決できます。

また、キャッシュに入っている場合、同期的にデータが返ってきます。だから一瞬で表示させることができるようになります。別のユーザーだと、またloadingが走るというかたちになります。

このように、非同期の更新処理の制御が柔軟にできます。

コードは、TimeoutのComponentがあって、expireしたら「loading...」と表示し、なければリポジトリのデータを出すようになっています(参考:GitHub - koba04/react-suspense-demo)。

このあたりはただAPIをfetchしているだけです。

さっきのcreateCacheなどを使ってfetcherオブジェクトを作成して、getApiDataとしてラップして、こいつをこのComponentのrenderメソッドの中から普通に呼んでいるだけという実装だけです。

もう1個は、これは画像を表示するケースです。普通に出すと、一瞬ガタガタっと表示されます。

これに対する解決方法としては、画像のサイズを固定するという方法を取ると思います。そうすると、画像のサイズは固定されますが、ネットワークが遅い場合だと、先ほどのように画像が徐々に読み込まれます。

例えば、回線を3Gぐらいにしておくとより顕著なんですけど、こんな感じにたぶん画像の読み込みが長くなります。これはたぶんみなさん許せないと思います。

ですので、方法としてはpreloadします。preloadすると、この場合は、画像の表示が全部preloadが終わった後に行われるので、一瞬でパッといい感じに出ます。

でも、今のなにも出ないのが嫌だという人は、プレースホルダー画像の表示もできるようになっています。例えばこんな感じにすると、プレースホルダー画像を表示して、loadが終わったら出すみたいなことが先ほどと同じ感じで出ます。

コードは先ほどとほとんどかたちは一緒になっています(参考:GitHub - koba04/react-suspense-demo)。ただ、今だとTimeoutのこのComponent自体が非同期のPromiseの投げるやつになっているんですけど。

例えばこの場合は、なにか別のただのComponentのwrapperがあって、この中にPromiseを返すComponentが入っているんですけど。こういうのって普通のPromiseのComponentサポートだと対応できないんですけど、そのへんもちゃんとできるようになっています。

では、ImageWrapperというタイトルを出したいと思ったら、その外側のTimeoutのさらに中でTimeoutでキャッチすることもできて、そうするとこいつが内側で処理するので外側まで伝播しなくなります。例えばこの「ImageWrapper」というタイトルはちゃんと表示されたまま、その中で画像のpreloadの表示がされるようになります。こんな感じですね。

エコシステムとの連携も

という感じで、更新処理の制御はすごく柔軟にできるようになるというのがSuspenseの機能です。

最初のTime Slicingもそうなんですが、基本ネットワークが不便だったり、すごい低スペックな端末とか使っている人に向けての機能が、最近はすごく強化されているという印象です。

というわけで、今のは17でデフォルトで使えるようになる予定で、最終的なAPIはたぶんまだ変わります。Reactはcanaryというタグをつけていて、ReactのcanaryとReact DOMのcanaryとsimple-cache-providerをインストールすれば使えるようになっています。今のデモもGitHubにあって、Netlifyとかでデプロイしてるので、手元でも見れます。

そのほかでは、サーバサイドでSuspenseを動かすという実験とかも行われていて。クライアント側でReact DOMとかをまったく使わずに今みたいな表示が行われています。このへんはソースが公開されていないので、どういうテイストになっているかはわかりませんが。

あとは、ReactEuropeでは、先ほどのSuspenseをApolloのキャッシュと組み合わせるデモもあったりとかって感じで、けっこうそのあたりのエコシステムと組み合わせるということも盛り上がっている段階です。

リリースは2018年中

というわけでまとめです。先ほどの例は、最終的なAPIではないので変わります。けっこうさっき見たみたいに、renderメソッドの中ってこれまでって「副作用みたいのは書くな」というのが鉄則というかベストプラクティスだったんですが、それを完全に覆してfetchしちゃうみたいな感じになっているので、Componentのあり方は変わってくるんじゃないかと思います。

あとは、先ほどのものを利用することでpreloadingをやったり、hiddenというpropsを使うことで、事前に裏側でメインのページを邪魔せずprerenderしておくみたいな、そういうのもできるようになったりします。

また、ReduxやStoreとどう組み合わせるのか、そうしたエコシステムとどう組み合わせていくかは今後も注目していくという段階です。

「最高! いつ使えるの?」というところで、一応予定としては2018年中に17がリリースされるイメージです。ただ、もう実際に使える段階になっていて、今はFacebookなどで実際に試したりしているような段階です。

ということで、Suspenseに備えていきましょう。ありがとうございました。

(会場拍手)