フューチャー技術ブログ

フロントエンドでシステム開発を2年半続けてハマったことから得た教訓3つ

こんにちは。フューチャー 3 年目の柏木です。

はじめに

React、Next.js を触り始めて2年半ほど経ちました。
これまでによくつまずいたポイントから、自分なりのノウハウを言語化してみます。

想定する読者

  • React、Redux(、Next.js)を初めて触る人
  • システム開発の中で初めてフロントエンドを担当することになった人

開発で使用している技術要素

  • Node.js(10.5.0)
  • Express(4.16.3)
  • Next.js(5.1.0)
  • React.js(16.4.1)
  • Redux(3.7.2)

ノウハウたち

次の1~4について順番に説明していきます

  1. フロントで持つべきデータの形とデータベースに登録するデータの形は必ずしもイコールではない
  2. React の各ライフサイクルで適切な処理を行う
  3. 更新を React が正しく検知してくれるよう、値はコピーしてまるっと置き換える
  4. (おまけ)ライブラリやフレームワークを導入するときは CSS をどれだけカスタマイズできるか注意する

1. フロントで持つべきデータの形とデータベースに登録するデータの形は必ずしもイコールではない

  • データベースに登録する値は、業務要件にもよりますが、出来るだけ冗長な構造を避け、シンプルであるべきだとよく聞きます。
  • 一方、フロントエンドでは、データベースにある形のままデータを持つことが必ずしもベストではないこともあります。
  • 例えば、データベースでは下記のような配列のデータを持っていたとします。
data.json
{
"deta_1": [
{
"id": "xxx",
"param_1": "xxx",
"param_2": "xxx",
"param_3": "xxx"
},
{
"id": "yyy",
"param_1": "yyy",
"param_2": "yyy",
"param_3": "yyy"
},
{
"id": "zzz",
"param_1": "zzz",
"param_2": "zzz",
"param_3": "zzz"
}
]
}
  • フロントでもこのままの形を維持した場合、idが”yyy” であるオブジェクトにアクセスしたい場合、deta_1の配列で For 文を回して検索することになります。
  • これでは、deta_1のオブジェクトが 10000 ある場合や、検索したいidが”yyy”の他にいくつもある場合、描画処理のたびに大きな負担がかかってしまいます。
  • フロント側で面倒な処理を重ねることは、描画のタイムラグに直結、使うユーザーのストレスを増やしかねません。
  • そこでこのデータを、配列ではなくid をキーとしたオブジェクトで持つようにします。
  • 変換するタイミングは、データを取得してフロントに返ってきた直後です。
  • 例えば私のプロジェクトでは、API コールは画面初期表示時の場合getInitialProps(Next.js の機能)の中、イベント発火の場合actions(Redux の機能)内で行っています。下記例はactions内でデータ取得した時の想定です。
例)xxx/actions/testpage.js
const arrangeDataForFront = data_1 => {
return Object.assign({}, ...data_1.map(data => ({ [data.id]: data })));
};

export const searchTest = parameter => {
return async dispatch => {
//APIコールでデータを取得
const result = res.json();
const data_1ForFront = arrangeDataForFront(result.data_1);
console.log(data_1ForFront);
//{ xxx:
// { id: 'xxx', param_1: 'xxx', param_2: 'xxx', param_3: 'xxx' },
// yyy:
// { id: 'yyy', param_1: 'yyy', param_2: 'yyy', param_3: 'yyy' },
// zzz:
// { id: 'zzz', param_1: 'zzz', param_2: 'zzz', param_3: 'zzz' }
//}

//フロントではdata_1ForFrontでやりとりする
dispatch({
type: ***,
data_1: data_1ForFront,
});
};
};
  • これで、目的のデータにはdeta_1[yyy]で参照できるようになりました。
  • このようにアクセスしやすいデータの形を作ることは、描画の際の負担を減らし、無駄な処理によるバグを生みだしにくくすることに繋がります!

2. React の各ライフサイクルで適切な処理を行う

  • Reactには様々なライフサイクルのメソッドがあります。また、Next.jsも親コンポーネントで使えるデータ取得のメソッドが存在します(それぞれのメソッドの特徴については上記公式ドキュメントに詳細に記載されているので割愛します)。
  • これらのライフサイクルをそれぞれのコンポーネントで使い分け、必要な時に必要な処理が適切に行われることが、React での開発の鍵なのではないかと個人的に思っています。
  • ハマった失敗談の例
    • 画面をリロードした時は検索して描画するまで想定通りに動くが、画面上でボタンをクリックして検索するとエラーになってしまう。実はgetInitialPropsに実装した必要な処理は、初期描画の時しか呼ばれていなかった !
    • 子供コンポーネントでのイベント発火時に親コンポーネントのonChangeメソッドをコールパックしたら、子供コンポーネントの値が変わる度に親コンポーネントも再描画され、レンダリングに大変な時間がかかってしまった!
  • このように自分の予期せぬところで値が更新されてしまうと、不具合がおきた時の切り分けが難しくなってしまいます。
  • それぞれのメソッドで適切な処理をコードにまとめると以下のような感じです。
