経緯
プロジェクトメンバーに対してReactのパフォーマンスについて教えなくてはいけなくなり自分なりに学習し、まとめたものをせっかくなので投稿させて頂きます。突貫で学習したため謝っている事やこうすれば更に効率よくチューニングが出来るという意見があればコメントしていただければと思います。
- useMemo
- useCallback
- React.Memo
Reactの仮想DOMについて
Reactは仮想DOMと呼ばれる実際のwebページに描画するためのDOMだけでなく、メモリにも仮想DOMと呼ばれるデータを保管している。利用者が入力欄やボタン操作を行なった際に、必要な箇所だけレンダリングして簡単にSPAを実現することができる。
留意点
上記の、仮想DOMを取り扱うにあたり留意点がある。それは開発者が何も考慮せずにコーディングを進めたアプリは最終的に動作が遅くなるという事。
その要因としていくつかのComponent(ボタンなどの部品)を各画面に使用する際、親コンポーネントは子コンポーネントに対してprops(情報)を渡してその時々に応じた画面を表示することになる。大規模なアプリケーションになるにつれ部品の数は多くなり、上階層のコンポーネントでレンダリングに伴う動作が行われた際はすべての画面でレンダリングが走り、結果として表示速度が遅くなってしまう現象が発生する。
レンダリングのタイミング
Reactではいつ、どのタイミングでレンダリングが発生するのかというと以下の三つです。
- stateが更新された時
- propsが更新された時
- 親コンポーネントが再レンダリングされた時
上記のタイミングを意識しつつ、本当にレンダリングが発生してほしいコンポーネントが更新されるように開発者が制御してあげる必要があります。
stateが更新された時
同じコンポーネントで定義されたstateが更新された場合は必ず画面のレンダリングは走ることになる。ただ、useEffectやuseCallback,useMemoなどでラップした場合、特定のstateのみ処理を実行するといった制御が可能なため特定のstateしか使用しない場合は使用した方がいい(useCallbackとuseMemoは別)
import { useEffect, useState } from 'react';
export default function State() {
const [count, setCount] = useState('');
const [text, setText] = useState('');
//レンダリングされたときに必ず実行される
useEffect(() => {
console.log('レンダリングされました');
});
//countが更新されたときに実行される
useEffect(() => {
console.log('countが変更されました');
}, [count]);
//textが更新されたときに実行される
useEffect(() => {
console.log('textが変更されました');
}, [text]);Í
const changeText = (e) => {
setText(e.target.value);
};
const onCountUp = () => {
setCount(count + 1);
};
return (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<input type="text" onChange={changeText} />
<button onClick={onCountUp}>インクリメント</button>
<div>{count}</div>
</div>
);
}
countを変更した場合に出力されるログは
'レンダリングされました' 'countが変更されました'
textが更新された場合に出力されるログは
'レンダリングされました' 'textが変更されました'
propsが更新された時
親となるコンポーネントが子コンポーネントに譲渡しているporpsが更新された時にレンダリングが実行される。以下のサンプルコードの場合、親コンポーネントであるCountPage内でcountまたはstrステートが変更・更新される度に子コンポーネントがレンダリングされ以下のログが出力される。
CountPage.jsx
import React, { useState, useEffect } from 'react';
import { SumCountView } from './SumCountView';
import { DisplayString } from './DisplayString';
function CountPage() {
const [count, setCount] = useState(0);
const [str, setStr] = useState('');
useEffect(() => {
console.log('トップ階層がレンダリング');
});
//countをインクリメントする
const onCountUp = () => {
setCount(count + 1);
};
//strの更新を行う
const onStrChange = (str) => {
setStr(str);
};
const log = (str) => {
console.log(str);
};
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<button onClick={onCountUp}>インクリメント</button>
<input
value={str}
onChange={(e) => {
onStrChange(e.target.value);
}}
></input>
<SumCountView count={count} log={log} />
</div>
<DisplayString str={str} log={log}/>
</>
);
}
export default CountPage;
SumCountView.jsx
import React, { useCallback, useEffect } from 'react';
export const SumCountView = (props) => {
const { count ,log} = props;
useEffect(() => {
log('SumCountViewがレンダリングされています。')
});
return <div style={{ fontSize: '2wv' }}>{count}</div>;
};
DisplayString.jsx
import React, { useEffect } from 'react';
export const DisplayString = (props) => {
const { str, log } = props;
useEffect(() => {
log('DisplayStringがレンダリングされています。')
})
return <h1 style={{ textAline: 'center' }}>{str}</h1>;
};
すべてのコンポーネントが出力されている
上記の場合、countステートのみ更新しているのでレンダリングされるのは親コンポーネントであるCountPage.jsxと子コンポーネントであるSumCountView.jsxのみ更新されてほしい。
上記の問題を解消するためにMemoとuseCallbackを用いる。
Memoについて
簡単に言えば計算結果を保持し、それを再利用する手法のこと。
メモ化によって各コンポーネントで都度計算する必要がなくなるため、パフォーマンスの向上が期待できる。
react.Memoを用いてDisplayString.jsxをメモ化する。
以下のようにコンポーネントをReact.memo()でラップする
import React, { useEffect } from 'react';
export const DisplayString = React.memo((props) => {
const { str} = props;
useEffect(() => {
console.log('DisplayStringがレンダリングされています。')
})
return <h1 style={{ textAlign: 'center' }}>{str}</h1>;
});
結果として以下のようにレンダリングが制御され、「DisplayStringがレンダリングされています。」のログが消えていることがわかる。
useCallbackについ
propsとして譲渡できるものは多岐にわたる。ステートのみならず関数なども渡すことができる。
関数をpropsとして渡す場合の注意点として以下の点がある。
関数は毎回新しく生成された情報渡されるため子コンポーネントをmemo化していても新しいpropsとして認定されてしまう
上記の問題を解決するためuseCallbackを使用する。このhooksはラップされた関数をメモ化することができる。これにより、指定されたステートが更新されたタイミングのみ情報を更新するようになるのでpropsとして子コンポーネントに譲渡しても不要なレンダリングを発生させないように制御できるようになる。
const log = (str) => {
console.log(str);
};
↑これが以下のようになる
**const log = useCallback((str) => {
console.log(str);
},[str]);**
useMemoについて
最後にuseMemoについてですが、これまでのReact.MemoやuseCallbackは親コンポーネントから子コンポーネントに関係し、譲渡されるpropsをレンダリングタイミングで比較して該当コンポーネントのレンダリングが必要かどうかを判定していました。
useMemoは下層の子コンポーネント内で定義された関数などもメモ化できるようにするというものです。例えば、渡されるporpsやそのコンポーネント内で定義されているステートを用いて重たい処理を行う場合、本来であればレンダリングされる度にその重たい処理を実行しなければいけません。このuseMemoを用いれば初回でこそ処理を実行しますが、それ以降は指定された値が変更されていなければ初回に実行された値を使い回すことで不要な処理を回避することができます。
以下サンプルコードになります。
「表示」ボタンを押下される度に関数calcが実行され1000回ループを回す比較的重たい処理が実行されます。
import React, { useState, useEffect, useMemo, useCallback } from 'react';
export const SumCountView = React.memo((props) => {
const { count } = props;
const [flg,setFlg] = useState(false)
useEffect(() => {
console.log('SumCountViewがレンダリングされています。');
});
//レンダリングされる度に以下のcalcが再計算される
let calc = () => {
for (let i = 0; i < 1000; i++) {
console.log('useMemoを使用した重たい処理');
};
return count * 2;
};
let num = calc();
return (
<>
{flg ? <div style={{ fontSize: '2wv' }}>{num}</div> : ''}
<button onClick={() => { setFlg(!flg)}}>表示</button>
</>
);
});
以下のようにuseMemoでラップすることでcountが更新されない限り同じ変数numの値を使用するため、結果的に重たいループ処理が実行されない。
let calc = useMemo(() => {
for (let i = 0; i < 1000; i++) {
console.log('useMemoを使用した重たい処理');
};
return count * 2;
},[count]);
まとめ
Reactのパフォーマンス改善を行うにあたり今回使用したReact.Memo,useCallback,useMemoの理解が重要になってくる。
- 親コンポーネントから子コンポーネントにpropsを渡すときは子コンポーネントをReact.memoでラップすること
- 関数をpropsで渡すときはその関数をuseCallbackでラップすること
- レンダリング毎に重たい処理を実行したくないときはその値を求める処理をuseMemoでラップすること
以上3点が基本的な対策だと考えます。もちろん、アンチケースとして使用すべきでないパターンもありますが一旦上記のルールを徹底することである程度の効果が期待できると考えています。
最終的なサンプルコード
CountPage.jsx
import React, { useState, useEffect, useCallback } from 'react';
import { SumCountView } from './SumCountView';
import { DisplayString } from './DisplayString';
function CountPage() {
const [count, setCount] = useState(0);
const [str, setStr] = useState('');
useEffect(() => {
console.log('トップ階層がレンダリング');
});
//countをインクリメントする
const onCountUp = () => {
setCount(count + 1);
};
//strの更新を行う
const onStrChange = (str) => {
setStr(str);
};
const log = useCallback(
(str) => {
console.log(str);
},
[str],
);
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<button onClick={onCountUp}>インクリメント</button>
<input
value={str}
onChange={(e) => {
onStrChange(e.target.value);
}}
></input>
<SumCountView count={count} />
</div>
<DisplayString str={str} log={log} />
</>
);
}
export default CountPage;
DisplayString.jsx
import React, { useEffect } from 'react';
export const DisplayString = React.memo((props) => {
const { str, log } = props;
useEffect(() => {
log('DisplayStringがレンダリングされています。');
});
return <h1 style={{ textAlign: 'center' }}>{str}</h1>;
});
SumCountView.jsx
import React, { useState, useEffect, useMemo, useCallback } from 'react';
export const SumCountView = React.memo((props) => {
const { count } = props;
const [flg,setFlg] = useState(false)
useEffect(() => {
console.log('SumCountViewがレンダリングされています。');
});
//レンダリングされる度に以下のcalcが再計算される
let calc = useMemo(() => {
for (let i = 0; i < 1000; i++) {
console.log('useMemoを使用した重たい処理');
};
return count * 2;
},[count]);
let num = calc;
return (
<>
{flg ? <div style={{ fontSize: '2wv' }}>{num}</div> : ''}
<button onClick={() => { setFlg(!flg)}}>表示</button>
</>
);
});