entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

ページ遷移時にReactコンポーネントの状態を維持する

こんにちは、VRoid部所属のエンジニアのyueです。

VRoid Hubでページ遷移時にcanvasの状態を維持する改善をリリースしました。本記事ではこの取り込みについて解説していきます。

前提

VRoid Hubではモデル詳細画面と投稿者のみに表示するモデル編集画面が存在しています。この二つのページは別々のレイアウトを使っていたため、モデル表示用のcanvasを操作するインスタンスがお互い共通せず、ページ遷移するたびにモデルを再度読み込むことが必要でした。

今回はDOM APIを利用してコード変更を最小限に押えつつ、再度読みを無くすような改善を行いました。

問題の再現

通常Reactの再レンダリングを防ぐには様々な手段があります。例えば React.memo 、React コンポーネントの key 、classコンポーネントのshouldComponentUpdate などが存在します。しかしこれらのAPIはほとんど同じ親コンポーネントを想定していて、違う親コンポーネントのchildrenに対して動作しません。

例えば以下の例

※簡素な実装なため requestAnimationFrame ではなく setInterval を使用しています。

import { memo, useEffect, useRef, useState } from "react";

export default function App() {
  const [showBlack, setShowBlack] = useState(true);

  return (
    <>
      <button onClick={() => setShowBlack(!showBlack)}>toggle</button>
      <div>{showBlack ? <Black /> : <Blue />}</div>
    </>
  );
}

const Black = memo(function _Black() {
  return (
    <div>
      <p>Black</p>
      <Canvas color="black" />
    </div>
  );
});

const Blue = memo(function _Blue() {
  return (
    <div>
      <p>Blue</p>
      <Canvas color="blue" />
    </div>
  );
});

const Canvas = memo(function _Canvas({ color }: { color: string }) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const colorRef = useRef(color);

  useEffect(() => {
    colorRef.current = color;
  }, [color]);

  useEffect(() => {
    const ctx = canvasRef.current?.getContext("2d");
    if (!ctx) return;

    let x = 0;
    const interval = setInterval(() => {
      ctx.clearRect(0, 0, 400, 400);
      ctx.beginPath();
      ctx.arc(200, 200, x % 100, 0, 2 * Math.PI);
      ctx.fillStyle = colorRef.current;
      ctx.fill();

      x++;
    }, 25);

    return () => clearInterval(interval);
  }, []);

  return <canvas width={400} height={400} ref={canvasRef} />;
});

toggleをクリックするたびにfirst renderとしてカウントされアニメーションが最初にリセットされます。

さて、どうすれば <Black /> <Blue />の中の <Canvas />の状態を共通にできるでしょう?もちろん<Canvas /><App /> に移動したりcompositingやContextを活用すれば解決するかもしれませんが、<Black /> <Blue />の中身が複雑になりネスティングが深くなるとかなりのリファクタリングが必要になってくるため、今回は選択肢として外します。

reparentingの簡易実装

実はこの問題はReactの公式リポジトリに2015年から上がっており (Support for reparenting #3965 https://github.com/facebook/react/issues/3965)、対策として react-reverse-portal (https://github.com/httptoolkit/react-reverse-portal)などのライブラリーが提示されています。これから react-reverse-portal を参考に、簡易な実装方法について解説していきます。

magicPortal、CanvasInとCanvasOut

通常 React と合わせて使うことはまれですが、DOM API は本来 Node.replaceNodeChildNode.replaceWith などを使用することでcanvasやvideoの状態に影響せずDOM内の位置を移動できます。

まずは <CanvasIn /><CanvasOut />というコンポーネントを追加します。そして両コンポーネントの間で共有される状態を内包した、 今回のコアとなるmagicPortalというオブジェクトを定義します。CanvasIn の中で createPortal して、場合によって CanvasOutの中身と入れ替わります。

magicPortal の中で使われている変数の内訳として

  • parent - CanvasOut の親を保持します
  • element - React.createPortal の対象、実質のDOM nodeとstateを保持します
  • placeholder - CanvasOutの中身が入れ替わるまでの仮置
const magicPortal = {
  parent: null as Node | null,
  element: document.createElement("div"),
  placeholder: null as Node | null,
  mount() { /* 後述 */ },
  unmount() { /* 後述 */ },
}

function CanvasIn() {
  return createPortal(<Canvas />, magicPortal.element);
}

function CanvasOut(props: { color: string }) {
  const placeholderRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const placeholder = placeholderRef.current;
    if (!placeholder?.parentNode) return;
    magicPortal.mount(placeholder.parentNode, placeholder);
    return () => {
      magicPortal.unmount(placeholder);
    };
  }, []);

  return <div ref={placeholderRef} />;
}

