Electron製アプリの起動速度を1,000ミリ秒速くする方法

Electron製アプリの起動速度を1,000ミリ秒速くする方法

Electron製アプリの起動速度を1,000ミリ秒速くする方法

JavaScriptの読み込みはめっちゃ遅い / require() は必要になるまで呼び出すな / V8 snapshotsを使う

英語で先に書いてから日本語訳しています ⇒ English version is available here.

こんにちは、TAKUYAと申します。InkdropというMarkdownノートアプリを開発しています。このアプリはElectronで作っています。Electronは、NodeJSとChromium(ブラウザ)ベースでクロスプラットフォームなデスクトップアプリが開発できるフレームワークです。このフレームワークは基本的に素晴らしいです。なぜならあなたはネイティブ用のフレームワークを学ぶ必要がなく、JavaScript、HTMLとCSSでアプリが作れるからです。もしあなたがウェブデベロッパーだったら、手軽にアプリが作れるでしょう。一方で、多くの人がElectronの弱い点についても言及しています。それはアプリの起動速度が遅い傾向にある、という点です。僕のアプリも、ユーザさんから「起動速度が遅い」という苦情を受けて、同じ問題に直面していました。起動速度が遅いとイライラしますよね。でも今回、この問題が解決できて僕はすごく嬉しいです。アプリのTTI(Time to Interactive = 操作できるようになるまでの時間)は4秒から3秒に縮めることに成功しました(手持ちのmac上で計測)。あえて「1秒」ではなく「1,000ミリ秒」高速化したとここでは言わせてください。なぜならこの違いは大きく、そしてその結果を得るためにメチャクチャ苦労したからです。以下の比較映像を覧ください:

前のバージョンよりも明らかに速いことがお分かりになると思います。まずアプリのメインウインドウの表示が少し速く、更にアプリバンドルがブラウザウィンドウで読み込み完了するまでの時間も速くなっています。現在ベータテスト中ですが、すでにユーザさんからは喜びのメッセージを頂いています。嬉しい。早くリリースしたい。

他の多くのElectronアプリ開発者も、このような起動速度が遅い問題に悩まれているのではないでしょうか。本稿では、自分がどのようにして解決したのかシェアしたいと思います。さあ、Electronアプリを高速化しましょう!

JavaScriptの読み込みはめっちゃ遅い

なぜElectronのアプリの起動は遅い傾向にあるのでしょうか?起動における大きなボトルネックはJavaScriptの読み込みプロセスにあります。Develoer Toolsのパフォーマンス分析ツールで、どのようにあなたのアプリバンドルが読み込まれているのか覗き見ることが出来ます。

Cmd-Eまたは赤いRecordボタンを押して、ランタイムパフォーマンスの記録を開始して、アプリを再読込します。すると以下のようなタイムラインが表示されます:

Developer Tools’ Performance Analytics

上図のように、タイムライン上ではモジュールの `require` にとても時間がかかっている事が分かります。どれぐらい時間がかかるかは、あなたのアプリの依存モジュール/ライブラリの数や大きさによって違います。

拙作アプリの場合は、プラグイン機能や、拡張可能なMarkdownエディタ、Markdownレンダラ、その他いろいろな機能を実現するために大量の依存モジュールがありました。スピードのためにそれらを削ぎ落とすのは難しく見られました。

もしあなたのプロジェクトは比較的新しくてシンプルであれば、パフォーマンスを意識してどのライブラリを使うか注意深く選定しましょう。より少ない依存は常により良いパフォーマンスをもたらします。

require() は必要になるまで呼び出すな

大きな読み込み時間を避けるために出来る最初の対策は、依存モジュールの require() コールを、本当に必要になる時まで遅らせることです。

改善後、僕のアプリのメインウインドウは少しだけ速く表示されるようになりました。これはなぜかというと、 jsdom というモジュールをメインプロセス内で読み込んでいました。これはHTMLをパースするために追加したのですが、非常に巨大で読み込みに数百ミリ秒かかる事が分かりました。

この問題の解決法は主に2つあります。

1. より軽い代替手段を使う

もしライブラリの読み込みが重いと感じたら、より小さい代替ライブラリを探しましょう。僕の場合、最終的に jsdom じゃなくても DOMParser というWeb APIを使えばHTMLのパースが出来ることに気づきました。HTMLのパースはライブラリ無しで以下のようにできます:

const dom = new DOMParser().parseFromString(html, 'text/html')

2. コードの評価時点でrequireしない

ライブラリはコード冒頭で require するのが通例です:

