POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

NADIA MAKAREVICH

本記事は、原著者の許諾のもとに翻訳・掲載しております。

この記事では、DOMの測定結果に基づいて要素を変更する方法、useEffectの問題点とuseLayoutEffectによる解決法、ブラウザペインティングとは何か、SSRの役割について説明します。 この記事と同じ内容を扱ったYouTube動画も公開していますので、活字媒体よりも動画視聴を好まれる方はそちらをご覧ください。文字ではなく、アニメーションと音声で同じ概念を解説しています。 この記事は動画形式でも公開しています。

目次

ReactにおけるDOMアクセスについてもう少しお話ししましょう。"前回の記事 ReactにおけるRef:DOMへのアクセスから命令型APIまで"では、Refを使用してDOMにアクセスする方法について説明し、Refに関することは全て網羅しました。しかし、DOMの使用方法については、あまり語られることはないものの非常に重要なトピックがもう1つあります。それは、要素のサイズや位置といったDOMの実際の測定結果に基づいた要素の変更です。 では、具体的な問題点は何であり、「通常」の方法では不十分な理由は何でしょうか。実際に少しコーディングを行いながら解明していきましょう。このプロセスを通じて、useLayoutEffectについて知るべきあらゆること、useEffectではなくuseLayoutEffectを使用すべきときとその理由、Reactコードがブラウザによってどのようにレンダリングされるのか、ペインティングとは何か、これらがなぜ重要で、SSRがどのような役割を果たすのかが分かると思います。

useEffectの問題点とは?

では実際にコーディングしてみましょう。今日は少し凝ったことがしたいので、レスポンシブ対応のナビゲーションコンポーネントを作ってみます。このコンポーネントは、1列に並んだリンクをレンダリングし、コンテナのサイズに応じてリンクの数を調整することができます。

コンテナに収まりきらないリンクがある場合は「もっと見る」ボタンを表示し、これをクリックするとドロップダウンメニューに残りのリンクが表示されるようにします。

次はコンポーネント本体です。作るのは、データ配列を受け取り、適切なリンクをレンダリングするコンポーネントのみです。

const Component = ({ items }) => {
  return (
    <div className="navigation">
      {items.map(item => <a href={item.href}>{item.name}</a>)}
    </div>
  )
}

では、これをレスポンシブ化するにはどうすればよいでしょうか。問題は、与えられたスペースに入るアイテムの数を計算しなくてはならないことです。そのためには、レンダリングが行われるコンテナの幅と、各アイテムのサイズが分からなくてはなりません。文字数を数えるなどして、事前に仮定することはできません。ブラウザ上でのテキストのレンダリングは、使用するフォントや言語、ブラウザによって異なります。ひょっとすると、月の満ち欠けの影響も受けるかもしれません。 実際のサイズを取得する唯一の方法は、ブラウザにそれらのアイテムをレンダリングさせた上で、getBoundingClientRectというJavaScriptのネイティブAPI経由でサイズを抽出するやり方です。 これを行うにはいくつかの手順を踏む必要があります。まず、要素にアクセスします。Refを作成し、対象となるアイテムをラップするdivに代入することができます。

const Component = ({ items }) => {
  const ref = useRef(null);


  return (
    <div className="navigation" ref={ref}>
      ...
    </div>
  )
}

Refの使い方や、Refを使ったDOMの操作に自信がない方は、"ReactにおけるRef:DOMへのアクセスから命令型APIまで"をお読みください。 次に、useEffectでdiv要素を取得し、そのサイズを確認します。

const Component = ({ items }) => {


  useEffect(() => {
    const div = ref.current;
    const { width } = div.getBoundingClientRect();
  }, [ref]);


  return ...
}

それからdivの子要素を反復処理し、幅を配列の中に抽出します。

const Component = ({ items }) => {


  useEffect(() => {
    // same code as before


    // convert div's children into an array
    const children = [...div.childNodes];
    // all the widths
    const childrenWidths = children.map(child => child.getBoundingClientRect().width)
  }, [ref]);


  return ...
}

後はこの配列を反復処理し、子要素の幅を合計し、その合計を親要素のdivと比較すれば、最終的に表示されるアイテムが分かります。 ただ、1つ忘れているものがあります。「もっと見る」ボタンです。このボタンの幅も考慮に入れなくてはいけません。そうしなければ、いくつかのアイテムは表示されるものの、「もっと見る」ボタンが入りきらないという状況になりかねません。

「もっと見る」ボタンの場合も同様に、ブラウザ上でレンダリングしなければ幅は分かりません。したがって、最初のレンダリング時に明示的にボタンを追加する必要があります。

