【1日1zenn - day21】createSelectorを理解する
今のプロダクトでどんな処理がどうmockされてるか収集してわからないところ調べようと思ってたのですが、あまりいじったことないSelector層の処理が普通にむずかったので、一回調べてみます。
追記、最初に取り上げた記事が良著すぎて、これの完全理解に時間を使いました。
色んなトピックについてこの解像度で説明することができるようになりたいというベンチマークだ。。
記事を読む
1記事目
そもそも何でSelector層って大事なの?
これ普通にエンジニアとして勉強になることが導入としてユーモラスに書かれていて良記事だ...。ぜひ読んでみてほしい。
さて、まずメモ化の重要性がフィボナッチ数に例えて書かれている。
const fib = n => {
if(n <= 1){
return n
}
return fib(n -1) + fib(n-2)
}
この処理の場合、例えばfib(5)を渡すと以下のようになる。
fib(5) = fib(4) + fib(3)
= (fib(3) + fib(2)) + (fib(2) + fib(1))
= ((fib(2) + fib(1)) + fib(1)) + (fib(1) + fib(0)) + fib(1)
= (((1 + 1) + 1) + (1 + 0)) + 1
= (3 + 1) + 1
= 5
これだと一度計算したfib(3)などにも繰り返し処理が走るので、数字が増えるとかなり非効率な処理になる。
これを効率化した例が以下だ。
const memo = {};
const fib = n => {
if (n <= 1) return n; //n が 0 または 1 の場合、n を返す
if (memo[n]) return memo[n]; //すでに計算済みならその値を返す
return memo[n] = fib(n-1) + fib(n-2); //未定義なら再帰的に計算してmemoに保存
}
memoという空のオブジェクトを用意しておき、一度実数の計算フェーズまでたどり着いたらmemoに保存する。
よってfib(5)を与えた場合は以下みたいな処理になる。
- fib(5) を呼び出す
- memo[5] は 未定義 なので fib(4) + fib(3) を計算。
- まずfib(4)が計算される
- fib(4) を呼び出す
- memo[4] は 未定義 なので fib(3) + fib(2) を計算。
- まずfib(3)が計算される
- fib(3) を呼び出す
- memo[3] は 未定義 なので fib(2) + fib(1) を計算。
- まずfib(2)が計算される
- fib(2) を呼び出す
- memo[2] は 未定義 なので fib(1) + fib(0) を計算。
- fib(1) = 1, fib(0) = 0 なのでそのまま返され、 fib(2) = 1 + 0 = 1。
- 計算が終わったので、memo[2] = 1が保存される
- STEP4が完了したのでSTEP3に遡り、
fib(3) = fib(2) + fib(1)
を計算。- この時fib(2)は、memo[2]がnullじゃないのでreturnに進んでmemo[2]として1が返されるし、fib(1)はそのまま1が返される
- よってfib(3) = 1 + 1 = 2がmemo[3]に保存される
- STEP3が完了したのでSTEP2に遡り、
fib(4) = fib(3) + fib(2)
を計算。- この時fib(3)はmemo[3]として2が返されるし、fib(2)は1が返される
- よってfib(4) = 2 + 1 = 3がmemo[4]に保存される
こんな風に足し算まで到達せず早期リターンされるようになるから、無駄な処理が行われない。
じゃあReactのselectorはなぜ必要?
なんとなく、重複する処理をなくすために処理の結果か何かを保持してるんだろうなぁというメンタルモデルを持ちつつ読み進める
Reduxのstoreに保存しているstateをReact Componentに渡す例
const DisplayBelts = ({ belts } : Props) => {
return (
<>
{belts.map(belt => <Image source={belt.imgUrl} />}
</>
)
}
}
const mapStateToProps = state => {
return {
belts: state.items.belts
}
}
export default connect(mapStateToProps)(DisplayBelts)
うちのプロダクトではconnect
関数ではなくuseSelector
とuseDispatch
を使っていますが、一旦このまま読み進めます。
connectはconnect(mapStateToProps, mapDispatchToProps)
のように書くことで、stateとcommponentを繋ぐことができるらしい。
mapStateToPropsがbeltsをリターンしていて、それをmapDispatchToPropsに引数として渡せる感じなのかな。
これによってitems.belts
をマップとして処理して、上記の例だと画像を1枚ずつ表示することができると。
発生する問題
しかしこれには問題があるらしい。
- React Componentにとって、Propsがどこにあるかを知る必要がある
- stateの中のitemsの中のbeltsにあるから、items.beltsをmapで処理する必要がある、みたいなのを把握してないと取り出せないってことかな?ちょっと曖昧。
- コードの保守性(変更容易性)が落ちる
- もしstateの中身が
belts: state.categories.items.belts
みたいに変わったら、mapStateToProps
も変える必要が出てくる。 -
mapStateToProps
相当の関数がめっちゃあったら超メンテ大変。
- もしstateの中身が
- パフォーマンスが低下する
3つ目が肝っぽい。
例示されたコードは以下。
const DisplayBelts = ({ belts, dispatch } : Props) => {
const onPress = () => dispatch(changeBelts())
return (
<View>
<TouchableOpacity onPress={onPress}>
<Text>Press me</Text>
</TouchableOpacity>
{belts.map(belt => <Image source={belt.imgUrl} />}
</View>
)
}
const mapStateToProps = state => {
return {
belts: state.items.belts.filter().reduce()... // some heavy calculation!
}
}
export default connect(mapStateToProps)(DisplayBelts)
これはPress Meをクリックするとbeltsの情報を更新するアクションがdispatchされるのだが、引数が変わるので毎回コンポーネント全体が再レンダリングされてしまう。
これについて、補足がアツい。
JSでは、配列やオブジェクトなど中身が膨大になる可能性があるものを比較する際、中身を1つずつ比べるのではなく、メモリアドレスを比較する。
以下みたいな感じ。
const objA = { a : 1 }
const objB = { a : 1 }
console.log(objA === objB) // false
const arrayA = [1,2,3]
const arrayB = [1,2,3]
console.log(arrayA === arrayB) // false
そして、Reduxはstateの変化をちゃんと検知してそのコンポーネントを再レンダリングするために使いたいので、以下のようにimmutable(参照するメモリアドレスを変える)必要がある。
// mutable な更新 (メモリアドレスは同じ)
const stateA = { name : "Bob", age: 31}
stateA.age = 35
const stateB = [1,2,3,4,5]
stateB.push(6)
// immutable な更新
const stateC = { name : "Bob", age: 31}
const newStateC = {...stateC, age: 35}
const stateD = [1,2,3,4,5]
const newStateD = [...stateD, 6]
しかしこれだと無駄に再レンダリングが走る場合があるので、reduxにはそれを防ぐ機能があるらしい。
それが、mapStateToProps
のshallowEqualという機能らしい。
これについてはこの記事が簡潔だったが、要は古いstateと新しいstateの1階層目が一緒だったら、もう同じ値だから更新しなくていいよとするものみたい。
深い階層を比べることはできない機能だが、メモリアドレスが変わっても再レンダリングを防ぐことができると。
なんだけど、
元のコードの例ではbeltsが変わる=stateが更新されると、結局belts更新のために毎回mapStateToProps
を計算し直す必要がある。
shallowEqualという機能があったところであまり意味がない。
(ちょっとここconnect関数わかってないせいで理解曖昧)
じゃあその計算を省くためにはどうすればいい?
解決策
改めて、問題は以下3つ。
- React Componentにとって、Propsがどこにあるかを知らないと取り出せない
- コードの保守性(変更容易性)が落ちる(storeを変えると全箇所変える必要が出る)
- パフォーマンスが低下する(mapStateToPropsを毎回計算することになる)
①と②の解決策は、以下のようにすること。
export const getBelts = (state) => state.items.belts;
import { connect } from 'react-redux'
import { getBelts } from './selector'
const DisplayBelts = ({ belts } : Props) => {
return (
<>
{belts.map(belt => <Image source={belt.imgUrl} />}
</>
)
}
}
const mapStateToProps = state => {
return {
belts: getBelts(state)
}
}
export default connect(mapStateToProps)(DisplayBelts)
このように、stateからのbeltsの取り出しをgetBeltsに持たせることにより、コンポーネント目線ではmapStateToProps
から一回のmapで取り出せばいいことは変わらない(変わらないようにmapStateToProps
を作ればいい)し、mapStateToProps
もメンテする必要がなくなる。
stateの持ち方が変わった時は、getBeltsだけをメンテすれば済むようになるのだ。
保守性が上がるので②は解決。
ちょっと①がよくわかってないけど、まあbeltsを一回mapで取り出せばいいってことが確定するから気にしなくて良くなるって感じかな。
だけどこれをやっても③のパフォーマンス問題は解決されない。
多分beltsを更新するような処理がある場合は、propsが変わるからDisplayBelts
コンポーネントが再レンダリングされ、となるとconnectも再実行され、となるとmapStateToProps
も再実行され、その中のgetBelts
が重い計算だったら再実行されることでパフォーマンスが悪化しちゃうってことかな?
ちょっとconnectが引き続きわかってないせいでmapStateToProps
が再実行されるのかがよくわかってないけど、今は非推奨っぽいから一旦置いておくとして読み進める。
とりあえずこれについて、getBelts
のpropsが前回と同じ値なら計算をスキップして前回の計算結果を返すことができれば解決しそう。
そのために、reselectというライブラリがある。
問題③の解決策
reselectのcreateSelector
は、以下のように使う
createSelector(...inputSelectors | [inputSelectors], resultFunc)
第一引数にinputSelectors
を何かしらの形で入れ、第二引数にresultFunction
を入れる。
この時inputSelectors
では、stateの全部ではなく、見る必要があるstateだけをpick upするとのこと。
具体例は以下。
const beltSelector = state => state.items.belts
export const mapStateToPropSelector = createSelector(
beltSelector,
(belt) => belt.filter().reduce()... // some heavy calculation here!
)
import { mapStateToPropSelector } from './selector'
const mapStateToProps = state => {
return {
belts: mapStateToPropSelector(state)
}
}
selector.tsのcreateSelector
にて、見たいstateであるbeltSelector
だけをpick upする。
そして第二引数として、beltSelectorを呼び出した結果に対して処理を行う。
この際、beltSelectorの戻り値が前回と同じだった場合は、第二引数として渡した処理を行わないらしい。
フィボナッチ関数で使ったif (memo[n]) return memo[n];
みたいな感じの処理がcreateSelectorで行われてるっぽい。
なるほど?と思って読み進めたら、かゆいところに手が届くようにソースコードも載ってた。
reselectはどうやってこの機能を実装しているか
ソースコードを一部改変したのが以下らしい。
※reselectがjsからtsに変わったことでリンクが切れてたんだけど、ここからEqualityFn
を辿れば見れそう?
function equalityCheck(a, b) {
return a === b
}
function areArgumentsShallowlyEqual(equalityCheck, prev, next) {
if (!prev || !next|| prev.length !== next.length) {
return false
}
for (let i = 0; i < prev.length; i++) {
// ここで、前回の値と、今回の値を shallow Equal で比較している
if (!equalityCheck(prev[i], next[i])) {
return false
}
}
return true
}
prev
は前回の実行結果で、next
は今回の実行結果になるものって感じの意味合いかな。
そしてさっきの例で言うconst beltSelector = state => state.items.belts
の戻り値のstate.items.belts
について、for文で中身を分解して不一致がないか見にいく。
これを以下のような関数でラップしてるっぽい。
function memoize(func) {
let lastArgs = null
let lastResult = null
return function(...currentArgs) {
if (!areArgumentsShallowlyEqual(defaultEqualityCheck, lastArgs, currentArgs)) {
lastResult = func(...currentArgs) // もし、現在の引数(currrentArgs) が前回と異なる場合、original function(func) を呼ぶ
}
lastArgs = currentArgs
return lastResult
}
}
areArgumentsShallowlyEqual
がfalse、つまり中身が一致してなかったら、func(...currentArgs)
を実行してreturnする。
そして、一致していた場合は、前回func(...currentArgs)
を実行した結果をlastResult
として返しつつ、currentArgs
をlastArgs
に格納して次回の呼び出しに備える。
なお関数の中で関数を宣言するクロージャーだから、前回の引数と結果を参照できるらしい。
こんなmemoize関数を、createSelectorの内部で使用しているらしい。
function createSelector(...selectors) {
const resultFn = memoize(selectors.pop()) //createSelectors の引数の最後が、resultFunction
return memoize((...args) => {
const resultFnParams = selectors.map(selector => selector(...args))
return resultFn(...resultFnPatams)
})
}
これちょっとむずいな。
与えられた引数に対して.popを行うことで引数の最後(重い処理)を取り出し、memoizeに渡す。するとresultFnはmemoizeのコールバック関数であるfunction(...currentArgs)
が入った状態になる。
そしてさらにcreateSelectorのreturn内で、引数はresultFnParamsとしてmapで分解され、さっき作ったfunction(...currentArgs)
に渡されて実行される。
ここでmemoizeのreturn関数により、lastArgsとcurrentArgsの差分がチェックされ、差分があった場合はcurrentArgsとして渡したcreateSelectorの最後の引数が実行される。
理解できた。
補足として、reselectは1階層しか比べないshallowEqualなので、deepEqualを使う場合にどうするかも説明されている。まあ一旦おいておこう。
useSelectorについて
最後にこれ。ライブラリではなくreduxが公式に提供しているやつ。
mapStateToProps
じゃなくて、今うちも使っているuseSelector
関数について。
ありがて〜〜。
以下みたいに使う。
const result = useSelector(selector: Function, equalityFn?: Function)
これはmapStateToPropsと以下が異なるらしい。
・useSelector は、default で、厳格な等価性チェック(===) を使う。
・useSelector は、オブジェクトだけではなく、値も返す。
shallowEqualじゃなくて===
を行うから、useSelectorがオブジェクトや配列を返す場合、メモリアドレスが異なると判定されて再レンダリングが起こるらしい。
よって、オブジェクトとかじゃない状態にすることでメモリアドレスではなく値自体を比べて再レンダリング不要だと判断してもらうために、以下みたいに書くとのこと。
const counter = useSelector(state => state.counter)
const belt = useSelector(state => state.items.belts[props.id])
const todo = useSelector(state => state.todos[props.id])
...
そして以下のような組み合わせはうちでもやってそう。
import React from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from 'reselect'
const selectNumOfDoneTodos = createSelector(
state => state.todos,
todos => todos.filter(todo => todo.isDone).length
)
export const DoneTodosCounter = () => {
const NumOfDoneTodos = useSelector(selectNumOfDoneTodos)
return <div>{NumOfDoneTodos}</div>
}
const NumOfDoneTodos = useSelector(selectNumOfDoneTodos)
のところでuseSelectorにselectNumOfDoneTodos
というcreateSelectorを渡している。
これはselectNumOfDoneTodosが厳密に前回と一緒だったら前回通りの値を返すことで再レンダリングを防ぐというもの。
そしてselectNumOfDoneTodos
の中では、stateのtodosというオブジェクトについて、前回と内容が変わってなかったらそのまま前回実行した値を返し、差分があったら第二引数の処理を行うと。
前回と内容が変わってなかったら多分メモリアドレスも変わらないから、useSelectorも再処理を行わずに前回通りのNumOfDoneTodosを返す感じかな。
ここから先の「おまけ」に書いてあった内容は、ちょっと疲れたのでペンド。
reduxの正規化について書いてくれてるので、設計的なの考えるときに読み返そう。
Discussion