Ateam Lifestyle Advent Calendar 2019の15日目は、株式会社エイチームライフスタイルでWebエンジニアをしている@turkeyzawaが担当します。
はじめに
昨年のアドベントカレンダーの時期からReactの布教も弊社内で進み、Reactに触れられる機会が増えてきました。嬉しいです。
今年はそんなReactで書かれたWebページのパフォーマンス改善、特にRedux周辺の改善について共有できればと思います。
当記事内のコードは全てSampleのため、そのままコピーしても動作しません。
また、実際のプロダクトに使われているコードからも大部分変更しています。
前提
Redux不要論を唱える意図はありませんが、「とりあえずReduxでState管理しよう」の精神で導入することは悪だと思っていて、本当に使う必要があるのか?の疑問は常に持ちながら使うべきだと思っています。
背景
Google Chromeでは早いのにIEで触ると遅い!そんな経験をお持ちの方は多いのではないでしょうか。
弊社でもつい最近、ReactとReduxで作られたとあるサービスのパフォーマンスが悪くなっていました。
ユーザが1文字入力するたびに画面がカクついてしまい、まともに入力ができない状態でしたので、取り急ぎ改善に取り組みました。
TL;DR
特に効果の大きかった改修ポイント
- Reduxのstoreに保持していたformの値をよりformへ近いstateへ移動
- 発行するactionの数の抑制
- reselectの導入と、propsとして流し込むstateの数の抑制
具体的に何をしたのかは下の方で記載しています。
beforeに比べてafterは負荷の高い処理(赤いライン)が少なくなっています。
事前準備
- eslint,prettierの導入
- ディレクトリ構造の整理
- re-ducksパターンの導入
上記はパフォーマンスに直接影響はしません。
ですが、この先書くコードの品質に良い影響を少なからず与えることができ、以下のようなことが期待できます。
- actionとthunkが散らばってしまい、ビジネスロジックが迷子になることを防ぐ
- 責務の分離を促進しやすくなる
もちろんディレクトリ構造を無視したコードは書けてしまうので、この事前準備が全ての負債を防げるわけではないです。
ルールが陳腐化しないように常に気を配る必要があります。
改修対象箇所の特定
読みやすく、詳細かつ丁寧に書かれている記事がありますので、是非こちらをご覧ください。
中でも特に、
Google Developer Tools の機能で,CPUの性能を4倍低速にします
こちらは常に有効にしています。
そうすることで、productionに出してからパフォーマンスの課題に気がつく、といったようなことも大幅に減らすことができます。
特に効果の大きかった改修ポイント
ここからは改修したポイントの中で、特にパフォーマンス改善に大きく影響した内容を解説していきます。
- Reduxのstoreに保持していたformの値をよりformへ近いstateへ移動
- ユーザのインタラクションに応じて動く処理の数の抑制
- selectorの導入と、propsとして流し込むstateの数の抑制
Reduxのstoreに保持していたformの値をよりformへ近いstateへ移動
StoreにFormの値を保持することは今ではアンチパターンです。
少し前はredux-formなどが使われていましたが、今から作成するアプリケーションではなるべく使わないようREADMEにも記載されています。改修しているサービスでもformの入力値が全てStoreに保持されていました。
const initialState = {
input: {
// formの入力値
name: '',
email: '',
prefecture: '',
// and more
},
// エラー
error: {},
errorList: {},
// 県や市などの固定値
prefectures: [],
cities: [],
towns: [],
streets: [],
cities: [],
// formの表示制御
showComponents: ['Form1'],
isForm1Visible: false,
isForm2Visible: false,
isForm3Visible: false,
isForm4Visible: false,
// ...others
suggestEmails: [],
isSubmitButtonDisabled: true,
scrollToBottomFlag: false,
scrollMoreFlag: true,
// and more
}
また、入力値が変更されるたびにform全体のvalidationをしなければならない仕様になっており、ユーザが1文字入力する毎に大量のactionが発行されてしまっていました。
class Form3 extends Component {
render() {
// それぞれconnectで流し込まれたstateとdispatcherです
const { store, suggestEmail } = this.props
return (
<>
<label for='email'>メールアドレス</label>
<input
type='email'
name='email'
placeholder='例)mail@example.com'
onChange={suggestEmail}
onBlur={suggestEmail}
value={store.input.email || ''}
/>
<EmailSuggest />
<Error error={store.error.email} />
</>
)
}
}
export const suggestEmail = el => {
return (dispatch, getState) => {
const suggestEmails = []
// 略
dispatch(actionTypes.setSuggestEmails(suggestEmails))
if (suggestEmails.length === 1 && el.type === 'blur') {
dispatch(
actionTypes.changeRequestInput({ [el.target.name]: suggestEmails[0] })
)
dispatch(actionTypes.setSuggestEmails([]))
} else {
dispatch(
actionTypes.changeRequestInput({ [el.target.name]: el.target.value })
)
}
// validate処理.内部でさらにactionを複数dispatchしている.
common.validate({
el: el,
dispatch: dispatch,
model: getState(),
validator: 'form3Validator',
additionalComponent: 'Form4',
})
common.validate({
el: el,
dispatch: dispatch,
model: getState().Request,
validator: 'form1Validator',
})
common.validate({
el: el,
dispatch: dispatch,
model: getState().Request,
validator: 'form2Validator',
})
common.validate({
el: el,
dispatch: dispatch,
model: getState().Request,
validator: 'form4Validator',
})
// dispatchされたthunkの中で更に別のthunkが呼ばれており、更に(ry
dispatch(focusElement({ el: el }))
// and more...
}
}
更に、reducerが呼び出されるたびにstateが新しいobjectとして生成されてしまっていたため、connectしているComponentが全てrerenderされていました。
import { handleActions } from 'redux-actions'
export default handleActions(
{
INITIALIZE_REQUEST: () => initialState,
CHANGE_STATE: (state, action) => ({
...state,
...action.payload,
}),
CHANGE_INPUT: (state, action) => ({
...state,
input: {
...state.input,
...action.payload,
},
}),
SET_ERROR: (state, action) => ({
...state,
error: action.payload,
}),
SET_ERROR_LIST: (state, action) => ({
...state,
errorList: action.payload,
}),
SET_COMPONENT: (state, action) => ({
...state,
showComponents: action.payload,
}),
ACTIVATE_SUBMIT_BUTTON: state => ({
...state,
isSubmitButtonDisabled: false,
}),
FINISH_CREATE_REQUEST: (state, action) => ({
...initialState,
...action.payload,
}),
SHOW_FORM1: state => ({
...state,
isForm1Visible: true,
}),
SHOW_FORM2: state => ({
...state,
isForm2Visible: true,
}),
SHOW_FORM3: state => ({
...state,
isForm3Visible: true,
}),
SHOW_FORM4: state => ({
...state,
isForm4Visible: true,
}),
// and more...
},
initialState
)
改修後はStoreにformの値を保持することをやめています。
また、validationや入力値の制御にはreact-hook-formを使用するように変更しました。
react-hook-formのapiの解説は主旨から外れてしまうので割愛します。
const Form3 = () => {
const prefectures = useSelector(selectors.selectPrefectures)
const cities = useSelector(selectors.selectCities)
const towns = useSelector(selectors.selectTowns)
const {
register,
errors,
setValue,
watch,
triggerValidation,
} = useFormContext()
const setEmail = useCallback(
email => {
setValue('email', email)
triggerValidation({ name: 'email' })
},
[setValue, triggerValidation]
)
const watchEmail = watch('email')
// registerされている入力項目全てをvalidateする関数
// triggerValidation() 相当の処理
const [validate700] = useDebouncedValidator(/* delay */ 700)
const handleOnChange = useCallback(() => validate700(), [
validate700,
])
// Componentを動的に表示するための処理を内部で行なっています
// valuesとerrorsから次のComponentを表示して良いかどうかを判定しており、こちらもdebounceでcancel可能にしています
useSubscriptionToNextStep({
nextComponent: 'Form4',
formValues,
errors,
delay: 500
})
return (
<>
<label for='email'>メールアドレス</label>
<input
type='email'
name='email'
placeholder='例)mail@example.com'
onChange={handleOnChange}
ref={register({
required: constants.ERROR_EMAIL,
pattern: {
value: MAIL_REG_EXP,
message: constants.ERROR_EMAIL_INVALID,
},
maxLength: {
value: 50,
message: constants.ERROR_MAXLENGTH_50,
},
})}
/>
<EmailSuggest email={watchEmail} setEmail={setEmail} />
<ErrorMessage errors={errors} keyName='email' />
</>
)
}
validationロジックもここに統一しています。triggerValidationで動的にvalidation処理を実行でき、かつ、マウントされているComponentの入力値でのみ処理が走るため、改修前よりも処理のコストを下げられます。
ユーザのインタラクションに応じて動く処理の数の抑制
入力の完了をトリガーに、動的に入力項目を表示させる必要があったため、改修前と同様にユーザの入力に応じてvalidationを走らせる必要がありました。
そのため、watchしたformの入力値をdependenciesに追加したuseEffectを使ってtriggerValidationをキックしていますが、debounceを活用してvalidationの実行回数を間引きました。
// registerされている入力項目全てをvalidateする関数
// triggerValidation() 相当の処理
const [validate700] = useDebouncedValidator(/* delay */ 700)
const handleOnChange = useCallback(() => validate700(), [
validate700,
])
どれぐらいdelayを挟むのかはそれぞれのformの特性に依存しますが、今回はユーザの行動分析に明るいメンバーに協力を仰ぎ、このサービスを利用しているユーザがよりストレスを感じづらい時間を設定しています。
結果、適切に実行回数を間引くことができ、インタラクションを阻害することなくvalidationも動かせるようになりました。
selectorの導入と、propsとして流し込むstateの数の抑制
ここまでの改修により、formの入力値はStoreから引きはがせましたが、Storeが抱えている全てのstateをReduxから剥がすことまではしていません。
connectされているComponentを確認すると、Componentが関心を持っているstateに限らず全てのstateがStoreから流し込まれていました。
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import form3 from 'components/form3'
import * as actionTypes from 'actions'
import * as requestActions from 'actions/form'
const mapStateToProps = ({ Store, Prefectures }) => ({
state: Store,
prefectures: Prefectures,
})
const mapDispatchToProps = dispatch => ({
...bindActionCreators(actionTypes, dispatch),
...bindActionCreators(requestActions, dispatch),
})
export default connect(
mapStateToProps,
mapDispatchToProps
)(form3)
このままでは、stateが更新されるたびに全てのComponentがrerenderされてしまいます。これは、嬉しくありませんよね。
そこで、必要のあるstateだけをStoreから引っ張ってくるようにするとともに、フィルタリングのコストも抑えるためにreselectを導入しました。
// Storeからformの情報を取ってくる必要がなくなったため処理も減っている
const prefectures = useSelector(selectors.selectPrefectures)
const cities = useSelector(selectors.selectCities)
const towns = useSelector(selectors.selectTowns)
不必要なrerenderを減らせたことに加え、Componentのレンダリングに必要なstateが更新がされていない場合は、フィルタリングコストも削減することができています。
まとめ
この画像にはサイズの関係上、一部のメトリクスしか載せていませんが、実際のformはもっと長いため、全体で見ると入力開始から終了までの時間も大幅に削減できています。
効果の計測はこれからですが、より多くのユーザ様に使っていただける状態にできたかなと思っています。
やった方が良いけどできなかったこと
まだまだ改修することによってパフォーマンスを改善できるポイントはいくつも存在しますが、以下の理由により今回は手を入れていません。
- かけられる工数が限られている
- 達成したかった水準は満たせた
興味のある方は、Redux Style Guideをぜひご覧ください。
終わりに
これからReduxを含めたアプリケーション開発を始める場合は、本当にReduxの導入が必要なのか?ローカルなstateで十分ではないか?ContextAPIで十分ではないか?などを検討した上で進めることをオススメします。
Reduxを導入することが決まった場合は、ぜひRedux Style Guideを参考に実装を進めてみてください。
Ateam Lifestyle Advent Calendar 2019 の16日目は、 @dabitsがk8sやECSなどの、コンテナサービスの選定方法について書きます。ご期待ください!