const Component = ({ items }) => {
  return (
    <div className="navigation">
      {items.map(item => <a href={item.href}>{item.name}</a>)}
      <!-- add the "more" button after the links explicitly -->
      <button id="more">...</button>
    </div>
  )
}

抽象化によって幅計算のロジックを全て関数にまとめるならば、useEffectの中は次のようになります。

useEffect(() => {
  const itemIndex = getLastVisibleItem(ref.current)
}, [ref]);

ここでは、getLastVisibleItem関数が、全ての計算を行い、1つの数字を返してくれます。この数字が、与えられたスペースに入る最後のリンクのインデックスです。今回はロジック自体については詳しく説明しません。やり方は無数にあり、後で紹介する最終コードの中で例をお見せします。 ここで重要なのは、この数字を得たことです。Reactの観点から次に行うべきことは何でしょうか。このままだと、全てのリンクと「もっと見る」ボタンが表示されてしまいます。解決策は1つしかありません。コンポーネントの更新をトリガーし、表示されるべきでないアイテムを取り除く必要があります。 これがほぼ唯一の方法です。数字は取得したときのステートで保存する必要があります。

const Component = ({ items }) => {
    // set the initial value to -1, to indicate that we haven't run the calculations yet
    const [lastVisibleMenuItem, setLastVisibleMenuItem] = useState(-1);


    useEffect(() => {
      const itemIndex = getLastVisibleItem(ref.current);
      // update state with the actual number
      setLastVisibleMenuItem(itemIndex);
    }, [ref]);
 }

そして、メニューをレンダリングする際に、それを考慮に入れます。

const Component = ({ items }) => {


  // render everything if it's the first pass and the value is still the default
  if (lastVisibleMenuItem === -1) {
    // render all of them here, same as before
    return ...
  }


  // show "more" button if the last visible item is not the last one in the array
  const isMoreVisible = lastVisibleMenuItem < items.length - 1;


  // filter out those items which index is more than the last visible
  const filteredItems = items.filter((item, index) => index <= lastVisibleMenuItem);


  return (
    <div className="navigation">
      <!-- render only visible items -->
      {filteredItems.map(item => <a href={item.href}>{item.name}</a>)}
      <!-- render "more" conditionally -->
      {isMoreVisible && <button id="more">...</button>}
    </div>
  )
}

これだけです。実際の数字でステートを更新すると、ナビゲーションのリレンダリングがトリガーされ、Reactがアイテムを再びレンダリングし、表示されないものを取り除きます。「適切」なレスポンシブ体験を作り出すためには、リサイズイベントを拾い、数字を再計算する必要もありますが、その実装については読者に委ねたいと思います。 完全に機能する例を下のCodeSandboxで公開しているのでご覧ください。リサイズにも対応しています😊。でも喜ぶのはまだ早いです。ユーザーエクスペリエンスに重大な欠陥が1つあります。

何度かリフレッシュしてみてください。特に、CPUがスローダウンしている状態で試してみてください。残念なことに、コンテンツのチラつきがかなりひどいことが分かると思います。メニューの全てのアイテムと「もっと見る」ボタンが表示される最初のレンダリング結果は、はっきりと見えなくてはいけません。本番公開までに確実に修正する必要があります。

useLayoutEffectでチラつきを解決する

画面のチラつきの原因は明らかです。不要なアイテムを除外する前にアイテムをレンダリングして表示させてしまうので、チラつきが発生します。また、レスポンシブデザインが機能するためには最初にレンダリングを行う必要があります。したがって、この問題を解決する方法としては、最初のレンダリングはそのまま実行しますが、opacityを0に設定して行うか、表示領域外のdivの中で見えないように行う方法が考えられます。そして、サイズとマジックナンバーを抽出してから表示させるのです。以前は、このようなケースにはこの方法で対応していました。 しかし、フックが導入されたReact 16.8以降は、useEffectフックをuseLayoutEffectに置き換えるだけで解決できます。

const Component = ({ items }) => {
    // everything is exactly the same, only the hook name is different
    useLayoutEffect(() => {
      // the code is still the same
    }, [ref]);
 }

これだけで、まるで魔法のように最初のチラつきがなくなります。実際にご覧ください。

