React Hooksのメモ化のための第二引数の配列にオブジェクトを突っ込んだときの挙動について
タイトルが長い。
useEffect
とかuseMemo
とかの第一引数に渡した関数を発火させるかどうかの条件指定とも言える第二引数の配列にオブジェクトをまるごと入れたらどうなるのか
というはなし。
勘違いしそうなので最初に説明しておくと、
useMemo(()=> someFunction(), myObject)
ではなくて
useMemo(()=> someFunction(), [myObject])
です。配列の中にオブジェクトであって第二引数に直接オブジェクトではない。
なお当記事はReact16.8.1でのバージョン時点でのはなしです。
それで、タイトルのことをコードで書くとこう。当記事ではuseMemo
を例にその実態を追う。
const myObj1 = { foo: "foo", bar: "bar", baz: { qux: 100, quux: { deepFoo: 'I am deepFoo' } } }; const Foo = () => { // myObj1のfooプロパティが変わったらsomeFunctionが実行される const memo = useMemo(() => someFunction(), [myObj1.foo]); return <div>{memo}</div>; }; const What = () => { // オブジェクトをごっそり渡した場合はsomeFunctionはどういうときに実行される? const memo = useMemo(() => someFunction(), [myObj1]); return <div>{memo}</div>; };
コメント箇所で書いてあるように
Foo
コンポーネントではuseMemo
の第二引数にmyObj1.foo
を入れているmyObj1.foo
が変わったら第一引数の関数が発火する
What
コンポーネントではuseMemo
の第二引数にmyObj1
をまるごと入れている- しかも
myObj1
はディープなオブジェクト- 第一引数の関数が発火する条件はどうなるの?
- オブジェクトのどのプロパティが変わっても発火するの?
- 第一引数の関数が発火する条件はどうなるの?
- しかも
というはなし。
ここでReactちょっとやってる人だとなんとなく「shallow compare
がされて浅いプロパティが変わったときは発火するが、深いプロパティが変わったときは発火しない」という予想を立てるかもしれない。
この予想はshallow copy
/deep copy
とかshallow compare(equal)
/deep compare(equal)
とかその辺の話題と経験からくると思う。
結論から
どういう条件下で第一引数の関数が発火するのかを結論からいうと、
第二引数の配列にオブジェクトを入れるとディーププロパティが変わっても大抵の場合は発火する
"大抵の場合"の意味を掘り下げると
オブジェクトが同値でなければ発火する
という意味。
さらに掘り下げると、
オブジェクトが更新される際に常に新しいオブジェクトが返されていれば発火する
ということになるかと思う。
どういうことなのか
詳しいことは後述するけれど、
配列に入れたオブジェクトのプロパティ値の比較は一切されていない = 第二引数の配列に入れたものは同一性の判定のみがされている
という処理になっている(Reactのコード見たらそうなっていた)
なんとshallow compare(equal)
とかdeep compare(equal)
とかの問題がありそうという予想は外れてしまって、そういった比較はなかった……。
DEMO
とりあえず実際に動く例を。
第二引数の配列に入れたオブジェクトのディーププロパティが変わってもshallow compare
は関係がなく第一引数が発火する様子は以下。
JSONの下にあるボタンをポチポチするとオブジェクトのプロパティが変わって\ WOW /
という文字の色が変わる。
ソースコード見てもらえばわかるとは思うけれど、\ WOW /
のカラーコードはuseMemo
を通していて表題の第二引数の配列にはuseState
から作ったオブジェクトを指定している。
浅いプロパティでも深いプロパティでも値が変わると\ WOW /
がビカビカすると思う。
下側にあるカウンターはuseMemo
の第二引数がちゃんと働いていることを裏付けるために一応置いたもの。カウンターのStateが変わっても\ WOW /
の色は変わらないのでちゃんとメモ化されている。
v16.6で追加されたReact.memo
ではpropsをshallow compareしてpureかどうか判定しているはずで、なんだかHooksの第二引数にディープオブジェクトを突っ込んだ際のこの挙動はReactらしくないのではという気持ちになる。
以下からはReactのコードを見た詳細の様子がひたすら続きます。
第二引数の比較の真相に迫るべくReactコードの海へと潜った––––
ドキュメンタリーです。
useMemoの中身
中身はreact-reconciler/src/ReactFiberHooks.js
のmountMemo
とupdateMemo
っぽくて、
ちなみに第一引数はnextCreate、メモ条件の第二引数はdepsという名称になっているらしい
詳細なコードはGitHubで見てもらうとして、メモを返すか新しいものを返すかの判断はupdateMemo
関数のここ(一部抜粋)
const nextDeps = deps === undefined ? null : deps; const prevState = hook.memoizedState; if (prevState !== null) { // Assume these are defined. If they're not, areHookInputsEqual will warn. if (nextDeps !== null) { const prevDeps: Array < mixed > | null = prevState[1]; // 1. 初回時にメモしたdepsと現在のdepsを`areHookInputsEqual`で比較 if (areHookInputsEqual(nextDeps, prevDeps)) { // 2. イコールならメモしていたものを返す return prevState[0]; } } } // 3. イコールじゃなかったら第一引数の関数を発火 const nextValue = nextCreate(); // 4. 新しくメモ hook.memoizedState = [nextValue, nextDeps]; // 5. 新しいほうの値を返す return nextValue;
元コードを改行してコメント済み
という流れ。
areHookInputsEqual
をみてみる
同じくreact-reconciler/src/ReactFiberHooks.js
にあって
function areHookInputsEqual( nextDeps: Array<mixed>, prevDeps: Array<mixed> | null, ) { . . . // forで配列分まわして全部isだったら前回と変わってないのでイコール(true)を返す for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) { if (is(nextDeps[i], prevDeps[i])) { continue; } return false; } return true; }
なんかis
関数をやってる
is
関数をみてみる
shared/objectIs.js
がそれ
/* * inlined Object.is polyfill to avoid requiring consumers ship their own * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is /
と書かれていて、Object.is
のポリフィルらしい
Object.is
した結果、イコールだったらメモしていたものを返す、そうでないなら関数を発火させ新しい値を得てそれを返すという流れということがわかった。
Object.is
の比較って??
Hooks第二引数(deps)の比較はObject.is
ということがわかったところで、これってどういう比較判定なんだっけという疑問が浮かぶ。
便利なページと表があってなるほどだいたいなるほどという感じ。
同値チェックをしている……。
手元でObject.is()を試してみる
See the Pen Object.isの動作チェック by 1natsu (@1natsu172) on CodePen.
コメント見にくかったら別タブで開いてconsoleもみてください
いろいろやってみた結果Object.is()
でオブジェクトの比較をすると見た目も値もすべて同じだったとしても比較対象がそれぞれ別物であればfalse
が返るようだった。
やはりObject.is()
はオブジェクトの中身は見ていないよう。
ふりかえり(結局どういうことだったのか)
CodeSandboxのデモではuseState
から取り出したディープオブジェクトを扱っていた。
useState
のアップデータ関数経由でstateを更新する際は、常に新しいオブジェクトを返すのがReactにおける鉄則なはずで、つまりstateが更新されるときはオブジェクトの同一性がfalse
になる。
第二引数(deps)の比較はObject.is()
ということで、false
が返る=オブジェクトが変更された=第一引数(nextCreate)を発火という流れ。だからディープオブジェクトのディーププロパティが変わっても第一引数が発火してuseMemo
は更新されるという流れらしかった。
つまるところオブジェクトをFCの外側に持って、そのオブジェクトを直接書き換えたりしなければ大丈夫そうな気がしている。
let myObject = { foo: 'foo', bar: 'bar', baz: 'baz' } const hey = myObject myObject.foo = '変わりました'
みたいなこと。
する人いない気がするしそもそもこういうのReactでどうやるのっていう感じではあるけれどこれだとObject.is()
はtrue
を返すので、もしやってると意図せぬ挙動をしそうという予感がする。
Hooksの第二引数の配列にオブジェクトを入れるとディーププロパティでも反応するよというただそれだけのことなのだけど、追ってみると当然奥が深かった。
『配列にオブジェクトまるごと入れたらどうなるんだろうな、shallow equalされてディーププロパティは反応しなさそうだけど……』という疑問からまさかReactのソースコード見ることになるとは思ってもみなかった。