testpage.js
class TestPage extends Component {
constructor(props){
//画面初期表示のとき一度だけ呼ばれる
//(例)サーバー通信の不要な初期値の定義(stateなど)、メソッドのbind
}
static getInitialProps({ query }) {
//画面初期表示のとき一度だけ呼ばれる
//Next.jsの機能で、サーバーサイドで処理が行われる
//(例)APIをコールして初期値を取得する
}
componentDidMount() {
//画面初期表示のとき一度だけ呼ばれる
//クライアント側でしか行えない処理
//(例)タイムスタンプのデータをクライアントのタイムゾーンの日付に変換する
}
onChangeXXX(){
//イベント発火時に呼ばれる
//bindしていないメソッドだとレンダリングの度に呼ばれることがあるので注意
}
render() {
//画面をレンダリングする度に呼ばれる
//描画のためだけに使う変数の定義
//(例)const isXXX = this.props.xxx;
return (
//isXXXを用いたコンポーネントの描画
);
}
}
export default TestPage;
  • データの流れがわからなくなってしまったら、書こうとしている処理が、どういったタイミングで行われて欲しいかを一度図にして整理してみると、すっきりすると思います。

BFF(Backend For Frontend) とは

3. 更新を React が正しく検知してくれるよう、値はコピーしてまるっと置き換える

  • 配列で持っているデータの値を更新したはずなのに、描画してみたらうまく行かない、、、、、となったことはありませんか。
  • 実は、React では差分検知は「浅い比較」で行われます。
  • そのため、下記のデータの項目paramCの値を更新したい場合、
arrayA.json
[
"object1" : {
"paramC" : "xxx"
},
"object2" : {
"paramC": "yyy"
},
"object3" : {
"paramC": "zzz"
}
]
  • reducer を下記のように実装するとデータは更新されるが再レンダリングは行われない状態になります。
reducers/test.js
// actionで`arrayA`を作り直してreducerに渡してしまうと差分が検知されない
export const testReducer = ({ arrayA = [] } = {}, action) => {
switch (action.type) {
case "NG_ASSIGNMENT":
if (action.arrayA) {
arrayA = action.arrayA;
}
break;
}
return {
arrayA
};
};
  • そこで下記のようにスプレッド演算子でコピーするようにします。
reducers/test.js
// actionからは`objectB`を渡し、reducerで`arrayA`に含める処理を行う
export const testReducer = ({ arrayA = [] } = {}, action) => {
switch (action.type) {
case "OK_ASSIGNMENT":
if (action.objectB) {
arrayA = [...arrayA, ...action.objectB];
}
break;
}
return {
arrayA
};
};
  • こうすることで、arrayAの更新が画面でも検知され、再レンダリングが行われます。
  • 注意すべきなのは、スプレッド演算子は第一階層までしかコピーできないことです。
  • そのため下記のようなデータ構造でparamCを更新するためには、JSON.stringify()を用いて強制的に値の変更を検知させるか、arrayAでなくobject1で更新するなど、更新検知のオブジェクトの粒度を見直した方が良いでしょう。
arrayA.json
[
"object1" : {
"arrayB": [
{
"paramC": "xxx"
}
]
},
]

4. (おまけ)ライブラリやフレームワークを導入するときは CSS をどれだけカスタマイズできるか注意する

  • Javasript 関連のライブラリは多種多様であり、加えて「npm」というパッケージ管理ツールのおかげで、コマンドを打つだけでやりたいことをやってくれる外部ライブラリがサクッと入られます。
  • また、React であれば Material UI などのフレームワークも充実しており、見た目が統一しやすく簡単に実装できるので取り入れるメリットは大きいと思います。
  • ただ一点注意が必要なのは、ライブラリやフレームワークの内部で設定されている CSS のカスタマイズには工夫が必要であるということです。
    • 過去失敗談として、要件に適したライブラリを導入し、要望の画面イメージに合わせていざ見た目を整えようとしたところ、調整に1日ほど溶かし、結果的に!importantで内部の CSS をオーバーライドする羽目になったことがあります。。
  • 例えば前述の Material UI は公式ドキュメントにも記載があるようにユーザーがカスタマイズしやすいフレームワークですが、このように、見た目のためのプロパティ(=ライブラリ・フレームワークを使う人が調整可能な部分)がどれだけ準備されているかを最初に把握して、自分たちの作るデザインとどれだけすり合わせが必要かを知っておくといいと思います。

最後に

  • これまでにお伝えしたこと
フロントで持つべきデータの形とデータベースに登録するデータの形は必ずしもイコールではない
Reactの各ライフサイクルで適切な処理を行う
更新をReactが正しく検知してくれるよう、値はコピーしてまるっと置き換える
(おまけ)ライブラリやフレームワークを導入するときはCSSをどれだけカスタマイズできるか注意する
  • React に限らず、フロントエンドはデータのやり取り・描画・見た目が密接に関連しており、同時に考えることが多いですが、思った通りに動いた時は本当に楽しいです! 使う人に一番近く、いろんなフィードバックをいただけるのもフロント開発の醍醐味だと思います。
  • 少しでも開発の手が止まった時のヒントになれば幸いです。