しかし、これは安全な方法なのでしょうか。もしそうであるならば、useEffectを全てこれに置き換えればよいのではないでしょうか。ドキュメントには、useLayoutEffectパフォーマンスに悪影響を及ぼす可能性があるため、使用を避けるべきであるとはっきりと記載されています。なぜでしょうか。ドキュメントには、useLayoutEffectは「ブラウザが画面をリペイントする前」に発火するとも書かれています。これはつまり、useEffectは画面がリペイントされた後で発火することを暗に示しています。しかし、これは実用面では具体的にどのような意味を持つのでしょうか。単純なドロップダウンを記述する際にブラウザペインティングのような低レイヤーの概念について考える必要があるのでしょうか🤯。 これらの問いに答えるためには、少しReactから離れてブラウザとJavaScriptについて話す必要があります。

解決策が有効な理由:レンダリング、ペインティング、ブラウザ

ここでまず必要なのは、「ブラウザレンダリング」です。Reactの世界では、Reactにおけるレンダリングと区別するために「ペインティング」とも呼ばれています。Reactにおけるレンダリングとペインティングは全くの別物です。考え方としては比較的単純です。ブラウザは、画面上にリアルタイムで表示する必要があるものを全て継続的に更新するわけではありません。例えば、ホワイトボードに線を描き、線を消し、文字を書き、あるいはフクロウのスケッチを描いたりするのとは違います。 それよりも、スライドを切り替えながら表示するのに似ています。あるスライドを表示して、見ている人がその内容を理解したら次のスライドに移るという要領です。 非常に遅いブラウザにフクロウの描き方を聞いたならば、おそらく以下の絵のようなひどい指示が返ってくるでしょう。

ただし、ブラウザが描くスピードは非常に速いです。通常、モダンなブラウザは60fps(毎秒60フレーム)のフレームレートを維持しようとします。約13ミリ秒ごとにスライドが切り替わるイメージです。Reactで「ペインティング」と呼ぶのはこの処理のことです。 スライドを更新する情報は複数の「タスク」に分割されます。タスクはキューに入れられます。ブラウザはキューからタスクを取得して実行します。時間に余裕があれば、約13ミリ秒の中で時間がなくなるまで次のタスクを実行していき、最後に画面をリフレッシュします。これを休むことなく続けます。私たちがこうした処理を意識せずにTwitter(X)でドゥームスクローリングを行ったりできるのはそのおかげです。 「タスク」とは何でしょうか。通常のJavaScriptの場合、scriptタグに入れて同時に実行するもの全てをタスクと呼びます。次のコードをご覧ください。

const app = document.getElementById("app");
const child = document.createElement("div");
child.innerHTML = "<h1>Heyo!</h1>";
app.appendChild(child);


child.style = "border: 10px solid red";
child.style = "border: 20px solid green";
child.style = "border: 30px solid black";

IDを使用して要素を取得し、app変数に入れ、divを作成します。次に、HTMLを更新してdivをappに付加し、divのborderを3回変更します。ブラウザにとっては、この一連の処理が全て1つのタスクとして認識されます。したがって、全ての行を実行してから、最終結果である黒いborderのdivを描画します。 画面上では、赤→緑→黒の遷移は見えません。 1つの「タスク」に13ミリ秒以上かかる場合はどうなるでしょうか。まあ、残念だと言うしかありません 🤷🏻‍♀️。ブラウザは、タスクを停止することも分割することもできません。完了するまで処理を続けてから、最終結果をペイントします。borderの更新間に1秒間のブロッキング(原文では synchronous delays)を追加した場合、次のようになります。

const waitSync = (ms) => {
  let start = Date.now(),
    now = start;
  while (now - start < ms) {
    now = Date.now();
  }
};

child.style = "border: 10px solid red";
waitSync(1000);
child.style = "border: 20px solid green";
waitSync(1000);
child.style = "border: 30px solid black";
waitSync(1000);

その場合でも、「中間」の結果は見えません。ブラウザが処理を行う間は何もない画面が表示されるだけで、最後に黒いborderが表示されます。これを「ブロッキングレンダー」あるいは「ブロッキングペインティング」コードと呼びます。 CodesSandboxで例をご覧ください。 Reactは単なるJavaScriptですが、もちろん単一のタスクとして実行されるわけではありません。もしそうであるならば、インターネットは耐えがたいものになるでしょう。そうなれば私たちは皆外に出て遊び、直接人と関わることを余儀なくされるでしょう。そんなことを誰が望むでしょうか。アプリ全体をレンダリングするような巨大なタスクを小さなタスクに「分割」するには、コールバック、イベントハンドラー、プロミスなどさまざまな「非同期」メソッドを使用する必要があります。 これらのスタイル調整を、遅延0でsetTimeoutにラップしたとします。

setTimeout(() => {
  child.style = "border: 10px solid red";
  wait(1000);
  setTimeout(() => {
    child.style = "border: 20px solid green";
    wait(1000);
    setTimeout(() => {
      child.style = "border: 30px solid black";
      wait(1000);
    }, 0);
  }, 0);
}, 0);

