zpaper-draft

Type to search...

to open search from anywhere

Electronの基本的な仕組み: Webアプリがデスクトップアプリになるまで

概要

自分は個人用のメッセージ作成ヘルパーアプリ(zudotext)をElectronで作っている。CodeMirrorのエディタとxterm.jsのターミナルを組み込んだ、Reactベースのデスクトップアプリ。

この開発を通じて、Electronが内部でどう動いているのかがだいぶ見えてきた。「.appファイルの中身って何が入ってるの?」「Node.jsをユーザーにインストールしてもらう必要あるの?」みたいな、最初に抱きがちな疑問に対する答えをまとめておく。Web開発の経験はあるがElectronは使ったことがないという人向けのメモ。

Electronとは何か

ごく端的にいうと、ElectronはChromium(ブラウザエンジン)とNode.js(サーバーサイドJS)を1つのアプリにパッケージしたもの。「Webアプリをデスクトップアプリに変換するシステム」と考えるとわかりやすい。

有名なElectronアプリとしては以下がある。

どれもWeb技術で作られたUIがデスクトップアプリとして動いている。

3つのプロセス

Electronアプリは3種類のプロセスで構成されている。メインプロセス、プリロードスクリプト、レンダラープロセスの3つ。

メインプロセス

Node.jsが動くプロセス。ファイルI/O、OS統合、ウィンドウ管理を担当する。アプリのエントリーポイントはここ。

const { app, dialog } = require("electron");
const { registerIpcHandlers } = require("./src/ipc-handlers");
const { registerPtyHandlers } = require("./src/pty-manager");
const { createMainWindow } = require("./src/windows");

async function initialize() {
  const projectRoot = await getProjectRoot();
  registerIpcHandlers(projectRoot);
  registerPtyHandlers(projectRoot);
  createMainWindow();
}

app.whenReady().then(initialize);

app.whenReady()でElectronの初期化を待ち、ウィンドウを作り、IPCハンドラを登録する。fsモジュールでファイルを読み書きしたり、dialogでOSネイティブのダイアログを出したりできる。普通のWebアプリではできないことがここでできる。

プリロードスクリプト

メインプロセスとレンダラーの橋渡しをするスクリプト。contextBridgeを使って、レンダラーに公開するAPIを定義する。

const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("api", {
  messages: {
    list: () => ipcRenderer.invoke("messages:list"),
    read: (filename) => ipcRenderer.invoke("messages:read", filename),
    write: (filename, content) =>
      ipcRenderer.invoke("messages:write", filename, content),
  },
  settings: {
    get: () => ipcRenderer.invoke("settings:get"),
    save: (settings) => ipcRenderer.invoke("settings:save", settings),
  },
});

contextBridge.exposeInMainWorld("api", ...)で定義したものが、レンダラー側でwindow.apiとしてアクセスできるようになる。ここで公開したものだけがレンダラーから使える。逆にいうと、ここで公開していないNode.jsのAPIはレンダラーからは一切触れない。

レンダラープロセス

普通のWebアプリ。React、Vue、vanilla JSなんでもOK。ブラウザで動くのと同じコードが動く。メインプロセスとの通信はwindow.api経由で行う。

function App() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    window.api.messages.list().then(setMessages);
  }, []);

  return (
    <div>
      {messages.map((msg) => (
        <div>{msg.title}</div>
      ))}
    </div>
  );
}

見ての通り、普通のReactコンポーネント。window.api.messages.list()がIPCを通じてメインプロセスのmessages:listハンドラを呼び、ファイルシステムからメッセージ一覧を取得する。レンダラー側のコードはfsモジュールの存在を知らなくて良い。

開発時と本番時の違い

開発時と本番時では、レンダラーのHTMLの読み込み方が違う。

開発時はViteなどの開発サーバーがhttp://localhost:5173で動き、ElectronのウィンドウがそのURLをloadURL()で読み込む。ホットリロードが効くのでWeb開発と同じ体験で作業できる。

本番時はvite buildでHTMLとJSを静的ファイルに出力し、ElectronがloadFile()で直接読み込む。Webサーバーは不要で、ポートも使わない。

if (app.isPackaged || process.env.ELECTRON_USE_BUILT_RENDERER) {
  win.loadFile(path.join(__dirname, "..", "dist-renderer", "index.html"));
} else {
  win.loadURL(VITE_DEV_URL);
}

app.isPackagedtrueなら本番ビルド、falseなら開発中という判定。

