キーポイント
- Major front-end frameworks such as React are getting more complex as they continue to add features. The added complexity is visible in the additional tooling, syntax and ecosystem that comes together with these frameworks.
- Part of that complexity comes from the fact that large frameworks need to maintain a high level of backward compatibility and stability due their large amount of users. As such, they have an incentive to not revisit key design choices.
- Crank revisits a key architectural part of React-like frameworks that dictates render functions to be pure functions. Instead, Crank leverages asynchronous generators to perform asynchronous rendering for free. Asynchronous generators are a standard language feature of JavaScript and do not carry the cost of a library implementing the functionality.
- Working with the generators baked in the language and the async/await syntax allows developers to handle asynchronous tasks (fetching remote data, suspending and resuming rendering) as naturally as synchronous ones. The number of concepts to master to implement a front-end application that are foreign to the language decreases.
オープンソースライブラリRepeater.jsの作者であるBrian Kim氏が先頃、Webアプリケーション開発用の新たなJavaScriptライブラリのCrankをリリースしました。Crankの特徴は、非同期ジェネレータ(aync generators)を使ってJavaScriptで実装されたコルーチンによって、アプリケーションの動作を宣言的に記述できることです。
現在はまだベータ段階であるため、さらなる研究開発が必要ですが、非同期レンダリングが可能になることで、Reactの提供するSuspense機能と同様なユースケースに対処できる可能性があります。
ジェネレータが実行されると、受信したデータ(initial props)から返されるイテレータによって、ジェネレータクロージャ内に保持されているプライベートな状態へのアクセスが可能になります。イテレータは、それがイテレートする対象(Crankライブラリ関数)からの指示があれば、いつでもビューの計算と生成を行います。その際に後者は、イテレーション要求の中に更新されたpropを渡します。Crankの非同期イテレータはpromiseを返します。このpromiseに計算結果のビューが格納されており、これによって非同期レンダリング機能が実現するのです。このようにCrankコンポーネントは、特別な構文を必要とせず、自然な形でローカルステートをサポートしています。さらに、Crankのコンポーネントのライフサイクルはジェネレータのライフサイクルと等価です — ジェネレータはマッチするDOM要素がマウントされると起動され、アンマウントされると停止します。エラーの検出は、標準的なJavaScript構造のtry... catch
で行うことができます。
Webアプリケーションの構築にJavaScriptジェネレータを活用しているフレームワークは他にもあります。HaskellからJavaScriptに移植されたConcur UI、PureScript、Pythonなどは、コンポーネント構築に非同期ジェネレータを使用しています。また、妥協のない純粋関数型Webフレームワークと称するTurbineは、FRPパラダイムの実装にジェネレータを活用しています。
InfoQでは今回、作者のBrian Kim氏から、新たなJavaScriptフレームワークを開発した背景について、特にJavaScriptジェネレータを使用することで得られたメリットを中心に話を聞くことができました。
InfioQ: 読者に自己紹介をお願いします。
Brian Kim: 私はフリーランスのフロントエンドエンジニアです。プログラマとしてのキャリアのほとんどで、Reactを使ってきました -- 2013年のReactのブログ記事に、私のことが紹介されています。
オープンソースの非同期イテレータライブラリであるRepeater.jsの作者でもあります。このライブラリは、JavaScriptに欠落している、安全な非同期イテレータを生成するためのコンストラクタを補完するものです。[...] そのために、ユーティリティクラスの"リピータ(repeater)"を開発しました。これはPromiseコンストラクタによく似ていて、コールバックベースのAPIを非同期イテレータに、これまでよりも簡単に変換できるようにします。
InfoQ: リピータの目的について、手短に説明して頂けますか?
リピータには、遅延実行やバウンデッドクエリ(Bounded Query)の使用、バックプレッシャの処理、予測可能性を持ったエラー伝搬など、私がこれまで習得した非同期イテレータの優れた設計プラクティスをたくさん詰め込んでいます。基本的には、非同期イテレータを使用する開発者が成功パターンに入ることのできるように注意深く設計されたAPIです。イベントハンドラを常にクリーンアップし、ボトルネックやデッドロックを短時間に発見できるようにします。
InfoQ: 先日リリースされたCrank.jsは、Webアプリケーションを開発するための新たなWebフレームワークという説明ですが、なぜ今、新たなJavaScriptフレームワークが必要なのでしょうか?
Kim: 確かに、新しいJavaScriptフレームワークは毎週のようにリリースされています。Crankを紹介するブログ記事の冒頭でも、さらに新しいものを作ることについてお詫びを書いたくらいです。私がCrankを作ったのは、HooksやSuspenseといった最新のReact APIには不満でしたが、それでもReactによって普及したJSXや要素駄文アルゴリズムを使いたかったからです。5年程の間Reactを満足して使っていたので、我慢できなくなって自分自身でフレームワークを書くまでには、本当に長い時間が掛かりました。
InfoQ: 何が不満だったのでしょう?
Kim: そもそもの始まりはフックだったと思います。コンポーネントに状態を持たせることによってコンポーネントの関数構文をより使いやすいものにする、という作業にReactチームが投資してくれたことは、私にとってとてもうれしいことでしたが、その"フックのルール"については懸念がありました。名称が
use
で始まる関数をそれ専用にするというのは他のフレームワークに対して不公平ですし、簡単に回避できたはずだ、と思ったからです。その後、実際の開発でフックを習得し始めるようになって、let
やconst
の発明以来、JavaScriptではお目に掛からなかったような古いクロージャのバグが見えるようになると、はたしてフックが最良のアプローチなのか、という疑問を持つようになりました。ですが、私にとって本当の転換点はSuspenseプロジェクトでした。[...]
InfoQ: 詳しく説明して頂けますか?
Kim: Suspenseを試し始めたのはこの頃でした。私が書いた非同期イテレータフックを同期のように使えるようになる、と思ったからです。しかしながら、実際にSuspenseを使用することはできないのではないか、とすぐに気付きました。Suspenseにはキャッシュに対して厳しい要件があり、私がフックで使用している非同期イテレータのキャッシュや再利用を行う方法が明確ではなかったからです。
Reactの世界にありながら、Suspenseと非同期データ取得にキャッシュが必要だという事実は、それまでReactコンポーネントのasync/awaitのようなものが手に入ったのだと思っていた私にとって、かなりの驚きでした。[...] promiseを使うためだけにキーを設定して、非同期コールを行うたびに無効化しなければならないというのは、とても気が重かったのですが、
Reactがコンポーネント内で、
componentDidWhat
メソッドやフックを使って行っていることは、すべてひとつの非同期ジェネレータ関数内にカプセル化できるのではないか、ということに気が付きました。async function *MyComponent(props) let state = componentWillMount(props); let ref = yield <MyElement />; state = componentDidMount(props, state, ref); try { for await (const nextProps of updates()) { if (shouldComponentUpdate(props, nextProps, state)) { state = componentWillUpdate(props, nextProps, state); ref = yield <MyElement />; state = componentDidUpdate(props, nextProps, state, ref); } props = nextProps; } } catch (err) { return componentDidCatch(err); } finally { componentWillUnmount(ref); } }
[...] JSXを返す代わりに生成することで、
componentWillUpdate
やcomponentDidUpdate
と同じようなコードをレンダリングの前後に書くことができるようになります。状態はローカル変数になって、フレームワークの提供する非同期イテレータを使って新しいpropを渡すことが可能になります。さらにtry/catch/finallyなど、JavaScriptのコントロールフロー演算子を使って子コンポーネントからのエラーをキャッチしたり、クリーンアップロジックを記述したりすることも可能です。これらがすべて、同じスコープ内にあるのです。
InfoQ: それで、非同期ジェネレータを新たなフレームワークの基盤にしようと決めたのですか?
Kim: [...] Reactチームは"UIランタイム"の構築に相当な能力を費やしたようですが、私は、スタックの一時停止やスケジューリングといった難しい部分については、JavaScriptランタイムに任せられることに気が付いたのです。ランタイムが提供するジェネレータや非同期関数、マイクロタスクキューなどが、それを確実に実行してくれます。Reactチームが行ったことで、1プログラマとしての私では到底できそうもないような素晴らしいことは、すべてバニラJavaScriptですでに用意されていると思えたので、あとはパズルのピースを組み合わせる方法を理解するだけでした。
Crankは、コンポーネントが同期関数だけでなく、非同期関数や、同期および非同期のジェネレータ関数でも記述可能であるというこのアイデアを、1か月にわたって追及した成果です。それまでは自分が最初に持っていたアイデアに真っ向から取り組んでいた私にとって、人生の上でのちょっとした回り道になりました。正直に言うと、フレームワークではなくアプリケーションの開発に戻りたい気持ちがあったのですが、JavaScriptコミュニティから突然注目されたことが、Crankにとって幸福なアクシデントになりました。
InfoQ: Crank.jsではJSX駆動のコンポーネントと非同期ジェネレータを使用している、という話がありましたが、JSXコンポーネントは、レンダリング関数を使用するフレームワーク(React型フレームワーク、ある意味ではVue)では一般的なものです。一方で、ジェネレータを使うものはすくなく、非同期ジェネレータはさらにまれです。このような構成は、Webアプリケーションの開発とどのように関連するのでしょうか?
Kim: 間違いないのは、ジェネレータや非同期ジェネレータを試したのは私が初めてではない、ということです。私はいつもGitHubで新しいアイデアを探しているのですが、フロントエンドでジェネレータを実験している人はたくさんいました。
ですが、おそらくはJavaScriptのジェネレータが当初async/awaitやpromiseと関連付けられていたために、これらライブラリの多くは、コンポーネントに非同期依存性を指定する方法として、promiseの生成にジェネレータを使用し、JSX要素を返すだけのようでした。私はもっと単純に、JSX要素を生成することができるのではないか、と気が付いたのです。そこで、仮想DOM差分アルゴリズムに基づいた、非同期コンポーネントの新たなセマンティクスを考え出しました。
結論として、JSX要素とジェネレータは実際に、完璧にマッチングすると考えています — 要素を生成し、フレームワークがそれをレンダリングして、レンダリングされたノードがジェネレータに対して、コール・アンド・レスポンス的なパターンで返されるのです。全体的に、多くの人たち、特に関数プログラミングにバックグラウンドを持つ人たちは、イテレータやジェネレータについて考え込む傾向があるのではないかと思います。これらがステートフルなデータ構造であるがために、ジェネレータがステートフルである理由について考え過ぎるのではないのでしょうか。ですが事実として、これはジェネレータの優れた機能のひとつだと思います。少なくともJavaScriptにおいては、ステートフルなプロセスをモデル化する最良の方法は、ステートフルな抽象化を使用することです。
コンポーネントのライフサイクルをジェネレータとしてモデリングすることで、コンポーネントインスタンスごとにジェネレータを1回のみ実行し、そのクロージャをレンダ間で保持することが可能になるため、DOMの状態を単一の関数内に取り込んでモデル化するという作業が、極めて透過的な方法で実現します。Crankで同期ジェネレータコンポーネントが再起動される回数は、親がそれを更新する回数と、コンポーネント自身が更新する回数とを加えた数と同じです。このような、コンポーネントが実行される正確な回数を論証するという能力は、Reactではほぼ諦められていた類のものです。事実として、Crankを使用すること9で、期待しないタイミングでコンポーネントが定常的に再レンダリングされることがなくなるので、サイドエフェクトを直接"renderメソッド"に置くことが可能になります。
InfoQ: 開発者からは、どのようなフィードバックがありましたか?
Kim: "こういうものがRustにあれば"、というフィードバックを受け取っています。Crankのアイデアを他の言語、特にRustのfutureのように強力な抽象化機能を持つ言語に移植してもらえれば、本当に素晴らしいと思います。
InfoQ: 他のフレームワークでは困難だったもので、Crankを使うことで簡単になる、というようなものについて、何か例を挙げて頂けますか?
Kim: すべての状態が単なるローカル変数なので、propsやstateやrefといったReactのコンセプトとジェネレータコンポーネント内で混在することが自由にできます。これは他のフレームワークではできないことです。例えば、新旧のpropsを比較して、一致の有無をベースとした差異を描画するこのコンポーネントの例には、Crank開発の早い段階でとても驚かされました。
function *Greeting({name}) { yield <div>Hello {name}</div>; for (const {name: newName} of this) { if (name !== newName) { yield ( <div>Goodbye {name} and hello {newName}</div> ); } else { yield <div>Hello again {newName}</div>; } name = newName; } } renderer.render(<Greeting name="Alice" />, document.body); console.log(document.body.innerHTML); // "<div>Hello Alice</div>" renderer.render(<Greeting name="Alice" />, document.body); console.log(document.body.innerHTML); // "<div>Hello again Alice</div>" renderer.render(<Greeting name="Bob" />, document.body); console.log(document.body.innerHTML); // "<div>Goodbye Alice and hello Bob</div>" renderer.render(<Greeting name="Bob" />, document.body); console.log(document.body.innerHTML); // "<div>Hello again Bob</div>"
独立したライフサイクルや新旧propsを比較するフックの必要はなく、クロージャ内で単に両方を参照すればよいのです。要するに、新旧propsの比較が、隣接する配列要素の比較と同じくらい簡単になっているのです。
しかもCrankがローカル状態の概念をレンダリングから分離しているので、他のフレームワークではまったく不可能な、多くの高度なレンダリングパターンへの道も開かれていると思います。例えば、子コンポーネント群はローカル状態を持つだけでレンダリングせず、requestAnimationFrameループ内でレンダリングする単一の親コンポーネントがすべてを一気にレンダリングするようなアーキテクチャも考えられるでしょう。ステートフルだが更新毎の再描画を行わないようなコンポーネントは、Crankならば簡単に実現できます。それもCrankが状態とレンダリングを切り離しているからなのです。
例として、私がまとめたこの簡単なデモを確認してみてください。これは昨年、ReactとSvelteの支持者がTwitterで議論した、3Dの立方体と球体のデモを実装したものです。Crankのパフォーマンスの高さには驚かされます。これは、コンポーネントの更新がジェネレータを通過するだけだからです。さらに、状態が単なるローカル変数であることや、上位コンポーネントがレンダリングするにも関わらず、すべてのステートフルなコンポーネントにレンダリングを強制するリアクティブシステムに強く結合していないことから、ユーザ空間においてさまざまな最適化を行う余地が生まれています。Crankの最初のリリースでは、パフォーマンスよりも正確性とAPI設計を重視していたのですが、今は可能な限り高速にすることを試みています。Crankのパフォーマンスについて、まだ具体的な主張をする気はありませんが、結果についての手応えは感じています。
InfoQ: 逆に、Crankよりも他のフレームワークの方が簡単にできる可能性のあることは何ですか?
Kim: 私はConcurrent ModeとReactの将来的な方向性には批判的なのですが、もしReactチームがそれを実現できたならば、メインスレッドの混雑度に応じて自動的にレンダリングをスケジュールできるような、すばらしいコンポーネントになると思います。この種のスケジューリング機能をCrankに実装する方法をいくつか考えているのですが、これというソリューションはまだできていません。うまくいけば、コンポーネント内で直接待ち合わせができるという事実から、透過的かつオプトイン的な方法で、ユーザ空間内に直接スケジューリングを実装できるかも知れません。
さらに、私はフックを支持している訳ではないのですが、ライブラリの作者がAPIをひとつないしふたつのフック内にカプセル化する方法については、少し言っておきたいことがあります。私が期待していて、まだ実現されていないことのひとつは、アーリーアダプタからの、自分のライブラリをCrankに統合するためのフック的な機能の要求です。それがどのようなものになるか、まだ分かっていませんが、この件についてもいくつかアイデアがあります。
インタビュー回答者について
Brian Kimはフリーランスのフロントエンドエンジニアです。さらには、安全な非同期イテレータを生成するための欠落したコンストラクタを自負する、オープンソースの非同期イテレータライブラリRepeater.jsの作者でもあります。