import { JSDOM } from 'jsdom'
export function parseHTML(html) {  const dom = new JSDOM(html);  // ...}

その代わり、用意した処理が呼び出された時に初めて require されるようにするのです:

var jsdom = null
function get_jsdom() {  if (jsdom === null) {    jsdom = require('jsdom')  }  return jsdom}
export function parseHTML(html) {  const { JSDOM } = get_jsdom()  const dom = new JSDOM(html);  // ...}

こうすれば、dependencyを落とさなくても起動時間を短縮することが出来るでしょう。ただし、Webpackなどでアプリバンドルをビルドしている人は注意です。依存ライブラリをアプリバンドルに同梱しないように設定する必要があります。

V8 snapshotsを使う

これで僕のアプリの起動は200〜300msぐらい速くなりました。それでも、レンダラプロセスの読み込みは依然として遅いままです。多くの依存モジュールは起動後すぐに必要になるので、読み込みを遅らせるのは難しいです。

ChromiumはあなたのJSファイルやモジュール群を読み込んで評価して実行する必要があります。これはたとえローカルのファイルシステムからの読み込みであっても、あなたの想像以上に時間がかかります。拙作アプリの場合は1〜2秒かかります。多くのネイティブアプリはその必要がありません。なぜならそれらはすでにバイナリコードであり、OSはプログラムを機械語に翻訳しなくてもそのまま実行できるからです。

ChromiumのJavaScriptエンジンはV8です。そしてV8には高速化テクニックがあります — — V8 snapshotsです。V8 snapshotsは、Electronアプリの任意のJavaScriptコードを実行した結果の状態、つまりGC後にメモリに残ったデータのヒープをシリアライズしてバイナリファイルに保存できます。

Atom Editorは3年前にこのV8 snapshotsを使って起動速度の改善に成功しました:

Atomチームはこの手法によって500ミリ秒高速化しています。これは期待できそうです。

V8 snapshotsのはたらき

結論から先にいうと、この方法はめっちゃ上手く行きました。例えば、 remark-parse というライブラリの読み込みは1ms以下になりました。

v8 snapshots無しの場合:

v8 snapshotsから読み込んだ場合:

Cool!!!

そして browser-main.js の読み込み評価は…

だったのが、

と高速化しました。効果絶大ですね。

こちらがPreferencesウインドウを表示させた時の比較映像です。V8 snapshotsがどれぐらいアプリバンドルの読み込みを高速化したのかが分かると思います:

ではどうやって実際にV8 snapshotsからモジュール群を読み込んでいるのでしょうか?あなたのカスタムV8 snapshotsを同梱したElectronアプリでは、 snapshotResult という変数がglobalスコープに存在します。この変数には、プリロードされたJavaScript実行結果のキャッシュデータが入っています:

これらのモジュール群は require() を呼ばなくても使えるという訳です。これがV8 snapshotsアリだと速い理由です。

では次のセクションで、実際にカスタムV8 snapshotsを作成する方法について説明します。

V8 snapshotsを生成する方法

以下のステップを行います:

  1. ツールのインストール
  2. electron-link でJSソースコードを前処理する
  3. V8 snapshotsを mksnapshot で生成する
  4. スナップショットをElectron内で読み込む

このチュートリアルに向けてサンプルのプロジェクトを作りました。以下のリポジトリをご参照ください:

ツールのインストール

以下のパッケージが必要です:

mksnapshot はV8 snapshotsを生成するためのツールで、electron-link を使って前処理したJavaScriptのファイルを食わせます。electron-mksnapshot はElectronと互換性のある mksnapshot 用のバイナリファイルをダウンロードします。もしあなたは古いElectronを使っている場合は、ELECTRON_CUSTOM_VERSION 環境変数を設定してElectronのバージョンを指定しながらインストールします:

# Install mksnapshot for Electron v8.3.0ELECTRON_CUSTOM_VERSION=8.3.0 npm install

バイナリのダウンロードは時間がかかります。以下のようにElectronのミラーをELECTRON_MIRROR 環境変数で指定してやると少し速くなります:

# Electron mirror for ChinaELECTRON_MIRROR="https://npm.taobao.org/mirrors/electron/"

electron-linkでJavaScriptファイルを前処理する

electron-link はJavaScriptのファイルをスナップショット化可能なように変換してくれるツールです。なぜ変換が必要かというと、V8 context内ではNodeJSのビルトインモジュールとかネイティブモジュールが読み込めないからです。もしあなたのアプリがシンプルなら、アプリのエントリポイントを渡せばよいでしょう。自分の場合はアプリが複雑すぎて、エントリポイントを渡しても生成に失敗してしまいました。なので、以下のようなスナップショット生成用のJSファイルを別途用意しました。必要なライブラリを require するだけの簡単なファイルです:

// snapshot.jsrequire('react')require('react-dom')// ...require more libraries

そして snapshot.js という名前でプロジェクトディレクトリに保存します。次に、以下のように electron-link を使ってこのJSファイルを前処理します:

const vm = require('vm')const path = require('path')const fs = require('fs')const electronLink = require('electron-link')
const excludedModules = {}
async function main () {  const baseDirPath = path.resolve(__dirname, '..')
console.log('Creating a linked script..')  const result = await electronLink({    baseDirPath: baseDirPath,    mainPath: `${baseDirPath}/snapshot.js`,    cachePath: `${baseDirPath}/cache`,    shouldExcludeModule: (modulePath) => excludedModules.hasOwnProperty(modulePath)  })
const snapshotScriptPath = `${baseDirPath}/cache/snapshot.js`  fs.writeFileSync(snapshotScriptPath, result.snapshotScript)
// Verify if we will be able to use this in `mksnapshot`  vm.runInNewContext(result.snapshotScript, undefined, {filename: snapshotScriptPath, displayErrors: true})}
main().catch(err => console.error(err))

スナップショット化可能なJSファイルが<PROJECT_PATH>/cache/snapshot.js に出力されます。この前処理されたJSには、依存ライブラリのソースが直接含まれています。ちょうどWebpackでバンドルを生成した時のような感じです。違いは、禁止モジュール(NodeJSの path など)のrequireが遅延されて、V8 context内で読み込まれないようになっている点です。(詳しくは electron-linkのドキュメントを参照)

mksnapshotでV8 snapshotsを生成する

これでスナップショット生成可能なスクリプトが出来ました。以下のスクリプトを実行してV8 snapshotsを生成します:

const outputBlobPath = baseDirPathconsole.log(`Generating startup blob in "${outputBlobPath}"`)childProcess.execFileSync(  path.resolve(    __dirname,    '..',    'node_modules',    '.bin',    'mksnapshot' + (process.platform === 'win32' ? '.cmd' : '')  ),  [snapshotScriptPath, '--output_dir', outputBlobPath])

スクリプトの全文はこちらを参照してください。

ついにv8_context_snapshot.bin というスナップショットのファイルが手に入りました。

スナップショットをElectron内で読み込む

早速生成したV8 snapshotsをElectronアプリ内に読み込んでみましょう。ElectronはデフォルトのV8 snapshotsをバイナリ内に持っています。これを自分のものに置き換えます。以下がV8 snapshotsの存在するパスです:

  • macOS: node_modules/electron/dist/Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Resources/
  • Windows/Linux: node_modules/electron/dist/

v8_context_snapshot.bin を上記ディレクトリにコピーしてください。こちらがコピースクリプトです。アプリを起動すると、snapshotResult という変数がglobalスコープにあるはずです。コンソールにsnapshotResult と打って確かめてください。

これでElectronアプリ内にカスタムのスナップショットを読み込むことに成功しました。さて、どうやって使えばよいでしょうか。

以下のように、デフォルトの require 関数をオーバーライドする必要があります:

const path = require('path')
console.log('snapshotResult:', snapshotResult)if (typeof snapshotResult !== 'undefined') {  console.log('snapshotResult available!', snapshotResult)
const Module = require('module')  const entryPointDirPath = path.resolve(    global.require.resolve('react'),    '..',    '..',    '..'  )  console.log('entryPointDirPath:', entryPointDirPath)
Module.prototype.require = function (module) {    const absoluteFilePath = Module._resolveFilename(module, this, false)    let relativeFilePath = path.relative(entryPointDirPath, absoluteFilePath)    if (!relativeFilePath.startsWith('./')) {      relativeFilePath = `./${relativeFilePath}`    }    if (process.platform === 'win32') {      relativeFilePath = relativeFilePath.replace(/\\/g, '/')    }    let cachedModule = snapshotResult.customRequire.cache[relativeFilePath]    if (snapshotResult.customRequire.cache[relativeFilePath]) {      console.log('Snapshot cache hit:', relativeFilePath)    }    if (!cachedModule) {      console.log('Uncached module:', module, relativeFilePath)      cachedModule = { exports: Module._load(module, this, false) }      snapshotResult.customRequire.cache[relativeFilePath] = cachedModule    }    return cachedModule.exports  }
snapshotResult.setGlobals(    global,    process,    window,    document,    console,    global.require  )}

上記をライブラリ群の読み込み前に実行するようご注意ください。正しく動作すれば、”Snapshot cache hit: react” のような出力がコンソールに出るはずです。

サンプルプロジェクトでは、以下のような結果が得られるはずです:

やったね!あなたのアプリの依存モジュールはV8 snapshotsから読み込まれました。

アプリの初期化処理もスナップショット化する

依存モジュールの読み込みだけでなく、アプリのインスタンス生成処理もスナップショット化できます。Atomがやっているように。アプリの初期化処理の一部は、例えばユーザ設定の読み込みなどのように動的ですが、静的な処理があるでしょう。それらはスナップショット化可能です。事前に初期化タスクを終えた結果をスナップショットに含ませることで、アプリの起動速度は更に高速化出来ます。しかしそれはあなたのコードベースに依存します。例えば、Reactコンポーネント群の読み込みなどは効果がありそうですね。

Read more

Keychron K2 HEを無刻印化する手順

Keychron K2 HEを無刻印化する手順

どうもTAKUYAです。KeychronさんにK2 HEをお願いしたら音速で送ってくれたので、無刻印化してみました。どうやったのか過程をシェアします。 Unboxing 上はKeychron Q1です。これは3年間ぐらい使ってきました。キーキャップが若干くたびれていますね。でも問題なく今まで使えていました。そろそろ飽きてきたので新しいキーボードを試したいと思い、前から気になっていたK2 HEを試すことにしました(写真下)。 Amazon | 【国内正規品】Keychron K2 HE ラピッドトリガー ワイヤレス カスタムキーボード、ホールエフェクトGateronダブルレール・マグネットスイッチ、2.4GHz・Bluetooth無線対応、QMKプログラム可能、アルミ+ウッドフレーム、USレイアウト、RGBライト、Mac Windows Linux対応 (ブラック) | Keychron | パソコン用キーボード 通販【国内正規品】Keychron K2 HE ラピッドトリガー ワイヤレス カスタムキーボード、ホールエフェクトGateronダブルレール・マグネットスイッチ、

By Takuya Matsuyama
ノート駆動AIコーディング術の提案

ノート駆動AIコーディング術の提案

どうもTAKUYAです。みなさんはAIエージェントを普段のコーディングで活用されていますか。ちょっと面白いワークフローを思いついたのでシェアします。それは、ノート駆動のエージェンティック・コーディング・ワークフローです。最近Claude Codeのプランモードを使っていたら、ターミナル内で生成されたプランを読むのが辛かったんです。それで、じゃあMarkdownノートアプリであるInkdropをプランの保存先バックエンドとして使えば解決するんじゃないかと思って、 試してみました。こちらがそのデモです(英語): こちらがClaude Codeの設定ファイル群です: GitHub - inkdropapp/note-driven-agentic-coding-workflow at devas.lifeComplete Claude Code configuration collection - agents, skills, hooks, commands, rules, MCPs. Battle-tested configs from an Anthropic hackathon w

By Takuya Matsuyama
2025年個人開発活動の振り返り

2025年個人開発活動の振り返り

どうもTAKUYAです。もう1月も半ばに差し掛かっているけど、2025年の自分の活動の振り返りをしたい。去年を一言で言うなら、本厄を満喫した年だった。 厄年とは、人生の節目にあたって、体調不良や災難が起こりやすいと経験的に言われる年齢のこと。数え年で42歳、確かにもう若さに任せた事は出来ないなと痛感した年だった。(ところであなたの国ではこのような年はありますか?) 夏に体調を崩して2~3ヶ月動けなくなった 暖かくなり花粉が飛び出した頃に、持病のアトピーが悪化しだして、まともに生活出来なくなってしまった。酷さで言うと、2019年に脱ステした時と同じぐらい。 脱ステに無事成功したから、この地獄は二度と味わうことはないだろうと高を括っていたが、まさか7年後にまた味わうとは思わなかった。当時の独身時代と違い、妻も子供もいる中で、周りに多大な迷惑をかける事となった。夏の子供との思い出が全く無い。悲しい。 現在はQoLもほとんど元の状態まで復活できた。写真を撮って症状の変化を記録したので、機会があればシェアしたい。食事療法など色々試したが、結局歩くのが一番自分に効いた。それ以来、一日一万歩

By Takuya Matsuyama
書いて、歩け!なぜノートアプリはシンプルで充分なのか

書いて、歩け!なぜノートアプリはシンプルで充分なのか

どうもTAKUYAです。今回はノートやメモから新しい発想を生むための考え方についてシェアします。 自分はシンプルさをウリにした開発者向けのMarkdownアプリInkdropを作っています。なので、どうしても「ノートアプリの作者」としてのポジショントークが含まれてしまいますが、逆に言えば、「ノートアプリを約10年間作り続けてきた人間が、どうやってアイデアを生み出しているのか」 という実際的な体験談として読んでもらえれば幸いです。 結論から言うと、僕は「アプリ上でノート同士を連携させる必要はない。繋げるのはあなたの脳だ」と考えています。本稿では、ノートアプリの機能に溺れずユニークなアイデアを考え出すために僕が実践している事をシェアします。 TL;DR * ノート整理に時間をかけるな。グループ化で充分だ * すごい人はアイデアが「降りてくる」のを待つ * プログラミング × 料理動画 という有機的な掛け合わせ * ノートは「忘れる」ために書く * 歩け! ノート整理に時間をかけるな。グループ化で充分だ 巷ではZettelkastenなどが流行っているようですね。これ

By Takuya Matsuyama