その場合、全てのタイムアウトが新しい「タスク」と見なされます。したがって、ブラウザは1つのタスクを終え、次のタスクを開始する前に、画面をリペイントできます。そして、何もない白い画面を3秒間ぼーっと眺める代わりに、赤から緑へ、そして黒へと、ゆっくりとではあるものの美しく移り変わる様子を見ることができます。 コード付きのCodeSandboxはこちらをご覧ください。 Reactはこのようなことを行ってくれます。一言で言えば、Reactはコーディングによって作られた何百ものnpmの依存関係で構成される巨大な塊を、ブラウザが(理想的には)13ミリ秒以内で処理できる最小限の塊に分割してくれる、極めて複雑で効率的なエンジンです。 これは非常にかいつまんだ短い紹介です。きちんと説明しようと思えば1冊の本になってしまいます。"Browser Event loop: micro and macro tasks, call stack, render queue: layout, paint, composite"は、ブラウザのイベントループとクエリに関する極めて秀逸な総合ガイドです。

useEffectとuseLayoutEffectの比較に戻る

では、useEffectuseLayoutEffectの比較に戻り、最初に挙げたいくつかの問いについて検討したいと思います。 useLayoutEffectは、コンポーネントの更新時にReactが同時に実行するものです。次のコードをご覧ください。

const Component = () => {
  useLayoutEffect(() => {
    // do something
  })


  return ...
}

コンポーネントの中で何かをレンダリングすると、必ずuseLayoutEffectが同じ「タスク」として実行されます。Reactがそれを保証します。たとえuseLayoutEffectの中でステートを更新しても、通常ステートは非同期タスクと見なされますが、Reactはそれでも全体のフローが同時に実行されるようにします。 最初に実装した「ナビゲーション」の例に戻りましょう。ブラウザの観点から言うと、これは1つの「タスク」に過ぎません。

この状況は、見ることができなかった赤→緑→黒のborder線の遷移と全く同じです。 一方、useEffectの場合、フローが2つのタスクに分割されます。

1つ目のタスクでは、全てのボタンを含むナビゲーションの「最初」のレンダリングをし、2つ目のタスクでは、不要な子要素を除外します。そして、2つのタスクの間に画面のリペインティングが行われます。タイムアウト間のborderと全く同じ状況です。 では、最初の問いに答えましょう。useLayoutEffectを使うのは安全でしょうか。はい、安全です。パフォーマンスに悪影響を及ぼす可能性はあるでしょうか。もちろんです。Reactのアプリ全体が同時に実行される1つの巨大な「タスク」になってしまうのは望ましくありません。 useLayoutEffect の使用は、要素の実際のサイズに従ってUIを調整する必要があり、それによって引き起こされる視覚的な「乱れ」を取り除く必要がある場合のみにしてください。それ以外はuseEffectを使うのが正解です。"You Might Not Need an Effect – React"によると、それさえも必要ないかもしれません。

useEffectについてもう少し

setTimeoutの中でuseEffectを実行するメンタルモデルは違いを理解するのには便利ですが、厳密には正確ではありません。まず、実装の詳細を明確にすると、Reactはこのモデルではなく、postMessagerequestAnimationFrameのトリックと組み合わせて使用します。このトリックについては私も知りませんでした。詳しく知りたい方は、"React: How does React make sure that useEffect is called after the browser has had a chance to paint?"で説明されているのでぜひご覧ください。 また、非同期に実行されることは実際には保証されていません。Reactは可能な限り最適化しようとしますが、ブラウザペイントの前に実行され、結果的にブロックされるケースもあります。そのようなケースの1つが、一連の更新のどこかにuseLayoutEffectがすでに存在する場合です。理屈と仕組みを理解したい場合は、"useEffect sometimes fires before paint"をご覧ください。こちらの記事にとても優れた調査結果が詳しくまとめられています。

Next.jsやその他のSSRフレームワークでuseLayoutEffectを実行する

