はじめに:なぜ「車輪の再発明」をしたのか
PinshotDriveというChrome拡張機能を個人開発しました。機能は非常にシンプルです。
指定領域の連続スクショをするChrome拡張機能です。
GitHubも興味あれば見てください。
- 事前に指定した領域を固定: 毎回範囲選択しなくていい。
- 連続撮影: ショートカットキー(Ctrl+Shift+S)でパシャパシャ撮れる。
- 自動アップロード: Googleドライブに保存される。プレビュー画面は出ない。
世の中にはスクリーンショット拡張機能が溢れていますが、既存のツールでは私の要求を満たしていませんでした。
一番の課題は撮影対象が複数ある場合にフローが冗長になることです。
- AwsesomeScreenshotを好んで使いますが必ずプレビューが開きます。
- 同期的な待機: アップロードのために1アクション取られてしまう。
これらは「親切」ですが、私の作業フローを分断してしまう状況でした。
私が求めたのは、撮影に特化したツールです。
本記事では、この要件を満たすために、Chrome拡張機能(Manifest V3)上でどのような非同期アーキテクチャを設計したか、その意思決定プロセスを共有します。
課題1:同期処理による操作ブロック
❌ Problem
初期実装はナイーブな同期処理でした。
ボタン押下 → 撮影(await) → アップロード(await) → 完了。
Google Driveへのアップロードには数秒かかります。
その間、UIは処理中のため、ユーザーは次の撮影ができません。
「たった数秒」ですが、連続撮影においては致命的なストレスです。
// 👎 Bad: ユーザーを待たせる実装
async function handleUserClick() {
const image = await captureTab(); // 撮影
// ここで3秒ブロック! UIがフリーズする
await uploadToDrive(image);
alert("完了");
}
// 👍 Good: ユーザーを待たせない実装
let executionQueue = Promise.resolve();
async function handleUserClick() {
// 1. 撮影とメモリ確保だけ先に行う(一瞬)
const image = await captureTab();
// 2. 重い処理は「裏側の行列」に積む
executionQueue = executionQueue.then(async () => {
await uploadToDrive(image);
});
// 3. アップロード完了を待たずに、即座に次へ!
}
⭕ Solution: Fire and Forget (非同期キュー)
エラーハンドリングが適切に設計できていれば、ユーザーが知りたいのは「アップロード完了」ではなく、「リクエストが受理されたこと」と設計検証フェーズを経て思うようになりました。そこで、UIスレッド(ユーザー体験)と処理スレッド(バックグラウンドロジック)を完全に分離しました。
これをしないとユーザーはショートカットを押下したのに、拡張機能のアイコンのバッジの数字がしばらく変わらないという認知的不協和に陥ってしまい、リクエストが通ったのかの実感が湧きづらいです。そのためアップロードの完了をユーザーに見せるのではなく、ショートカットの押下ができたことをユーザーに認識してもらうことにしました。
ショートカット押下時、アイコンのバッジ(数字)を即座にインクリメント。
実際の処理は Promise チェーンによるキューに追加し、ユーザーには即座に制御を返す。
// Background Service Worker
let executionQueue = Promise.resolve();
function processQueue(taskId, data) {
// 並列実行ではなく、直列実行を保証する(ファイル名の連番順序を守るため)
executionQueue = executionQueue.then(async () => {
try {
await uploadToDrive(data);
} catch (e) {
console.error(`Task ${taskId} failed`, e);
// エラーログを残すが、キュー全体は止めない
}
});
}
これにより、体感待ち時間はほぼ 0秒 になりました。
課題2:ユーザー行動とAPI実行の競合(Race Condition)
❌ Problem
非同期を導入したことで新たな問題が発生しました。
ショートカットキーを押した直後、ユーザー(私)がタブを切り替える操作を行ったことでアップロードに失敗するようになりました。
chrome.tabs.captureVisibleTab は非同期APIです。もしAPIが実行される瞬間にユーザーがタブを切り替えてしまうと、「切り替え先の画面」が撮影されてしまいます。
⭕ Solution: 即時メモリ確保
当初、「処理中はタブ移動を警告する」という実装を検討しましたが、これは悪手でした。ツールの都合でユーザー体験を制限すべきではありません。
解決策は、「撮影可動域」の最小化です。ショートカットイベントの発火直後に captureVisibleTab を最優先で実行し、画像データをBase64文字列としてメモリに確保してしまいます。
その後の「トリミング加工」や「アップロード」は、ユーザーがどのタブにいようが、ウィンドウを閉じようが、Service Workerが生きていれば実行可能です。「状態の確定」と「処理の実行」を時間的に分離することで、ユーザーの自由な操作を担保しました。
課題3:ヘッドレスな状態管理
❌ Problem
Backgroundで処理が進む際、ポップアップ(UI)が閉じていると runtime.sendMessage が届きません。ユーザーは「いま裏で何枚目がアップロードされているのか?」を知る術がありませんでした。
⭕ Solution: Single Source of Truth
状態管理をメッセージング(Push型)から、ストレージ監視(Pull/Reactive型)へ移行しました。chrome.storage.local を唯一の真実(Single Source of Truth)とし、UIはそれを反映するだけの "View" に徹します。
Background (State Updater)
// ステータス変更はストレージへの書き込みのみ
async function updateStatus(id, status) {
const logs = await getLogs();
logs[id].status = status;
await chrome.storage.local.set({ appLogs: logs });
}
Popup (Reactive View)
// ストレージの変化を監視して再描画
chrome.storage.onChanged.addListener((changes) => {
if (changes.appLogs) {
render(changes.appLogs.newValue);
}
});
これにより、ポップアップを開閉しても状態の整合性が保たれ、開いていればリアルタイムにステータスが流れていくリッチな体験が可能になりました。
課題4:不可逆な操作への恐怖
❌ Problem
高速な操作はミスを誘発します。Googleドライブにアップロードされたファイルを消すには、ブラウザでドライブを開き、ファイルを探し、削除する必要があります。この手間が心理的障壁となり、ユーザーは撮影を躊躇するようになります。
⭕ Solution: API経由のUndo
「失敗してもいい」という安心感が、最速のUXを支えます。単なるUI上のキャンセルではなく、Google Drive APIを実行してファイルをゴミ箱へ移動(trashed: true)させるUndo機能を実装しました。
async function performUndo() {
const lastFileId = state.lastUploadedId;
if (!lastFileId) return;
// 物理削除ではなくゴミ箱移動(安全策)
await driveApi.updateFile(lastFileId, { trashed: true });
// ログとバッジをロールバック
updateLogStatus('Undone');
decrementBadgeCount();
}
まとめ:個人開発におけるUXエンジニアリング
自分自身がユーザーである個人開発において、「妥協」はそのまま「ツールの死(利用停止)」に直結します。
- Latency Hiding: 非同期キューで待ち時間を隠蔽する。
- Concurrency Control: ユーザーの予測不可能な操作(タブ切替)を前提に設計する。
- State Management: ステータスの可視化と整合性を保証する。
- Error Recovery: ミスを許容する設計にする。
これらは大規模なシステム設計でも通じる原則ですが、個人開発というサンドボックスだからこそ、純粋に「気持ちの良い体験」を追求して実装に落とし込むことができました。
ソースコードはGitHubで公開しています。同様の課題を持つ開発者の参考になれば幸いです。
もし記事やツールが面白いと思っていただけたなら、いいねやコメントをいただけると嬉しいです。