そして新しく作ったコンポーネントに書き替えます。アプリケーションコードに関しては変更が相当少ないことがわかります。

export default function App() {
  const [showBlack, setShowBlack] = useState(true);

  return (
    <>
+     <CanvasIn/>
      <button onClick={() => setShowBlack(!showBlack)}>toggle</button>
      <div>{showBlack ? <Black /> : <Blue />}</div>
    </>
  );
}

const Black = memo(function _Black() {
  return (
    <div>
      <p>Black</p>
-      <Canvas color="black" />
+      <CanvasOut color="black" />
    </div>
  );
});

const Blue = memo(function _Blue() {
  return (
    <div>
      <p>Blue</p>
-      <Canvas color="blue" />
+      <CanvasOut color="blue" />
    </div>
  );
});

ざっくり処理フローを説明すると

  1. magicPortal 内で CanvasIn 内の createPortal のmount先のDOM Elementを作ります
  2. CanvasIn がレンダリングされ、子要素が magicPortal.element にマウントされます。ただしこの時点では、まだ magicPortal.element は DOM tree 上にいません
  3. CanvasOut がレンダリングされ、一回placeholderのdivがレンダリングされます
  4. CanvasOut の中の useEffect が発火し、CanvasIncreatePortalのmount先のDOM要素がplaceholderのdivと入れ替わります
  5. CanvasOut が別の箇所でレンダリングされと、前のCanvasOut の中身と入れ替わり、前のCanvasOut がunmountされます

magicPortalのmountunmount の詳細を補足すると以下になります。

const magicPortal = {
  // ...
  mount(newParent: Node, newPlaceholder: HTMLDivElement) {
    if (!magicPortal.element) return;
    if (magicPortal.parent === newParent) return;
    magicPortal.unmount();
    newPlaceholder.replaceWith(magicPortal.element); // replaceWithに注目
    magicPortal.parent = newParent;
    magicPortal.placeholder = newPlaceholder;
  },

  unmount(expectedPlaceholder?: Node) {
    if (expectedPlaceholder && expectedPlaceholder !== magicPortal.placeholder) return;
    if (!parent || !magicPortal.placeholder) return;
    magicPortal.element.replaceWith(magicPortal.placeholder); // replaceWithに注目
    magicPortal.parent = null;
    magicPortal.placeholder = null;
  },
  // ...
};

こうして初回renderingにも関わらず、アニメーションの状態が受け継がれます。

props対応

以下のコードを追加し、CanvasOutが別の場所にレンダリングされた時、propsが変わった時に再レンダリングされるようにします

   function CanvasIn() {
+    const [props, setProps] = useState({});
+    useEffect(() => {
+      Object.assign(magicPortal, { setProps });
+    }, []);
-    return createPortal(<Canvas />, magicPortal.element);
+    return createPortal(<Canvas {...props} />, magicPortal.element);
   }

  function CanvasOut(props: { color: string }) {
    useEffect(() => {
      ...
      magicPortal.mount(placeholder.parentNode, placeholder);
+     magicPortal.setProps(props);
      return () => {
        magicPortal.unmount(placeholder);
      };
    }, []);
  }

簡易実装であるため、一旦 setProps の参照をmagicPortalに持ち出すことで外部からstate操作することが可能になり、切り替え時のprops変化にも対応され、JSXの記述からも期待通りの動きを想定されます。

Reactのプロファイラーから見て実態としての CavnasIn のpropsが変わり再レンダリングされます

実例から見る改善&注意ポイント

それではNext.jsを利用しているVRoid Hubでの実例を見ていきます。例の同じように以下の変更を行います。

_app.tsx

  <>
    <GlobalStyle />
+   <ModelViewerInPortal />
    <Component {...pageProps}  />
  <>

pages/models/[modelId]/index.tsx pages/models/[modelId]/edit.tsx

   <>
-     <ModelViewer {...props} />
+     <ModelViewerOutPortal {...props} />
   <>

これでModelViewerの状態がページ遷移と関係なく維持されるようになりました。

tween.jsを使ったtransition animation

ページ遷移前後のcanvasのサイズが違うため、transition animationをつけます。

Node.replaceNode などのAPIはcss animationに対応していますが、今回はcanvasの中身に対してより細い制御を行いたく、https://github.com/tweenjs/tween.js を利用します。

