🤖

JavaScriptのObjectの扱いでやらかした話

2023/04/28に公開

やあ、愚か者のぼくが通るよ。

TL;DR

  • 環境
    • React 18.x
    • Node.js 18.x
  • React で書いたコードの TOP ページがサーバーに負荷をかけている
  • リクエスト回数を減らせないか
  • おや? useQuery で同じリクエストが重複して発生するぞ?
  • 調査した結果、Object の扱いを軽んじていた
  • 似たようなことを起こさないために記事にするよ
  • 上から目線みたいに見えるかもしれないがそんな意図はないのでそういうキャラクターだと思ってくれ

経緯

もう既に書いたけど改めて状況を説明しよう。

  • React で TOP ページを表示
  • ページ遷移して別のページを表示
  • その後、TOP ページを再度表示した際にリクエストがキャッシュされず再びリクエストが発生

ことの発端は TOP ページがサーバーサイドの CPU 負荷を上げているぞという話から、
二回目以降リクエストしなければ多少マシなんじゃないか、となり
おいおい、そもそもなぜ二回目以降に同じリクエストが発生してるんだ?
ってなって調査したんだ。

もちろん、Link コンポーネントを介さず a タグで遷移していたとかそういうことじゃない。
ドキュメント自体が reload とかされたわけでもなく SPA の実装の想定上、キャッシュから値が返りサーバーにリクエストは行かない想定だったんだがそうはなっていないことに気がついた。

原因

まぁ、結論から言ってしまうと useQuery で指定した queryKey に問題があった。
感のいい諸君のことだ。既にお察しのことかと思うがここに

useQuery(
  [
    'クエリキー',
    {
      sort: 'asc',
      offset: 0,
      limit: 10
    }
  ],
  fetchList // promiseを返すFunction
);

みたいな指定をしていたことに問題があった。
ぼくみたいなぼんくらからしてみたら一見なんの問題もないように見えたが調査していてようやく原因に思い至った。

そう。JavaScript の Object は同値を設定しても毎回同じ Object ではない という初歩的なミスだ。

既におわかりだろうが要するに

Object.is({}, {});
// falseが返る;

ということだ。
あろうことかこの仕様をすっかり忘却していたぼくは useQuery の QueryFunction のパラメータにオブジェクトを指定してしまっていた。

要するにこんな感じである
(実際は typescript を使っていたのでパラメータの型指定や認証トークンの指定のためのラッパーなど書いてあるのだけどそこはどうでもいいので今は省略する)

const fetchList = (key) => {
  const [_key, params] = key;

  return axios.get('/path', { params });
};

横着ここに極まれり

つまり useQuery に指定した Object は一見同じに見えても毎回内部では違うパラメータが渡されたと認識して毎回キャッシュせずに律儀にクエリを投げ続けていたのである。

そこまで思い至ってぼくは怖気が走った。

待てよ?これもしかしたら hook の副作用全般で同じことが起こるのではないか?と
奴らは内部的には Object.is のようなことをして処理をスキップするなりキャッシュを返すなりする。

と、いうことはだ。

before

const hash = {};

useEffect(() => {
  // 何らかの処理
}, [hash]);

これ毎回、たとえ hash の中身が人間から見て同じだったとしても何度も、そこれこそレンダリング毎に実行されてしまうんじゃなかろうか。

よし、阿呆みたいなコードを全部チェックしてなるべく必要最小限の副作用にせねば。
具体的には

afeter

const hash = {};

// どこかのタイミングでhash.valueが入るとする

useEffect(() => {
  // 何らかの処理
}, [hash?.value]);

のようにイミュータブルな値が入った変数を指定する。
あくまで例だからそもそも展開しろとか const で定義した Object の中身をいじるなとか苦情は受け付けないよ。
全てのケースを確かめたわけではないので話半分程度に思ってくれていい。
真相は自分で確かめてくれ。

余談

見出しの通りここからは完全に余談だ。
くだらないと思ったら結論に飛んでくれたまえ。

?.ってなんじゃ?と思った JavaScript を嗜んでいない人のために説明しておくと、これはオプショナルチェーンと言って
要するに key が存在しなくてもundefinedが返りその時点で後続の処理をスキップする JavaScript 特有の構文だ。Ruby にも似たようなのがあるだろう。あれだよあれ。