この「本番時はfile://プロトコルで読み込む」という仕組みから、いくつかの制約が生まれる。

  • HashRouterを使う — file://のURLではサーバーサイドルーティングが効かないので、BrowserRouterではなくHashRouterを使う必要がある
  • Viteの出力パスは相対パスにする — ./assets/index-abc.jsのように相対パスで出力しないと、file://プロトコルでアセットを解決できない。ルート相対パス(/assets/index-abc.js)だとファイルが見つからなくなる
  • ポートの競合は開発時だけの問題 — 本番ではポートを使わないので気にしなくて良い

.appファイル / .exeファイルの中身

パッケージされたElectronアプリの中に何が入っているかを見てみる。

macOSの.appの中身はこうなっている。

MyApp.app/
  Contents/
    MacOS/
      MyApp              ← Electronバイナリ(Chromium + Node.js)
    Resources/
      app.asar           ← アプリのコード(JS/HTML/CSS)をパッケージしたもの
      electron.asar      ← Electronの内部コード
    Frameworks/
      Electron Framework.framework/  ← Chromiumエンジン
    Info.plist           ← アプリのメタデータ

Windowsの場合はこう。

MyApp/
  MyApp.exe              ← Electronバイナリ
  resources/
    app.asar             ← アプリコード
  *.dll                  ← Chromium/Node.jsのライブラリ

app.asarがアプリのコードをまとめたアーカイブファイル。asarはElectron独自のアーカイブ形式で、JS、HTML、CSSなどのアプリコードがここに入っている。

electron-builderの設定で、どのファイルをパッケージに含めるかを指定する。

{
  "build": {
    "appId": "com.takazudo.zudotext",
    "productName": "zudotext",
    "mac": {
      "category": "public.app-category.productivity",
      "target": "dir"
    },
    "files": [
      "main.js",
      "preload.js",
      "splash.html",
      "src/**/*",
      "dist-renderer/**/*",
      "package.json"
    ]
  }
}

files配列に指定したものだけがapp.asarに入る。node_modulesの中の必要な依存関係(package.jsondependenciesに書かれているもの)も自動的に含まれる。

Node.jsバイナリの同梱

「ユーザーにNode.jsをインストールしてもらう必要がある?」という疑問があるかもしれない。

答えはいいえ。ElectronにはNode.jsランタイムが含まれている。Electronバイナリ自体がChromiumとNode.jsを内包しているので、ユーザーのマシンにNode.jsがなくても動く。

これがElectronアプリのサイズが大きい理由でもある。最小でも100MB以上になる。内訳はだいたいこんな感じ。

  • Chromiumエンジン(レンダリング用): 70-100MB
  • Node.jsランタイム(メインプロセス用): 20-30MB
  • アプリのコード: 通常数MB

つまりElectronアプリは「専用ブラウザ + Node.jsサーバー + Webアプリ」を1つのファイルにまとめたもの。アプリのコード自体は数MBでも、それを動かすためのランタイムが100MB以上あるということになる。

セキュリティモデル

Electronのセキュリティ設定についても簡単に触れておく。BrowserWindowを作る際に以下のように設定する。

const win = new BrowserWindow({
  webPreferences: {
    nodeIntegration: false,
    contextIsolation: true,
    preload: path.join(__dirname, "preload.js"),
  },
});
  • nodeIntegration: false — レンダラーから直接Node.jsのAPIにアクセスさせない
  • contextIsolation: true — レンダラーのJSとプリロードのJSを分離する
  • preload — プリロードスクリプトのパスを指定する

この設定により、レンダラー(Webアプリ部分)はwindow.apiで公開された安全なAPIだけを使える。レンダラーから直接fs.readFile()のような操作はできない。

なぜこれが大事かというと、レンダラーは基本的にWebページと同じ環境なので、もしnodeIntegration: trueにしてしまうと、XSSなどでレンダラーに悪意のあるスクリプトが注入された場合にNode.jsの全APIが使えてしまう。ファイルの読み書きやコマンドの実行がやり放題ということになる。contextBridgeで明示的に許可したAPIだけを公開することで、このリスクを最小限に抑えられる。

余談

Electronのアーキテクチャを理解してから振り返ると、VS CodeやSlackがなぜあのサイズなのかがわかる。Chromiumを丸ごと同梱しているのだからそうなる。

一方で、Web技術でデスクトップアプリが作れるのはかなり便利で、React + TypeScriptでUIを書けるのはWebフロントエンドに慣れている人間からすると楽。自分の場合、zudotextのUIをゼロから書き始めて、既存のWeb開発の知識がそのまま使えた。新しく覚える必要があったのはメインプロセスとレンダラーの分離、IPCの仕組み、パッケージングの設定ぐらい。

サイズが大きいという批判は当然あるが、個人ツールとして使う分にはあまり気にならない。