パリで発表されていたReact向けプロダクトがあまりにも未来に生きていて興奮したので、紹介させてください。
目次
- 目次
- この記事のゴール
- 想定読者
- はじめに
- 今回ベースとするソースコード
- React Nativeは何をするツールか
- Reactは何をするツールか
- React DOMとReact Nativeの違い
- React Native DOMはどこがReact Nativeなのか
- React Native DOMのやばいところ6連発
- React Native DOMはやばい
- Donation Welcome!
この記事のゴール
この記事では、React Nativeの内部実装に関する予備知識がほとんどない人が、React Nativeが元々どういうものなのかをふんわりと理解した上で、React Native DOMがどのように変態かつ未来に生きているのかを分かったつもりになってもらうことを目的とします。
想定読者
- ReactがわかるJavaScriptエンジニア
- JavaScriptのマルチスレッド処理の実例に興味がある方
- WebAssemblyの実例に興味がある方
- React Nativeのソースコードを読んでみたいけど、JavaScriptならともかくJavaやObjective-Cを読むのはちょっと・・・と尻込みしていた方
はじめに
5月17, 18日に、パリでReact Europeが開催されました。
Reactに関する様々な知見が公開されたようですし、スケジュールを見た限りではReact Nativeに関するセッションも多めなように思われました。
中でも私の目を引いたセッションは、Vincent Riemerさん([twitter:@vincentriemer])の "Bridging React Native Back to its Roots" でした。動画↓とスライドがそれぞれ公開されています。
このセッションは「React Native DOMというプロダクトを作ってみたので、モチベーションと設計方針の紹介、それからデモをするね」というタイプのやつでした。
字面からなんとなく想像できるかと思いますが、React Native DOMは「React Nativeコードをブラウザ上で動かすためのライブラリ」です。
このへんのユースケースに興味があって調べたことがある方は「えっ、それReact Native for Webと何が違うの?」と思われたことでしょう。はい、私もそう思いました。
ちょうど、Cheap Dive into React Nativeを書いたおかげでReact Nativeのソースを読むのにも自信がついてきたところなので、「まあどうせYet Another React Native for Webでしょ」と思いながらReact Native DOMのソースを読み始めたのです。
そしたらまんまと度肝を抜かれたので、「こりゃみんなにどれだけすごいか伝えにゃいかん」と、こんな記事を書き始めた次第です。
長文で前提条件から細かく書いているので、ひとまず概要を知りたい方はQiitaの記事へどうぞ。
react-native-dom の何がすごいのか - Qiita
また、React Nativeのことはもうわかってるよ、という方は「React Native DOMはどこがReact Nativeなのか」の見出しを検索して、そこからお読みください。
今回ベースとするソースコード
本記事の中でソースコードへのリンクまたは引用を行う場合、下記のバージョンを指します。
- React v16.4.0
- React Native v0.55.4
- React Native DOM v0.1.2
React Nativeは何をするツールか
React Nativeは様々な側面を持つツールですが、「複数のプラットフォームにおけるネイティブViewを抽象化して記述するためのもの」という側面があることは多くの人に賛同いただけると思います。
JavaScript側で <ScrollView>
コンポーネントを使えば、AndroidのネイティブUIにScrollView
が描画され、iOSのネイティブUIにUIScrollView
が描画されます。各プラットフォームに存在する「画面をスクロールさせるView」を <ScrollView>
として抽象化している、ということができるでしょう。
GUIアプリケーションを構築していく上で、どんなデザインガイドラインに沿っていくにしても最低限必ず必要になるであろうUIパーツを、React Nativeは抽象化されたコンポーネントとして提供しています。
Reactは何をするツールか
Reactも様々な文脈で語られがちなツールですが、筆者は次の2つだけを強い特徴として認識しています。
- コンポーネントを組み合わせてUIを構築するためのツール(コンポーネントのためのツール)
- データ変更に伴ってUIを更新する際に、前後のUIの差分を事前に計算して、実際に更新するViewを最小限に抑えることで画面更新を効率化するためのツール(差分更新のためのツール)
一時期はVirtual DOMという言葉ばかりが独り歩きしていた感もありますが、上記のようにReactを認識する上では、縁の下の力持ちとして見ておいたほうがよいのだろうな、というのが最近の筆者の認識です。
これだけといえばこれだけではありますが、差分をViewに適用するプロセスを抽象化するために react-reconciler
というパッケージが用意されて、実装を react-dom
の ReactDOMHostConfig.js
や react-native-renderer
の ReactNativeHostConfig.js
が担当して、といった厚みのある仕組みが整えられていくのを見ると、やはりReactが強い関心を持っている分野であることは間違いないなあと思うところです。
React DOMとReact Nativeの違い
さて、Reactの役割はなんとなくわかりましたが、React Native DOMのことを理解しやすくするために、まずReact DOMとReact Nativeの違いを見ておきましょう。
Reactアプリケーションを描画するものたち
React DOMとReact Nativeの共通点を挙げるならば React製のアプリケーションをプラットフォーム上に描画する というものがあると筆者は考えています。
一方で、プラットフォームに対する向き合い方が違うために、React DOMとReact Nativeの役割は大きく違っています。それぞれについて説明していきます。
React DOMの役割
React DOMはその名のとおり、Virtual DOMの差分をDOMに適用するのが責務です。ReactDOMFiberComponent.jsの中でcreateElementやupdatePropertiesに実装されているような形でDOM APIをゴリゴリ叩くことにより、更新を実現しています。
また、react-reconcilerに触ってもらうための入り口としては、ReactDOMHostConfig.jsの中に、Viewツリーにノードを追加するためのcreateInstance、既存のノードに更新を行うcommitUpdateなどが用意されています。
ところで、DOMツリーに対しては強い関心を持っているReactですが、スタイルについては意外と関心を持っていません。CSS in JSで書かれたstyleは、CSSPropertyOperations.jsの主導で、pxを付けられたり、ハイフンを付けられたりするものの、ほぼそのままDOMに対して渡されます。それがどんな特色を持ったスタイルであるかには、関知しません。スタイルの解釈とレンダリングはブラウザの領分なので、当然といえば当然ですね。
React Nativeの役割
React Nativeというツールの具体的な役割は、ひとことでは表現できません。いくつかの役割の違うツールが組み合わさって動いているからです。大別すると次の3つに分類できます。
- ネイティブ処理系の上でJavaScript処理系を動かすための仕組み
- Reactを動かすための仕組み(React Native版のReact DOM)
- Reactから渡された差分をネイティブViewに適用するための仕組み
React Native DOMの真髄を理解する上では、どれも重要な要素になりますので、それぞれ説明していきます。
1. ネイティブ処理系の上でJavaScript処理系を動かす
Android SDK/NDKやiOS SDKを用いてロジックを記述する際には、次の言語が公式にサポートされています。
- Android
- Java
- Kotlin
- C++
- iOS
- Objective-C
- Objective-C++
- Swift
これら以外の言語を動かしたい場合には、一工夫が必要になるわけです。
React Nativeは、AndroidとiOSの上でJavaScriptの(できるだけ同じ)処理系を動かすために、WebKit(≒Safari)のJavaScriptエンジンであるJavaScriptCore(JSC)を採用しました。JSCならiOS SDKにはJavaScriptCore.frameworkとして使えますし、Android向けには少しビルド方法を弄って、facebook/android-jscという形にポーティングすることで、Android NDK上での動作を実現できています。前述の言語群の中で言えば、C++処理系の上でJavaScriptの処理系を動かすことにしたわけです。
JavaScriptCoreがどこまでできるのかという点については、以下の資料が詳しいです。
www.slideshare.net
React Native的には「JavaScriptとしては大部分が十全に動く*1けど、流石にDOM APIはないし window.document
も存在しないよ」というところが把握できていればいいのかなと思います。
さて、JSCはバックグラウンドスレッドをひとつ占拠する形で実行されます。このスレッドがJavaScriptにとってのメインスレッド(JavaScriptはシングルスレッドなので、唯一のスレッド)になります。AndroidやiOSのUIスレッドとは別にJavaScriptを実行するためのスレッドが動いているとご理解ください。
また、事前にネイティブ側からJSCに対して設定を行うことで、JavaScriptで呼び出す関数をネイティブ実装にすることもできます。いわゆるネイティブモジュールの仕組みです。この辺の雰囲気は公式ドキュメントを読んでいただいたほうが早そうですが、React Native DOMの話をしていく上では次のサンプルが非常に重要です。
// Objective-C #import "CalendarManager.h" #import <React/RCTLog.h> @implementation CalendarManager RCT_EXPORT_MODULE(); RCT_EXPORT_METHOD(addEvent:(NSString *)name location:(NSString *)location) { RCTLogInfo(@"Pretending to create an event %@ at %@", name, location); }
Objective-C側の RCT_EXPORT_METHOD()
マクロによって定義された addEvent
メソッドは、最終的にJavaScriptCoreに読み込まれ、JavaScript側では以下のようなコードで呼び出せるようになります。
// JavaScript import {NativeModules} from 'react-native'; var CalendarManager = NativeModules.CalendarManager; CalendarManager.addEvent('Birthday Party', '4 Privet Drive, Surrey');
上記のJavaScriptコードが実行されると、Objective-C側の RCTLogInfo()
が実行されて、ログが出力されるという寸法です。
RCT_EXPORT_METHOD
という言葉をよく覚えておいてください。テストに出ます。
2. Reactを動かす
DOM APIは叩けないながらもJavaScriptはちゃんと動く、ということは「Reactアプリケーション内の状態更新に応じた、Virtual DOMによる差分の算出」あたりまではReact DOMのときと同じようにできるはずです。
このへんはreact-reconcilerによる抽象化の範囲になっており、共通ののインターフェースが設けられています。react-native-rendererのReactNativeHostConfig.jsには、React DOMと同様にcreateInstanceもcommitUpdateも完備されています。react-reconcilerから見れば、React DOMなのかReact Nativeなのかは特に考えずに、とりあえずVirtual DOMの差分を投げつければOK、という作りにしたようです。
差分の算出と、更新の依頼、あたりまでの文脈であれば、React DOMとReact Native(厳密にはReact Native Renderer)は区別なく扱われるということを覚えておいてください。
3. Reactから渡された差分をネイティブViewに適用する
さて、react-reconcilerがReact DOMと同じ要領でReact Native Rendererに更新依頼を投げつけてくることが分かりました。しかしここからは同じ要領というわけにはいきません。ブラウザの場合はReact DOMもreact-reconcilerもUIスレッド上で動いているので、ReactDOMHostConfigを通じて更新依頼を行った場合にも、そのままDOM APIを叩いてツリーに書き込むことができました。しかし、React NativeのJavaScriptCore内で動くreact-reconcilerとネイティブViewの間には、大きな壁が2つ立ちふさがっています。言語とスレッドです。
言語は言わずもがなで、JavaScriptからObjective-C/Javaの処理系へと処理を渡さなければなりません。これは「1. ネイティブ処理系の上でJavaScript処理系を動かす」で述べたとおり、ネイティブモジュールの仕組みを用いて突破できます。
問題なのはスレッドです。「1. ネイティブ処理系の上でJavaScript処理系を動かす」で述べたとおり、React NativeのJavaScript処理系は、バックグラウンドスレッドで動作しています。一方、 ネイティブViewはUIスレッドからしか更新することができません。 何とかして、react-reconcilerが発令した更新依頼を、ネイティブViewまで伝える必要があります。
ここでReact NativeはウルトラCを発明しました。ネイティブ側にDOMツリーの代わりを作ったのです。次の図は、バックグラウンドスレッドで行われる更新処理の流れです。
処理の流れを番号に沿って説明しますと、次のようになります。
- react-reconcilerがVirtual DOMの差分を認識する(今回の例ではノードの追加)
- react-reconcilerがReact Native Rendererの
createInstance
関数を通じて、React NativeにViewの更新を依頼する createInstance
はUIManager
モジュールのcreateView
関数にViewの更新を依頼するUIManager
モジュールはネイティブモジュールであり、iOSにおいてはRCTUIManager.m
、AndroidにおいてはUIManagerModule.java
という実体を持ちます(以降は単にUIManagerと呼びます)- 以降の解説はiOS版をベースに行う*2ことにします
createView
はRCTShadowView
というツリー状のデータ構造に対して、追加処理を行う- 実際にネイティブViewに書き込む段階で実施したい更新命令をブロック(関数オブジェクト)に詰め込んで、
addUIBlock
を呼び出す - UIManagerのインスタンス変数である
_pendingUIBlocks
にキューとして保存する
ひとまずここで一度処理が落ち着きます。
ブラウザでいうところのDOMツリーに該当するShadowViewツリーを用意することで、React Native Rendererからの更新依頼に対して、DOMの更新に近い形でネイティブ側の受け入れを成功させています。これにより、複雑になりかねなかった 言語の壁を超える という大技を、比較的素直な形で済ませています。
筆者もまさかVirtual DOMのようなものがネイティブ側にもあるとは思っていなかったので、初めて見たときには驚きましたが、react-reconcilerとの兼ね合いを考えると悪くないようにも思えますし、ShadowViewの内部実装を見るとYogaのレイアウト計算にも活用されているらしいことが見て取れるので、なかなか重要な役割を果たしているようです。
さて、ここまでではまだスレッドの壁を越えられていません。越えてはいませんが、壁を越えたあとにやりたいことをブロック(関数オブジェクト)の形にしてキューに入れることができました。あとは壁の向こう側でキューから取り出すだけです。UIスレッド側の処理は次の図のようになります。
また順を追って解説します。
- UIスレッドからUIManagerのメソッドが呼ばれる*3
- いろいろと経路を通って、最終的に
flushUIBlocksWithCompletion
メソッドが呼ばれる- Yogaの評価はこの直前くらいにShadowViewに対して行われていました
- キューをひとつずつ実行する
- キューに登録されていたブロック(関数オブジェクト)を実行して、ネイティブViewを更新する
- ネイティブViewが更新済みであることをShadowViewに通知する
このような流れでネイティブViewに変更を適用しています。Javaの場合も(関数オブジェクト相当の言語機能がないのでちょっと雰囲気は違いますが)似たような仕組みになっています。
スレッドの観点では、この更新の仕組みは次のようにまとめることができます。
- JavaScript側のreact-reconcilerは容赦なく次々と更新依頼を出し続ける
- ネイティブ側は受け取った更新依頼の内容をShadowViewツリーとキューの形で保持して、一度バックグラウンドスレッドでの処理を終わらせる
- UIスレッドの都合がいいときにキューを随時実行していく
筆者はこの流れが良いバランスでできているように感じられて、とても好きです(個人の感想です)。
React Native DOMはどこがReact Nativeなのか
さて、React Nativeのおさらいができましたので、ようやくReact Native DOMの話ができます。結論から言ってしまうと、React Native DOMは DOMツリーをネイティブViewと見なして作られた、React Nativeのサードパーティ実装 です。
既にあるサードパーティ実装としてはWindows向けとmacOS向けが有名でしょうか。
特にWindows向けの実装はMicrosoftが手ずからメンテナンスを行っていることや、Skypeチームが頑張っているという噂のReactXPの基幹技術にもなっているということで、一時期注目されました。React Native DOMは、これらと同列のものです。
類似品としてReact Native for Webがありますが、これはたまたまReact Nativeと挙動が近いUIライブラリなので、どちらかというとmaterial-uiなどの親戚です*4。
React Native DOMのやばいところ6連発
それではReact Native DOMの何がやばいのかを見ていきましょう。
ReactからはReact Nativeに見えてるのがやばい
まずは序の口から。サードパーティ実装として成立しているので当然といえば当然ですが、react-reconcilerやReact Native Rendererからは普通のReact Nativeに見えています。
これはどういうことかというと、React Native向けのUIライブラリや各種モジュールが、ブラウザ上でも動く可能性を示唆しています。
react-native-paperのような素晴らしいUIライブラリがモバイルWebの文脈でも使えるかと思うと、否が応でもテンションが上がるというものです。*5
Objective-C実装をJavaScriptに書き直しててやばい
React Native Rendererからも普通のReact Nativeと同じように見えているということは、UIManagerを使っているはずです。ネイティブ実装であればUI Moduleの中身はJavaやObjective-Cで書いてあったわけですが、React Native DOMではどうなっているのでしょうか。
RCTUIManager.jsです!!!
大事なことなので二回言いました。
察しのいい皆様は、Androidエンジニアの筆者が、何故わざわざObjective-C版の実装を使って解説をしたのか、そろそろお気付きの頃ではないでしょうか。
恐ろしいことに、React Native DOMでは、Objective-C版のクラス群を、ほぼそのままJavaScriptにポーティングしているのです。
ディレクトリ構造もクラス名も、メソッド名まで瓜二つ。多少のマイナーチェンジが為されている箇所もありますが、まあ誤差の範囲です。
流石に@RCT_EXPORT_METHOD
デコレーターが実装されているのを見たときには腹を抱えて笑いました。
// JavaScript @RCT_EXPORT_METHOD(RCTFunctionTypeNormal) createView( reactTag: number, viewName: string, rootTag: number, props: Object ) {
ちゃんとネイティブモジュールを公開するために使っているらしいところが侮れない・・・
Web Workerまで使って2スレッド制を再現してるのがやばい
React Native DOMはReact Nativeのネイティブ側を差し替えるサードパーティ実装です。この説明に偽りはありません。
本家React Nativeは UIスレッドと別にJavaScriptを実行するためのバックグラウンドスレッドを持っていました。 Android実装もiOS実装もそうである以上、 React Native DOMもそうあるべきだ 、という作者のメッセージが筆者には聞こえます。
React Native DOMは Web Workerの中でReactアプリケーションを動かす ことで、ブラウザ上であるにも関わらずReact Nativeの2スレッド制を完全再現しています。React Nativeのスレッドの説明に当てはめるならば、次のような分担です。
RCTBridge.jsに出てくる Worker
や thread
などの文言はWeb Worker関連の実装なので、気になった方は眺めてみるといいでしょう。
Yogaをwasm向けにポーティングしてるのがやばい
先ほどのファイル一覧にも写り込んでいましたが、当然のように RCTShadowView.js
が存在していますし、ShadowViewツリーも構築されています。
さて、ShadowViewツリーはバックグラウンドのJavaScriptから来た更新依頼をDOMの代わりに受け付けるという責務以外にも、もうひとつの責務がありました。Yogaのレイアウト計算の対象になることです。
しかしここでよく考えてみましょう。YogaはAndroidやiOSといったプラットフォーム上で、Flexboxの思想によるレイアウト計算を行うためのものでした。確かにAndroidやiOSのネイティブViewをFlexboxで配置する上では必要なツールだったと思います。でも、Flexboxを標準搭載していることも多い近代的なブラウザに対して、わざわざYogaを使う必要はあるでしょうか? そもそもC++で実装されているものをブラウザで動かすのは無理筋では?
この疑問に対する回答としては 「そういうのはどうでもいい。俺はYogaを再現する」 だったようです。
あろうことかこの作者、Yogaをポーティングしたyoga-domというライブラリを作りやがりまして、Web Assemblyで動くようにしてしまったのです。
RCTShadowView.jsを眺めると、JavaScriptがWeb Assemblyの機能をimportして使っている、物珍しい光景を見ることができます。
// JavaScript import * as YG from "yoga-dom";
// JavaScript module.exports = (async () => { const Yoga: YG.Module = (await require("yoga-dom"): any);
やりすぎでは・・・? という気持ちもありますが、本家と挙動を揃えるためにはやむを得ない措置だったのでしょう。きっとそうに違いない。(自分に言い聞かせる)
React DOMを使ってないのがやばい
まあ、そんなこんなで最新技術をこんもり使っているReact Native DOMですが、さすがにネイティブViewであるDOMへのアクセスには、React DOMくらい使ってるでしょ……
というわけで、ネイティブコンポーネントを覗いてみましょう。本家React Nativeならば、RCTViewにはネイティブのViewを描画するためのコードが書いてあるので、React DOMとかのそれがあれっぽいものか書いてあるはず・・・
// JavaScript /** * @providesModule RCTView * @flow */ import type { Frame } from "InternalLib"; import UIView from "UIView"; import type RCTBridge from "RCTBridge"; import RCTEventEmitter from "RCTNativeEventEmitter"; import CustomElement from "CustomElement"; @CustomElement("rct-view") class RCTView extends UIView { bridge: RCTBridge; onLayout: ?Function; constructor(bridge: RCTBridge) { super(); this.bridge = bridge; } get frame(): Frame { return super.frame; } set frame(value: Frame) { super.frame = value; if (this.onLayout) { this.onLayout({ layout: { x: value.left, y: value.top, width: value.width, height: value.height } }); } } // TODO: Renable when I have a plan for focus styling // set accessible(value: boolean) { // this.tabIndex = 0; // } } export default RCTView;
ええええ、描画してるっぽいところが何もない・・・というかスーパークラスになってるUIViewってなんでしょう。iOS SDKのUIKitの基底Viewの名前がなんでこんなところに・・・ちょっと見てみましょう。
// JavaScript @CustomElement("ui-view") class UIView extends HTMLElement implements RCTComponent { _top: number = 0; _left: number = 0;
おおっと、HTMLElementが出てきたということはここが底でよさそうですが、わざわざUIViewという名前をつけているというのは、iOS SDK側のクラスまで再現の対象に入ってしまっているということで、業が深くなってきましたよ・・・
少なくともReact DOMは使っていなさそうなことが分かりました。まあ、React Native Rendererと同格のツールなので、こんな深いところで使うわけないでしょという話でした。
実はWebComponentsを描画してるのがやばい
そういえば、結局何処でDOMに描画しているのかがまだわかっていませんね。どこかにcreateElement
くらいはありそうなものですが。ちょっとここで、 @CustomElement("ui-view")
という記述が気になったので中身を見てみましょう。
// JavaScript export default function CustomElement(name: string) { return function(target: Function) { customElements.define(name, target); }; }
あっ、タグ作ってる(確信)。
Custom Elementsって、本当にあのWeb ComponentsのCustom Elementsさん!?
ちょっと公式サンプルを見てみましょう。
完全にCustom Elementsですわ・・・疑う余地なかったですわ・・・
React Native DOMはやばい
React Native DOMはやばい。DOMをネイティブViewだと言い張って、作りきってしまった。これはRNシリーズの一角を張るのに恥じない作りをしていると、筆者は考えています。ものがものなのでメンテナンスは大変そうだけど、なんとか生き残ってくれると面白いと思う。
最新技術をこれでもかと盛り込んでいるのも非常に面白いですね。
- Web Worker
- Web Assembly
- Web Components
どれも多くのブラウザに実装はされてきているものの、まだあまり活用事例がないものばかりで、非常に勉強になりました。その中でも、Web Workerを使った点について、筆者は高く評価しています。
React Native DOMで作ったアプリケーションでは、UIスレッドが動く用事はYogaのレイアウト計算と、DOMの描画くらいしかなさそうです。私たちがクソ重い処理を書き散らかしているReactアプリケーションは、別スレッドに逃がしてあるわけなので、普通に高速化しそうな気がします。
React Native DOMのアリナシとは別に、DOMを触らないようなビジネスロジックの処理をWeb Workerに逃がすパラダイムは、今後のスタンダードになりうる威力を持つように思いました。
We're in 2018 while @vincentriemer is in 2040
— Rubén Sospedra 💊 (@sospedra_r) 2018年5月19日
俺たちは2018年に生きてるけど、ヴィンセントは2040年に生きてる。
みんなreact-native-domを読んで2040年に行こう。
Donation Welcome!
思いの外、とんでもなく充実した記事になってしまったので、気が向いたら投げ銭ください*6*7。
*1:Babelが挟まっているので、関数や文法の提供元がJSCなのかPolyfillなのか分かりづらいことはあります
*2:本当はJavaを読みたかったのだけれど、React Native DOMの解説をするにはObjective-Cのほうが適切だったので泣く泣くObjective-Cを読んでました
*3:UIスレッドではないところから呼ばれることも想定している実装にはなっていますが、基本的にはUIスレッドから呼ばれるはず
*4:こんなこというとnecolasに叱られそうですが
*5:React Native for Webにreact-native-paperを適用しようとして撃沈した私怨があります
*6:39円投げてサンキューっていうのをやってみたいだけ
*7:最近の流行りを考えたらnoteで課金するべきなのかもしれないけど、noteに技術記事書くのしんどそうなんだよなあ・・・