はじめに
前回の記事では、React KonvaにはReact Componentをchildrenに含めることで、Canvas上に描画できることが分かりました。どのようにCanvasへのレンダリングを行なっているか調べてみたいと思います。
たまには、オープンソースのコードを読むのも良いかなと思ったので、今回は読んで理解した内容を記事にまとめました。
React Konvaの大まかなアーキテクチャ
まず、ReactとReact DOMの責務を整理すると次のようになります。
- React
状態管理、仮想DOMの管理・変更検知 - React DOM
仮想DOMの変更検知に従い、実際にDOMをレンダリングする
React Konvaは React DOMに近い位置付けで、大まかに次のような仕組みとなっています。
- <Stage>のchildrenに渡された描画用のコンポーネント(Circleなど)は、ReactKonvaのカスタムレンダラーによって、Konva.jsへの描画命令に変換されている。
- React Konvaのカスタムレンダラーは、
react-reconciler
を使って実装されている。(React DOMやReact Nativeも使っています) - つまり、<Stage>内の仮想DOMの管理や変更検知はReact Fiberに任せ、React Konvaは変更検知に従ってKonva.jsへの描画命令に変換することを責務の範囲としている。
ちなみに、react-reconcilerを使ったカスタムレンダラーは色々とあり、こちらのリポジトリにまとめられています。
コンポーネントがCanvas描画に変換される仕組み
Stageコンポーネント
Stageコンポーネントは、
StageWrap
コンポーネントを生成するのみとなっています。
1 2 3 4 5 6 7 | export const Stage = React.forwardRef((props, ref) => { return React.createElement( FiberProvider, {}, React.createElement(StageWrap, { ...props, forwardedRef: ref }) ); }); |
FiberProviderとは、its-fineリポジトリが提供しているものでStageWrapで
useContextBridge()
を使用するために、必要なプロバイダです。
useContextBridge()
については、後ほど説明します。
StageWrap
StageWrapでは、Konva.Stageを生成し、useLayoutEffectによって、childrenに渡されたコンポーネント(Circleなど)の変化に応じてKonvaRendererへの伝達を行なっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | const StageWrap = (props) => { const container = React.useRef(); const stage = React.useRef<any>(); const fiberRef = React.useRef(); const oldProps = usePrevious(props); const Bridge = useContextBridge(); const _setRef = (stage) => { const { forwardedRef } = props; if (!forwardedRef) { return; } if (typeof forwardedRef === 'function') { forwardedRef(stage); } else { forwardedRef.current = stage; } }; // Stageの初期化(depsが[]なので、一度だけ呼ばれる) React.useLayoutEffect(() => { // stageの生成 stage.current = new Konva.Stage({ width: props.width, height: props.height, container: container.current, }); _setRef(stage.current); // @ts-ignore // react-reconcilerを使って、レンダラーを作成する //https://scrapbox.io/mkizka/react-reconciler%E3%81%AE%E7%90%86%E8%A7%A3 fiberRef.current = KonvaRenderer.createContainer( stage.current, LegacyRoot, false, null ); // Bridgeコンポーネントを追加し、childrenをそのまま渡す // このchildrenに<Circle />などのコンポーネントが入っていて、ReactKonvaHostConfigによって実際にCanvasに描画される KonvaRenderer.updateContainer( React.createElement(Bridge, {}, props.children), fiberRef.current ); // unmount時の処理 return () => { if (!Konva.isBrowser) { return; } _setRef(null); KonvaRenderer.updateContainer(null, fiberRef.current, null); stage.current.destroy(); }; }, []); // StageWrapがレンダリングされる度に呼び出される React.useLayoutEffect(() => { _setRef(stage.current); // Stageのpropsの変更を反映する applyNodeProps(stage.current, props, oldProps); // Canvas内の描画を更新する KonvaRenderer.updateContainer( React.createElement(Bridge, {}, props.children), fiberRef.current, null ); }); return React.createElement('div', { ref: container, accessKey: props.accessKey, className: props.className, role: props.role, style: props.style, tabIndex: props.tabIndex, title: props.title, }); }; |
このコンポーネントでは、2つの
useLayoutEffect
がメインの処理となっています。
まず、1つ目のuseLayoutEffectでは、第二引数が空配列となっているため、このコンポーネントの1回目のレンダリング時のみ呼び出され、Stageの初期化を行なっています。
以下に実装のポイントをまとめました。
- childrenで渡されてきた<Circle />などのコンポーネントを、Konva.jsによってCanvasに描画させるため、独自のレンダラー
KonvaRenderer
を使用しています。 - KonvaRendererの直下に、
Bridge
コンポーネントを置います。- Bridgeは
useContextBridge()
によって生成される。このフックは、its-fine
という別の依存パッケージによるもので、これを使用すると異なるレンダラー間でContextを共有することができます。そのため、以下のように、通常のReactコンポーネントのように、Stage内でContextが共有されます。
- Bridgeは
1 2 3 4 5 6 7 8 9 10 11 12 13 | // 共有したいContext export const HogeMessageContext = createContext(""); function App() { return ( // 親コンポーネントでContextProviderを配置 <HogeMessageContext.Provider value={"foobar"}> <EditPage /> </HogeMessageContext.Provider> ); } export default App; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | export const EditPage = () => { const hogeMessage = useContext(HogeMessageContext); return ( <div> <Stage width={500} height={500} > <Layer> // Stage内でもContext内の値が共有されている! <Text text={hogeMessage} width={100} height={100} /> </Layer> </Stage> </div> ); }; |
2つ目の
useLayoutEffect
は、第二引数が省略されているため、StageWrap自体が再描画されようとするときに毎回呼ばれます。
ここでは、
applyNodeProps()
でStage自身のpropsの変更を反映した後、
KonvaRenderer.updateContainer()
によって、Canvasの内容を更新しています。(
applyNodeProps()
については後ほど説明します。)
KonvaRenderer
ここがReact Konvaの本題と言える部分になります。Fiberによって差分検知された仮想DOMを、実際にKonvaの描画処理に変換し、レンダリングする機能です。
KonvaRendererはReactKonvaCore.tsxに次のように定義されています。
1 | export const KonvaRenderer = ReactFiberReconciler(HostConfig); |
ReactFiberReconciler
という、Reactのレンダラーを作成するための機能を使用しています。引数のHostConfigにレンダリングに必要な実装をすることで、仮想DOMを実際にレンダリングします。
ちなみに、React Konvaに限らず、React DOMやReact Nativeも、このHostConfigを実装することで、実際のDOMやNativeのViewなどをレンダリングしています。詳しくはこちらのドキュメンントに説明があります。
ここからは、描画用のコンポーネントがStageに追加された場合に、どのようにCanvasに描画されるか追っていきたいと思います。
createInstance
createInstance()関数は、仮想DOMが追加されたときに呼び出されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | export function createInstance(type, props, internalInstanceHandle) { // typeに描画しようとしているコンポーネント名(Circle)が渡される。Konvaに定義されているクラス名と同じになるようになっているので、 // 以下の Konva[type] で生成しようとするクラスを生成する let NodeClass = Konva[type]; if (!NodeClass) { console.error( `Konva has no node with the type ${type}. Group will be used instead. If you use minimal version of react-konva, just import required nodes into Konva: "import "konva/lib/shapes/${type}" If you want to render DOM elements as part of canvas tree take a look into this demo: https://konvajs.github.io/docs/react/DOM_Portal.html` ); NodeClass = Konva.Group; } // we need to split props into events and non events // we we can pass non events into constructor directly // that way the performance should be better // we we apply change "applyNodeProps" // then it will trigger change events on first run // but we don't need them! const propsWithoutEvents = {}; const propsWithOnlyEvents = {}; for (var key in props) { var isEvent = key.slice(0, 2) === 'on'; if (isEvent) { propsWithOnlyEvents[key] = props[key]; } else { propsWithoutEvents[key] = props[key]; } } // events (onXXX) 以外のpropsを使ってクラス生成 const instance = new NodeClass(propsWithoutEvents); // events (onXXX) はapplyNodePropsで適用 applyNodeProps(instance, propsWithOnlyEvents); return instance; } |
createInstanceで、仮想DOMとして渡した描画コンポーネントをもとに、Konvaのクラスからインスタンス生成し、returnします。ここでreturnしたインスタンスは、次に紹介するinsertBefore()に渡されます。
ちなみに、ここで生成した描画用のインスタンスは、全て Node クラスを継承しているので、こちらのドキュメントを見ながらコードを読み進めると、理解が深まりました。
applyNodeProps()
applyNodePropsは、propsとして渡された値を、実際にKonva Classのアトリビュートとしてセットしたり、イベントリスナーとして登録したりする関数です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | export function applyNodeProps(instance, props, oldProps = EMPTY_PROPS) { if (props === oldProps) { console.error('same props'); } // don't use zIndex in react-konva if (!zIndexWarningShowed && 'zIndex' in props) { console.warn(Z_INDEX_WARNING); zIndexWarningShowed = true; } // check correct draggable usage if (!dragWarningShowed && props.draggable) { var hasPosition = props.x !== undefined || props.y !== undefined; var hasEvents = props.onDragEnd || props.onDragMove; if (hasPosition && !hasEvents) { console.warn(DRAGGABLE_WARNING); dragWarningShowed = true; } } // check old props // we need to unset properties that are not in new props // and remove all events for (var key in oldProps) { if (propsToSkip[key]) { continue; } var isEvent = key.slice(0, 2) === 'on'; var propChanged = oldProps[key] !== props[key]; // if that is a changed event, we need to remvoe it if (isEvent && propChanged) { // keyが onContentXXX(onContentTouchなど)の場合は、キャメルケースに整える // 例: // | key | eventName | eventName(変換後) | var eventName = key.substr(2).toLowerCase(); if (eventName.substr(0, 7) === 'content') { eventName = 'content' + eventName.substr(7, 1).toUpperCase() + eventName.substr(8); } instance.off(eventName, oldProps[key]); } var toRemove = !props.hasOwnProperty(key); if (toRemove) { instance.setAttr(key, undefined); } } var strictUpdate = useStrictMode || props._useStrictMode; var updatedProps = {}; var hasUpdates = false; const newEvents = {}; for (var key in props) { if (propsToSkip[key]) { continue; } // onXXX のpropsだけをnewEventsに集める var isEvent = key.slice(0, 2) === 'on'; var toAdd = oldProps[key] !== props[key]; if (isEvent && toAdd) { var eventName = key.substr(2).toLowerCase(); // keyが onContentXXX(onContentTouchなど)の場合は、キャメルケースに整える // 例: // | key | eventName | eventName(変換後) | // onContentTouch -> oncontenttouch -> contentTouch if (eventName.substr(0, 7) === 'content') { eventName = 'content' + eventName.substr(7, 1).toUpperCase() + eventName.substr(8); } // check that event is not undefined if (props[key]) { newEvents[eventName] = props[key]; } } // event以外は、updatedPropsに集める if ( !isEvent && (props[key] !== oldProps[key] || (strictUpdate && props[key] !== instance.getAttr(key))) ) { hasUpdates = true; updatedProps[key] = props[key]; } } // event以外のpropsをKonva Classのインスタンスに適用する if (hasUpdates) { instance.setAttrs(updatedProps); updatePicture(instance); } // subscribe to events AFTER we set attrs // we need it to fix https://github.com/konvajs/react-konva/issues/471 // settings attrs may add events. Like "draggable: true" will add "mousedown" listener // Konva Classのインスタンスにイベントリスナーを追加する for (var eventName in newEvents) { instance.on(eventName + EVENTS_NAMESPACE, newEvents[eventName]); } } |
まず、渡されたoldPropsの内容を、Konva Classから一旦全部削除します。
on
から始まるpropsはイベントリスナーとして扱われ、
instance.off(eventName, oldProps[key])
で削除され、その他のpropsは
instance.setAttr(key, undefined)
で削除します。
次に、渡されたpropsの内容をKonva Nodeに適用します。
こちらもイベントリスナーと他のpropsの分け方は削除時と似ています。イベントリスナーは一番最後のforループで
instance.on(eventName + EVENTS_NAMESPACE, newEvents[eventName])
で登録し、その他のpropsは
instance.setAttrs(updatedProps);
でセットします。
ちなみに、上記のコードでは省略しましたが、実際のコードでは
EVENTS_NAMESPACE
は以下のように定義されています。
1 | export const EVENTS_NAMESPACE = '.react-konva-event'; |
Konva Nodeのイベンスト名には名前空間を指定することができ、実際はReact Konva経由で登録したイベントリスナーはすべてこの名前空間が充てられます。詳細はこちらのドキュメントで説明されています。
おそらく、jQueryの
on()
と同じような概念で、リスナーを名前空間ごとに管理できるようになる仕組みです。(こちらのjQueryの説明記事が分かりやすいです。)
React Konvaでの名前空間の使いどころとしては、後述するKonva Rendererでの実装で、仮想DOMが削除され、実際のKonva Nodeも削除する必要があるときに、全てのイベントを削除するために使われています。
1 2 3 4 5 | export function removeChild(parentInstance, child) { child.destroy(); child.off(EVENTS_NAMESPACE); updatePicture(parentInstance); } |
insertBefore()
1 2 3 4 5 6 7 8 9 | export function insertBefore(parentInstance, child, beforeChild) { // child._remove() will not stop dragging // but child.remove() will stop it, but we don't need it // removing will reset zIndexes child._remove(); parentInstance.add(child); child.setZIndex(beforeChild.getZIndex()); updatePicture(parentInstance); } |
parentInstanceには仮想DOMツリー上の親要素に当たるインスタンスが入っています。例えば、Layer直下にCircleを追加した場合、
parentInstance
はLayer、
child
にはCircleが入ります。
まず、
child_remove()
していますが、この
insertBefore()
は、仮想DOMが追加されるだけでなく、要素の順番を入れ替える変更が起きたときにも呼ばれるためです。
そして、
parentInstance.add(child)
することでCanvasに描画させます。このadd()関数は、通常のKonnva.jsでも使用する関数です。
また、
child.setZIndex(beforeChild.getZIndex())
で、直前の子要素より上に描画されるように、Canvas内での描画順序を変更しています。
最後に、updatePicture()が呼ばれていますが、以下のような関数となっています。
1 2 3 4 5 6 | export function updatePicture(node) { if (!Konva.autoDrawEnabled) { var drawingNode = node.getLayer() || node.getStage(); drawingNode && drawingNode.batchDraw(); } } |
Konva.autoDrawEnabled
がfalseの場合は、レイヤーに対する変更をしても再描画されないため、
drawingNode.batchDraw()
を呼び出して実際にCanvasに描画するようにしています。
ただし、デフォルトで
Konva.autoDrawEnabled
はtrueなので、レイヤーに対する変更は自動的に描画されるようになっています。
さいごに
Reactの低レイヤー部分をカスタム(特にレンダリング部分)したコードでしたが、react-reconcilerを使っていることでシンプルな実装となっていることが分かり、Reactの拡張性の高さを実感しました。