こんにちは、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.replaceNode
や ChildNode.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> ); });
ざっくり処理フローを説明すると
magicPortal
内でCanvasIn
内のcreatePortal
のmount先のDOM Elementを作りますCanvasIn
がレンダリングされ、子要素がmagicPortal.element
にマウントされます。ただしこの時点では、まだmagicPortal.element
は DOM tree 上にいませんCanvasOut
がレンダリングされ、一回placeholderのdivがレンダリングされます-
CanvasOut
の中のuseEffect
が発火し、CanvasIn
内createPortal
のmount先のDOM要素がplaceholderのdivと入れ替わります CanvasOut
が別の箇所でレンダリングされと、前のCanvasOut
の中身と入れ替わり、前のCanvasOut
がunmountされます
magicPortalのmount
とunmount
の詳細を補足すると以下になります。
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に興味のある方は是非一度サイトを触ってみてください!