カテゴリー: FrontEnd

React Konvaのソースコードを読む

はじめに

前回の記事では、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コンポーネントを生成するのみとなっています。

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への伝達を行なっています。

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が共有されます。
// 共有したいContext
export const HogeMessageContext = createContext("");

function App() {
  return (
    // 親コンポーネントでContextProviderを配置
    <HogeMessageContext.Provider value={"foobar"}>
      <EditPage />
    </HogeMessageContext.Provider>
  );
}

export default App;
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に次のように定義されています。

export const KonvaRenderer = ReactFiberReconciler(HostConfig);

ReactFiberReconcilerという、Reactのレンダラーを作成するための機能を使用しています。引数のHostConfigにレンダリングに必要な実装をすることで、仮想DOMを実際にレンダリングします。
ちなみに、React Konvaに限らず、React DOMやReact Nativeも、このHostConfigを実装することで、実際のDOMやNativeのViewなどをレンダリングしています。詳しくはこちらのドキュメンントに説明があります
ここからは、描画用のコンポーネントがStageに追加された場合に、どのようにCanvasに描画されるか追っていきたいと思います。

createInstance

createInstance()関数は、仮想DOMが追加されたときに呼び出されます。

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のアトリビュートとしてセットしたり、イベントリスナーとして登録したりする関数です。

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は以下のように定義されています。

export const EVENTS_NAMESPACE = '.react-konva-event';

Konva Nodeのイベンスト名には名前空間を指定することができ、実際はReact Konva経由で登録したイベントリスナーはすべてこの名前空間が充てられます。詳細はこちらのドキュメントで説明されています。
おそらく、jQueryの on()と同じような概念で、リスナーを名前空間ごとに管理できるようになる仕組みです。(こちらのjQueryの説明記事が分かりやすいです。)
React Konvaでの名前空間の使いどころとしては、後述するKonva Rendererでの実装で、仮想DOMが削除され、実際のKonva Nodeも削除する必要があるときに、全てのイベントを削除するために使われています。

export function removeChild(parentInstance, child) {
  child.destroy();
  child.off(EVENTS_NAMESPACE);
  updatePicture(parentInstance);
}

insertBefore()

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()が呼ばれていますが、以下のような関数となっています。

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の拡張性の高さを実感しました。

おすすめ書籍

カイザー

シェア
執筆者:
カイザー

最近の投稿

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

3週間 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

1か月 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前

FSMを使った状態管理をGoで実装する

はじめに 一般的なアプリケーシ…

3か月 前