実は配列にも使える。 a?.[0].trim()みたいに書くだけだ。あ?何だコラ?ってキレているわけではない。
String.prototype.trim()は String オブジェクトに生えてるメソッドなのでa[0]undefined だった場合未定義エラーとなる。しかしオプショナルチェーンによってa[0]の結果がある場合だけ trim()が実行される。

なお、これでa[0]から String 以外が返ってきたらやはり普通に未定義エラーとなるがそんなのはぼくの知ったこっちゃあないよ。
typescript とか使って厳密な型定義をしろとしか言えないね。

昔はそんなものなかったがモダンな環境ならオプショナルチェーンはまず対応しているだろう。

別に参照だけならオプショナルチェーンを使わずともエラーまでは出ないが書き換えようとしたキーが存在しない場合に限ってエラーになるという JavaScript 特有の不思議な仕様だ。

この例では単に Eslint に怒られるからオプショナルチェーンで指定しているだけだ。
彼らの逆鱗に触れないようにしなければ。おお怖い。

閑話休題.

いやね?最初はトップページのコンポーネントが画面遷移の度に unMount されて再度 Mount された時にクエリが走ってるだけかなと高を括っていたわけだ。

で、各画面をチェックしていてとある画面では再レンダリングの際にリクエストがちゃんとキャッシュされてて再びリクエストが飛ぶことはなかった。

おや?そうなると話は変わってくるぞ?ということで

useEffect(() => {
  console.log('Mount');
  return () => {
    console.log('unMount');
  };
}, []);

みたいなコードを仕込んで確かめてみたわけだ。
するとどうだ。画面遷移のたびに Mount,unMount が走っている。問題ない。
React18 では Strict モードでコンポーネントの Mount は unMount したあとに再び行われる、ということも踏まえた上で。

はて、ではなぜ TOP ページではリクエストが再び飛ぶのか、と。
で、先のような結論に至った。

また、わざわざ言うまでもないかもしれないが下記も同様だからなるべくスプレッド構文とか使ってイミュータブルな値を取り出してから副作用に指定したほうがいいかもしれない。

Object.is([], []);
// falseが返る

おっと、JSON.stringify を使えばいいじゃないかとか野暮な事は言ってくれるなよ?
あれはあれで中身の順番が担保されてないと同じにならないという問題があるからな。

あとこれもまた言うまでもないと思うが、
JSON.stringifyは値が null の時と undefinedの時で挙動が異なるからな?
具体的には値が undefinedだった時、指定した key は無視される。
意味がわからない、という人がいたら実際に試してみるといい。
開発者ツール開いて打ち込んでみるなり console.logで出力するなりすればいいだろう。

JSON.stringify({ a: null, b: undefined });

どうかな?b が消滅しただろう?

そしてくどいようだが下記のような差異があるから使うときは注意したほうがいい。

null == undefined;
// => true

null === undefined;
// => false

これらは似て非なるものだから実際にどっちが入っているかわからない時は下記のようにするといい。

let empty = null;

const defaultValue = empty ?? ''; // Null 合体演算子というらしい
empty ??= ''; // これも行ける

そして間違っても論理和だけは使ってくれるなよ?

// これらは全部同じ結果''となる
null || '';
undefined || '';
0 || '';
false || '';
// これらはnullとundefinedだけ''となる
null ?? '';
undefined ?? '';
0 ?? '';
false ?? '';

結論

まぁ横道にそれまくったがひとえにぼくが浅慮なだけだった。
Object の比較は一見中身が同じでもイコールとなるわけではない。
油断していると足元をすくわれる。

と、いうかだよ。ぶっちゃけ Eslint の react プラグインが hook の副作用にプリミティブでない値を指定したら警告とか出してくれれば
いや、ぼくが悪いな。うん。責任転嫁は良くない。

あぁ疲れた。不甲斐ない自分にカッとなって記事をしたためたがなんか余計なことまで語ったような気がする。

なるべく印象に残るように少々茶目っ気を発揮しているが気にしないでくれ。
なにかの参考になれば幸いだよ。

Discussion