React DOMとReact Nativeの違い(2018)
本記事は、2018年に筆者が書いた「Web最新技術がてんこ盛りのreact-native-domから目が離せない」という記事の一部分を抜き出したものです。
2018年の記事はproof of conceptなツールについて紹介していたものでした。そのため、時間が経てば陳腐化して読まれなくなりますし、私もそれでいいと思っています。
ただ、一部分については2021年になっても参照してくれる方がいるような、それなりにまとまった特異なノウハウだったことがわかってきました。そこで、単独の記事として分離し、Zennに転載することにした次第です。
React Nativeは2019〜2020年にかけて内部構造のリファクタリングが行われたため、本記事の内容が不正確になっている部分があるかもしれません。調査工数をかける余裕がないので、ひとまず2018年版のまま公開しますが、そのうち202x年版を書けたらいいなあと思っています。
はじめに
本記事は、2018年時点でのReact DOMやReact Nativeの実装について調査・整理したものです。
Reactでアプリケーション開発を行う場合、次のような組み合わせでライブラリを運用します。
- ブラウザ向け:React + React DOM
- モバイル向け:React + React Native
ここで出てくるReactライブラリは、ブラウザでもモバイルでも完全に共通のものです。一方、Reactコンポーネント内での出来事をブラウザやAndroidやiOSといったプラットフォームの挙動へと翻訳したり、ライフサイクルを管理する責務は、React DOMライブラリやReact Nativeライブラリに任されています。
本記事では、React・React DOM・React Nativeという各ライブラリの境界部分でどのような処理が行われているのかを解説して、3者がどんな責務を分担しているのかを読者が理解するための足掛かりとなることを目的とします。
今回ベースとするソースコード
本記事の中でソースコードへのリンクまたは引用を行う場合、下記のバージョンを指します。
- React v16.4.0
- React DOM v16.4.0
- React Native v0.55.4
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 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の責務を理解する上では、どれも重要な要素になりますので、それぞれ説明していきます。
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がどこまでできるのかという点については、以下の資料が詳しいです。
React Native的には「JavaScriptとしては大部分が十全に動く[1]けど、流石にDOM APIはないし window.document
も存在しないよ」というところが把握できていればいいのかなと思います。
さて、JSCはバックグラウンドスレッドをひとつ占拠する形で実行されます。このスレッドがJavaScriptにとってのメインスレッド(JavaScriptはシングルスレッドなので、唯一のスレッド)になります。AndroidやiOSのUIスレッドとは別にJavaScriptを実行するためのスレッドが動いているとご理解ください。
また、事前にネイティブ側からJSCに対して設定を行うことで、JavaScriptで呼び出す関数をネイティブ実装にすることもできます。いわゆるネイティブモジュールの仕組みです。この辺の雰囲気は公式ドキュメントを読んでいただいたほうが早そうですが、一例として次のような形で実装します。
#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側では以下のようなコードで呼び出せるようになります。
import {NativeModules} from 'react-native';
var CalendarManager = NativeModules.CalendarManager;
CalendarManager.addEvent('Birthday Party', '4 Privet Drive, Surrey');
上記のJavaScriptコードが実行されると、Objective-C側の RCTLogInfo()
が実行されて、ログが出力されるという寸法です。
(元の記事ではReact Native DOMのJavaScript実装に 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ベースのモバイルアプリ(+Webアプリ)開発フレームワーク「Expo」を触りながらアプリ開発に入門する本「基礎から学ぶReact Native入門」を書きました。気になったら買ってね。
Discussion