低レイヤーJavaScriptやブラウザについてはこれくらいにして、本番コードに戻りたいと思います。というのも、「現実世界」では、これらはさほど頻繁に考える必要のないことだからです。「現実世界」では、Next.js(Next.jsの宣伝記事ではないので、もちろん他のフレームワークでも構いません😅​​​​)のようなフレームワーク上で、美しいレスポンシブナビゲーションをコーディングし、優れたユーザーエクスペリエンスを構築できればよいのです。 実際にやってみると、全くうまくいかないことにまず気づきます。チラつきはなくなっておらず、魔法はもう消えています。こちらの例を開いて何度かページをリフレッシュしてみてください。もしくは、Next.jsアプリをお持ちでしたら、前に修正したナビゲーションをコピペしてみてください。 何が起きているのでしょうか?🤨 SSR、つまりサーバーサイドレンダリングです。一部のフレームワークがデフォルトでサポートしている素晴らしい機能ですが、このようなケースでは極めて厄介な代物です。 SSRが有効になっていると、Reactコンポーネントの最初のレンダリングと全てのライフサイクルイベントの呼び出しは、コードがブラウザに届く前にサーバー上で行われます。SSRの仕組みに詳しくない人のために説明すると、これは単にバックエンドのどこかで何らかのメソッドがReact.renderToString(<App />)のようなものを呼び出すということを意味します。そうすると、Reactは次にアプリ内の全てのコンポーネントを処理して「レンダリング」(単に関数を呼び出すということです。コンポーネントはただの関数なので)し、各コンポーネントが表すHTMLを生成します。

次に、このHTMLがブラウザに送られるページ内に注入され、ページが送信されます。これは、全てがサーバー上で生成され、メニューを開くためだけにJavaScriptを使用していた昔と同じやり方です。その後、ブラウザがページをダウンロードして表示し、(Reactを含む)全てのスクリプトをダウンロードして(Reactも含めて)実行します。Reactはあらかじめ生成されたHTMLを処理し、多少のインタラクティブ要素を組み込みます。これで再びページが使える状態になります。 問題は、最初のHTMLを生成した時点では、まだブラウザがないということです。したがって、要素の実際のサイズ計算(useLayoutEffectで行うような)を必要とする処理は、サーバー上では単純にうまくいきません。サイズのある要素はまだ存在せず、あるのは文字列だけなので。useLayoutEffectの目的そのものが要素のサイズを取得することなので、サーバー上で実行してもあまり意味はありません。Reactはサーバー上で実行しません。 その結果、ブラウザが最初に読み込む、まだインタラクティブ要素のないページに表示されるのは、コンポーネントの「最初」のステージでレンダリングされた内容です。すなわち、「もっと見る」ボタンを含む全てのボタンの列です。ブラウザが全ての処理を実行し、Reactが機能する状態になったら、ようやくuseLayoutEffectを実行できるようになり、ボタンが隠れます。しかし、画面のチラつきはあります。 これをどう修正するかはユーザーエクスペリエンスの問題であり、ユーザーに「デフォルトで」何を表示したいかによります。メニューの代わりに「読み込み中」の状態を表示したり、最も重要なメニュー項目を1つか2つ表示したり、項目を完全に非表示にしてクライアント上でのみレンダリングすることもできるでしょう。どうするかはあなた次第です。 1つの方法としては、shouldRenderというステート変数を導入し、useEffectの中でtrueに切り替えます。

const Component = () => {
  const [shouldRender, setShouldRender] = useState(false);


  useEffect(() => {
    setShouldRender(true);
  }, []);


  if (!shouldRender) return <SomeNavigationSubstitude />;


  return <Navigation />
}

useEffectはクライアント上でのみ実行されるため、初回のSSRでは代替コンポーネントが表示されます。次に、クライアントコードが作動し、useEffectが実行され、ステートが変わり、Reactがそれを通常のレスポンシブナビゲーションに置き換えます。 ここでステートを導入することを恐れる必要はありません。また、次のような条件付きのレンダリングを実行しようとしないでください。

const Component = () => {
  // Detecting SSR by checking whether window is there
  if (typeof window ===undefined) return <SomeNavigationSubstitude />;


  return <Navigation />
}

厳密には、typeof window === ‘undefined’はSSR環境を示しますが(サーバー上にウィンドウはありません)、私たちのユースケースでは役に立ちません。Reactでは、SSRから受け取ったHTMLと、クライアント上で初回にレンダリングされたHTMLが完全に一致する必要があります。これらが一致しない場合、アプリは正常に機能しません。さらに詳しく知りたい人は、The Perils of Rehydrationという記事がこのテーマについて優れた考察を行っていますので、そちらをご覧ください。


手軽で便利なトリックについて簡単に説明するだけのつもりが、レンダリングについてかなり突っ込んだ議論になってしまいました。途中で脱落された方がいないことを願います​​😅 この調査で参考にしたリソースを以下にご紹介します。さらに詳しく学びたい方は、ぜひご覧ください。

さらに理解を深めていただくために、YouTube動画もぜひご覧ください。概念によっては、2パラグラフ分の文章よりも3秒のアニメーションの方が伝わりやすい場合もあります。

ではまた次回お会いしましょう。