🗒️

ExpoとElectronのマルチプラットフォーム環境でのJotaiとDOMコンポーネントによる共通化

2025/01/29に公開

はじめに

筆者は個人開発で「ナノノート」というメモアプリを開発しています。

ナノノートはモバイル版はExpo、デスクトップ版はElectronで開発しています。この記事ではマルチプラットフォームアプリでのロジックの共通化の例をご紹介します。

ナノノートとは?

デスクトップ・モバイルで同期できる、マークダウン対応のシンプルなメモアプリです。アプリの詳細は窓の杜の記事を参照ください。

デスクトップ・モバイル同期、Markdown対応のシンプルなメモアプリ「ナノノート」 - 窓の杜

ナノノートのアーキテクチャ

以下がアーキテクチャです。

React Native/ExpoでiOS/Android版を、React/Electornを使ってWeb/Electron版をを開発しています。またWeb版は中身はほぼElectron版と同じです。 開発言語はすべてTypeScriptで統一しています。

共通処理の実現方法

ナノノートでの共通的な処理は大きく分けて3つに分かれます。

1. サービス

UIに依存しないアプリ固有の処理はサービスとて共通化されています。具体的には「ノートを新規作成する」「ノートを別のフォルダに移動する」「ゴミ箱を空にする」などです。

サービスは処理ごとにJotaiのatomとして実装しました。JotaiはReact Nativeでも使用できプラットフォーム間の共通化に最適でした。またグローバルなステート管理としてもシンプルで扱いやすかったです。

それぞれのプラットフォームで使用するライブラリが異なる場合があります。特にExpoの場合はネイティブのライブラリをそのまま使いたい場合があります。そのようなケースでは共通のインターフェイスを作成してドライバという形でそれぞれのプラットフォームでDIして差分を吸収しています。DIはそれぞれのプラットフォームマウント時にJotaiのatomにドライバをセットするというシンプルな仕組みで実現しています。

共通サービス

// サービスはJotaiのatomで実装
export const createFolderAtom = atom<
  null,
  [
    {
      folderName: string;
    },
  ],
  Promise<void>
>(null, async (get, _, data) => {
  const userId = get(authUserAtom)?.uid;
  if (!userId) {
    return;
  }
  const newFolder: NewFolderData = {
    name: data.folderName,
  };
  // DIされたdriverを使ってそれぞれのプラットフォームの差分を吸収
  await FolderRepository(get(repositoryDriverAtom)).create(userId, newFolder);
  get(analyticsDriverAtom).analytics.logEvent("folder_created");
});

Expo/React Nativeでのコード

export const CreateFolderButton = () => {
  const createFolder = useSetAtom(createFolderAtom);
  
  const handleCreate = () => {
    const folderName = ... // React NativeのAlertによる入力
   createFolder({folderName});
  }
};

Electron/React DOMでのコード

export const CreateFolderButton = () => {
  const createFolder = useSetAtom(createFolderAtom);
  
  const handleCreate = () => {
    const folderName = ... // MUI(React DOM)のDialogによる入力
   createFolder({folderName});
  }
};

2. エディタ

ノートアプリのコア部分であるエディタはReact DOMのコンポーネントとして共通化されています。エディタ部分はTipTapを採用しています。エディタのプラグインやスタイル等もExpo/Electronともに同じロジックが使うことで、モバイルアプリでも見てもデスクトップアプリで見ても同じ見た目、振る舞いが実現できました。

共通化されたエディタコンポーネントはElectron版の場合はReact DOMで構築されているので直接インポートして使用できます。

問題はExpo版です。ExpoはReact Nativeですので通常だとDOMコンポーネントは使用できませんが、DOMコンポーネントサポートという仕組みがあります。これによりReact NativeからReact DOMで作られたコンポーネントを利用することができます。内部的にはWebViewのラッパーなのですが、イベントハンドラを渡したり、親から子の処理を呼び出せたり通常のReactコンポーネントのようにシームレスに利用できる仕組みを備えています。パフォーマンスもメモアプリとしては問題ないレベルでした。

// Expo側のコンポーネント(DOMコンポーネントを呼び出す側)
export default function ExpoEditorContainer() {
  return (
    ...
    <ExpoEditor ... />
    ...
  );
}

// DOMコンポーネントの宣言
"use dom";

// 共通化されたエディタを呼び出して、React Native側ともやり取りするアダプター的なコンポーネント
export default function ExpoEditor() {
  return (
    ...
    // 共通化されたエディタ
    <NanonoteEditor ... />
    ...
  );
} 

なおツールバーやコンテキストメニューなどエディタに付随するUI、またナビゲーションやダイアログなどのUIはプラットフォームで最適解が異なるため大部分がExpo/Electronでそれぞれで実装されています。

3. ユーティリティ

文字列操作やHTMLの加工、エクスポート時のフォーマット変換などのユーティリティは共通化してそれぞれのプラットフォームで直接インポートして利用しています。

まとめ

本稿ではExpo/Electronでの共通化の仕組みを紹介しました。共通化により開発速度の向上とプラットフォーム間で仕様のズレを減らすことができました。マルチプラットフォームアプリ開発の参考になれば幸いです。

Discussion