React NativeでsetNativePropsした結果がどうNativeに反映されるのか JS編
React Native OSSもくもく会では、現在、こちらのIssueに取り組んでいます。
(メンバー: @ggtmtmgg, @takahi5, @__syumai, @binaryta)
https://github.com/facebook/react-native/issues/20971
内容としては、 setNativeProps
を実行してTextInputの文字サイズを変更しようとした時に、予想通りの挙動をしてくれないという物でした。
setNativePropsとは
通常、ReactでComponentのpropsを更新したい場合は、該当のComponentに対して値を渡しているstateをsetState
で更新します。
setState
を実行すると、VDOMのツリー全体で、前回setState
した時点と差分があるかどうかの比較が走り、もし差分があれば、その部分のDOMツリーを再構成してrenderし直します。
一方、React Nativeを使う上で、stateは更新したくないが、Viewのプロパティだけを更新したい場合があります。
例えば、アニメーションするComponentを実装したいと考えた時、Viewのプロパティを更新するのにsetState
を使うと、Reactのrenderが連続的に走ってしまい、スムーズな動作が実現できなくなってしまいます。
setNativeProps
を呼ぶと、Reactのstateを更新することなく、NativeのViewのプロパティのみを直接書き換えることが出来ます。
setNativePropsの実行例
<TextInput ref={this.inputRef} style={{ fontSize: 20, }} /> <TouchableOpacity onPress={() => this.inputRef.current.setNativeProps({ style: { fontSize: 40, }, }); } > <Text>Press me to change TextInput fontSize!</Text> </TouchableOpacity>
今回問題となっている挙動
I'm trying to implement a dynamic fontSize on React Native based on the content size, but I hit a React Native limitation / bug.
You can change the fontSize multiple times before typing anything in the TextInput, but once you type something you cannot change it anymore. See it in action in the link below.
ざっくり翻訳
React Nativeで、コンテンツの大きさに応じて、動的にfontSizeを変更するものを実装しようとしています。しかし、React Nativeの制限、もしくはバグに引っかかってしまいました。
TextInputに文字を入力していない状態では、何度もfontSizeを変更することが出来ました。しかし、文字を入力した状態では一切変更出来なくなります。
本来予想される動作
setNativeProps
する度、意図通りにTextInputのサイズが変わって欲しかった
setNativePropsの実装
setNativeProps
がNativeのViewに反映されるまでの流れは、大きく2つに分けることが出来ます。
- JS側で変更するPropsを処理して、変更依頼をenqueueする。
- Native側で変更依頼をdequeueしてビューに反映させる。
今回はJS側の変更依頼をenqueueする処理に問題がないかを調べていきます。
JS側の実装
setNativePropsのJSの実装の実体は主に以下になります。
react-native/Libraries/Renderer/oss/ReactNativeRenderer-dev.js#L16165
setNativeProps: function(nativeProps) { ... var updatePayload = create(nativeProps, viewConfig.validAttributes); if (updatePayload != null) { UIManager.updateView( maybeInstance._nativeTag, viewConfig.uiViewClassName, updatePayload ); } }
この関数を以下の2つの部分に分けて実装を見ていきます。
- updatePayloadを作成する
- UIManager.updateViewにupdatePayloadを渡して変更依頼をenqueueする
1. updatePayloadを作成する
updatePayloadはcreateという関数で作られています。
var updatePayload = create(nativeProps, viewConfig.validAttributes);
そしてそのcreateではaddPropertiesを実行しています。
function create(props, validAttributes) { return addProperties( null, // updatePayload props, validAttributes ); }
そしてそのaddPropertiesでは引数にemptyObjectを渡した状態でdiffPropertiesを実行しています。
function addProperties(updatePayload, props, validAttributes) { // TODO: Fast path return diffProperties(updatePayload, emptyObject, props, validAttributes); }
diffPropertiesはupdatePayloadを返す役割を持っており、実体はこの関数です。
だいぶ簡略化したのですがちょっと長いのでソースコード内に説明を記述していきます。
function diffProperties(updatePayload, prevProps, nextProps, validAttributes) { ... /* nextPropsに対してループ処理をします。 * 今回のシチュエーションでは以下のオブジェクトが渡ってきます。 * { * style: { fontSize: 40 } * } * */ for (var propKey in nextProps) { /* validAttributesは以下のようなオブジェクトです。 * * { * ... * autoCorrect: true * backfaceVisibility: true * backgroundColor: {diff: null, process: ƒ} * blurOnSubmit: true * ... * } * * diffは値の変更を判定する関数です。 * processは値をNative側に渡す前に処理する関数です。 * backgroundColorは実数値に変換されます。 */ // もしattributeAttributesが値を持たないpropKeyであれば無視します attributeConfig = validAttributes[propKey]; if (!attributeConfig) { continue; // not a valid native prop } ... if (prevProp === nextProp) { // もし値が変わってなくても無視します continue; // nothing changed } if (typeof attributeConfig !== "object") { /* * もしattributeConfigがただのtrueであれば * defaultDifferで差分の有無を確認し * 差分があればupdatePayloadに追加します。 */ if (defaultDiffer(prevProp, nextProp)) { (updatePayload || (updatePayload = {}))[propKey] = nextProp; } } else if ( typeof attributeConfig.diff === "function" || typeof attributeConfig.process === "function" ) { /* * diffがfunctionならdiffを利用して差分を確認する。 * processがfunctionならprocessを利用して値を処理する。 */ var shouldUpdate = prevProp === undefined || (typeof attributeConfig.diff === "function" ? attributeConfig.diff(prevProp, nextProp) : defaultDiffer(prevProp, nextProp)); if (shouldUpdate) { var _nextValue = typeof attributeConfig.process === "function" ? attributeConfig.process(nextProp) : nextProp; (updatePayload || (updatePayload = {}))[propKey] = _nextValue; } } else { ... /* * もしattributeConfigがObjectじゃなければ、再帰的にdiffPropertiesを呼び出す。 * 例えばpropKeyが "style" のような場合にここを通ります。 */ updatePayload = diffNestedProperty( updatePayload, prevProp, nextProp, attributeConfig ); ... } } ... return updatePayload; }
これで晴れてupdatePayloadを返せました。
ちなみに、setNativeProps({ style: { fontSize: 40 } });
を実際に実行してみるとupdatePayloadは以下の値になりました。
{ fontSize: 40 }
要求通りにupdatePayloadが作られていたことがわかりました。
2. UIManager.updateViewにupdatePayloadを渡す
if (updatePayload != null) { UIManager.updateView( maybeInstance._nativeTag, viewConfig.uiViewClassName, updatePayload ); }
UIManager.updateViewは内部的にenqueueNativeCallを呼んでいます。
enqueueNativeCallは(かなり簡略化すると)以下のような関数です。
enqueueNativeCall( moduleID: number, methodID: number, params: any[], onFail: ?Function, onSucc: ?Function, ) { ... this._queue[MODULE_IDS].push(moduleID); this._queue[METHOD_IDS].push(methodID); ... this._queue[PARAMS].push(params); ... }
enqueueNativeCall
はキューにmoduleId, methodId, paramsを積んでいて、
UIManager.updateView
は、enqueueNativeCallで下記の値をキューに積んでいました。
{ moduleID: 41, methodID: 6, params: updatePayload }
まとめ
setNativeProps
の実装を追い掛けた結果、渡したい値がちゃんと作られて、enqueueNativeCall
でキューに積まれていることが確認出来ました。
JavaScript側の実装には問題が無さそうと言うことがわかったので、次は、Native側の実装(今回は、iOS)を追い掛けてみようと思います。