一部詳細は省きますが、こんな感じのコードになります。都度canvasのresizeするとパフォーマンス問題に繋がりやすいため、今回は一回だけcanvasをresizeした後に THREE.WebGLRenderer.setViewport() 利用し表示エリアを調整します。

  // handle canvas size change on routing
  useEffect(() => {
    // ...
    const before = canvas.current.getBoundingClientRect();
    const after = canvasSizeReference.current.getBoundingClientRect();

    // skip if here is no size change
    if (before.width === after.width && before.height === after.height) return;

    // set canvas size once
    viewer.current.setSize(after.width, after.height);

    // start tween
    let id: number;
    const tween = new TWEEN.Tween({
      w: before.width,
      h: before.height,
    })
      .to({ w: after.width, h: after.height }, 300)
      .easing(TWEEN.Easing.Cubic.InOut)
      .onUpdate(({ w, h, adjust }) => {
        if (!viewer.current) return;
        if (!canvasWrap.current) return;
        const viewHeight = Math.max(128, h);
        viewer.current.setViewport(0, h - viewHeight, w, viewHeight);
        canvasWrap.current.style.height = CSS.px(viewHeight);
      })
      .start();

    function animate(time?: number) {
      id = requestAnimationFrame(animate);
      const result = TWEEN.update(time);
      if (!result) cancelAnimationFrame(id);
    }
    animate();

    return () => {
      if (id) cancelAnimationFrame(id);
      tween.stop();
    };
  }, [onResize, route.pathname]);

しかしこの実装だとcanvasサイズの変化により初期位置がずれてしまいます。

対策として前後のサイズを比べ、差分量を計算し、それを補正値として入れることで、初期位置が動いていないように演出します。

    tween = new TWEEN.Tween({
      w: before.width,
      h: before.height,
+       // since the canvas y baseline is different after navigation,
+       // adjust the init viewport by the y delta and tween it to 0
+       adjust: before.height - after.height,
    })
-     .to({ w: after.width, h: after.height }, 300)
+     .to({ w: after.width, h: after.height, adjust: 0 }, 300)
      // https://sole.github.io/tween.js/examples/03_graphs.html
      .easing(TWEEN.Easing.Cubic.InOut)
      .onUpdate(({ w, h, adjust }) => {
        if (!viewer.current) return;
        if (!canvasWrap.current) return;
        const viewHeight = Math.max(128, h);
-       viewer.current.setViewport(0, h - viewHeight, w, viewHeight);
+       viewer.current.setViewport(0, h - viewHeight - adjust, w, viewHeight);
        canvasWrap.current.style.height = CSS.px(viewHeight);
        onViewportUpdated?.();
      })

広域的なlayout transitionに苦手の人も一定数いるため、 prefers-reduced-motion https://web.dev/prefers-reduced-motion/ のmedia queryを確認しアニメーションを抑えることもできます。

+   // skip if prefer reduced motion
+   if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+      onResize();
+      return;
+    }
    // ... animation code

bundle sizeを注意しましょう

今回 <ModelViewerInPortal /> _app.tsx に入れていましたが、このままだと3D viewerが存在しないページでもthree.jsの関連コードが読み込まれます。この場合 React.lazy または next/dynamic を利用して分割するといいでしょう。

magicPortalの制限と可能性

DOM tree内同一OutPortalは一つまでになります。また、ページ遷移してもInPortalの親が変わらないようにする必要もあります。

あくまで簡易実装であるため、任意なコンポーネントやpropsの型を対応して、edge caseをカーバーしてsvgでも動作させるなど機能を追加をすればreact-reverse-portal に近づくと思うのでよければそちらを確認してください。今回一部Reactの状態をReactの外で管理しているため、場合によってuseSyncExternalStore を挟むことが必要かもしれません。

この実装はcanvasだけではなく任意のReactコンポーネントに対して動作します。また、React側のstateだけではなく、 <video /> など動画埋め込み要素のブラウザ内部の状態でも維持されます。

最後に

これでページ間の遷移がだいぶスムーズになりました。結局Reactの世界から離れて生のDOM動作をしていましたが、コード全体変更がかなり少なく、コンポーネントの利用者側でも一般なReactと違って意外性を感じる部分も抑えられて、いい結果になったと思います。

VRoid Hubでは通常のフロントエンドと違うドメインに触れることもしばしばあるので非常に楽しいです。VRoid Hubに興味のある方は是非一度サイトを触ってみてください!

yue
2021年よりVRoid部所属。RustとTypeScriptが好きです。いつかネコと暮らしたいと思っています。