🔨
Vite + TypeScript で作る Chrome 拡張(MV3)Step3
chrome.storage の“汎用ラッパ”を作る
Step3 ではデータ保存(chrome.storage)が汎用的に使用できるモジュールを追加していきます。
Options に簡単なフォームを用意して greeting と age を保存・読込・削除できるようします。
また、Popup で保存済みの値を表示し、変更を自動反映(watch)されるようにします。
これで基本的な storage 操作がマスター出来るようになる想定です。
1. 汎用ストレージラッパを追加
sync、local、session それぞれの storage の操作を汎用的に行えるラッパを作成します。
src/lib/storage.ts(新規)
export type Area = "sync" | "local" | "session";
type Change = chrome.storage.StorageChange;
export type StorageNS = {
area: Area;
prefix: string;
get: <T>(key: string, defaultValue?: T) => Promise<T>; // 非同期Promise化で await chrome.storage.* を素直に使えるようにする
set: <T>(key: string, value: T) => Promise<void>;
remove: (key: string | string[]) => Promise<void>;
getAll: () => Promise<Record<string, unknown>>;
clear: () => Promise<void>; // prefix 配下だけを消す(prefix未指定なら全消去)
watch: <T>(key: string, cb: (curr: T, prev: T) => void) => () => void; // 解除関数を返す
};
// 領域選択(sync/local/session)を 1 か所で切り替え
const ensureArea = (area: Area): chrome.storage.StorageArea =>
chrome.storage[area];
export const createStorage = (area: Area = "sync", prefix = ""): StorageNS => {
const store = ensureArea(area);
// 「実キーを作る」関数「自分の名前空間か?」の判定。
const fkey = (k: string) => (prefix ? `${prefix}:${k}` : k);
// 「自分の名前空間か?」の判定。
const isMine = (k: string) => (prefix ? k.startsWith(`${prefix}:`) : true);
const get = async <T>(key: string, defaultValue?: T): Promise<T> => {
const res = await store.get(fkey(key));
const val = res[fkey(key)];
return val === undefined ? (defaultValue as T) : (val as T);
};
const set = async <T>(key: string, value: T): Promise<void> => {
await store.set({ [fkey(key)]: value });
};
const remove = async (key: string | string[]): Promise<void> => {
const keys = Array.isArray(key) ? key.map(fkey) : fkey(key);
await store.remove(keys);
};
const getAll = async (): Promise<Record<string, unknown>> => {
const all = await store.get(null);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(all)) {
if (isMine(k)) {
const nk = prefix ? k.slice(prefix.length + 1) : k;
out[nk] = v;
}
}
return out;
};
// **名前空間(prefix)**でキー衝突を防ぎ、clear() で“自分の設定だけ”消せる
const clear = async (): Promise<void> => {
if (!prefix) {
await store.clear();
return;
}
const all = await store.get(null);
const keys = Object.keys(all).filter(isMine);
if (keys.length) await store.remove(keys);
};
// chrome.storage.onChanged をラップ。監視対象キーだけを拾い、解除関数を返すので unload で外せます(リーク防止)。
const watch = <T>(key: string, cb: (curr: T, prev: T) => void) => {
const target = fkey(key);
const handler = (changes: { [k: string]: Change }, areaName: string) => {
if (areaName !== area) return;
if (changes[target]) {
const c = changes[target];
cb(c.newValue as T, c.oldValue as T);
}
};
chrome.storage.onChanged.addListener(handler);
return () => chrome.storage.onChanged.removeListener(handler);
};
return { area, prefix, get, set, remove, getAll, clear, watch };
};
export const storage = {
// sync … 端末間で同期。容量制限が小さいので“設定”向け。
sync: (prefix = ""): StorageNS => createStorage("sync", prefix),
// local … 端末ローカルに保存。容量は比較的大きい(ログ/キャッシュ等)。
local: (prefix = ""): StorageNS => createStorage("local", prefix),
// session … 拡張の“セッション”限りの揮発。永続化されない。
session: (prefix = ""): StorageNS => createStorage("session", prefix),
} as const;
2. Options に“保存 UI”を追加
storage へ書き込む & 出力 のための入力フォームを作成します。
save ボタンで保存、load ボタンで読み込み、clear ボタンで消去が行えます。
src/options.html(置き換え)
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Options</title>
<body
style="max-width:720px;margin:16px auto;font:14px/1.5 system-ui,sans-serif;"
>
<h1 style="font-size:18px;margin:0 0 12px;">Options</h1>
<form id="form" style="display:grid;gap:10px;max-width:480px;">
<label>
Greeting:
<input id="greeting" type="text" placeholder="入力して下さい" />
</label>
<label>
Age:
<input id="age" type="number" min="0" step="1" />
</label>
<div style="display:flex;gap:8px;">
<button id="save" type="submit">Save</button>
<button id="load" type="button">Load</button>
<button id="clear" type="button">Clear (namespace)</button>
</div>
<small id="status" style="color:#555;"></small>
</form>
<script type="module" src="./options.ts"></script>
</body>
</html>
src/options.ts(置き換え)
import { storage } from "./lib/storage";
// 例: 必要なら別NSを使う => const st = storage.sync("setting");
const st = storage.sync("app"); // これで“同期設定のapp名前空間”が一目瞭然
const $ = <T extends HTMLElement>(sel: string) =>
document.querySelector(sel) as T | null;
const setStatus = (s: string) => {
const el = $("#status");
if (el) el.textContent = s;
};
const loadForm = async (): Promise<void> => {
const greeting = await st.get<string>("greeting", "");
const age = await st.get<number>("age", 0);
($("#greeting") as HTMLInputElement).value = greeting;
($("#age") as HTMLInputElement).value = String(age);
};
const saveForm = async (): Promise<void> => {
const g = ($("#greeting") as HTMLInputElement).value || "";
const a = Number(($("#age") as HTMLInputElement).value || 0);
await st.set<string>("greeting", g);
await st.set<number>("age", Number.isFinite(a) ? a : 0);
};
const main = async (): Promise<void> => {
await loadForm();
$("#form")?.addEventListener("submit", async (e) => {
e.preventDefault();
await saveForm();
setStatus("Saved.");
setTimeout(() => setStatus(""), 1200);
});
$("#load")?.addEventListener("click", async () => {
await loadForm();
setStatus("Loaded.");
setTimeout(() => setStatus(""), 1200);
});
$("#clear")?.addEventListener("click", async () => {
await st.clear();
await loadForm();
setStatus("Cleared namespace.");
setTimeout(() => setStatus(""), 1200);
});
};
main().catch(console.error);
3. Popup に“表示&自動反映”を追加
ライブ反映確認用に表示エリアを追加します。
popup.ts ではStorageNS.watch
関数を使って変更を検知=>即時反映が行えるようにします。
src/popup.html(追記のみ・既存に合わせて)
<!-- 省略(既存) -->
<div id="store-view" style="margin-top:8px;">
<div><b>Greeting</b>: <span id="v-greeting">-</span></div>
<div><b>Age</b>: <span id="v-age">-</span></div>
</div>
<!-- 省略(既存) -->
src/popup.ts(置き換え)
// import 追加
import { storage } from "./lib/storage";
const $ = <T extends HTMLElement>(sel: string) =>
document.querySelector(sel) as T | null;
const msg = (text: string) => {
const el = $("#msg");
if (el) el.textContent = text;
};
// stとrender追加
const st = storage.sync("app");
const render = async (): Promise<void> => {
const g = await st.get<string>("greeting", "");
const n = await st.get<number>("age", 0);
const gEl = $("#v-greeting");
const nEl = $("#v-age");
if (gEl) gEl.textContent = g;
if (nEl) nEl.textContent = String(n);
};
const main = async (): Promise<void> => {
// 既存のボタンのイベントはそのまま
$("#btn-open-options")?.addEventListener("click", () => {
chrome.runtime.openOptionsPage();
});
$("#btn-ping-bg")?.addEventListener("click", async () => {
const res = await chrome.runtime.sendMessage({ type: "PING" });
msg(`BG replied: ${JSON.stringify(res)}`);
});
$("#btn-log-tab")?.addEventListener("click", async () => {
// activeTab 権限で現在アクティブなタブ情報を取得(ユーザー操作後に限定)
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
console.log("[popup] active tab:", {
id: tab?.id,
url: tab?.url,
title: tab?.title,
});
msg(`Active tab: ${tab?.title ?? "N/A"}`);
});
// 以下追加
// 初期描画
await render();
// 変更を自動反映(Optionsで保存されたらPopupに即反映)
const off1 = st.watch<string>("greeting", async () => render());
const off2 = st.watch<number>("age", async () => render());
// Popupが閉じられる際のクリーンアップ(任意)
window.addEventListener("unload", () => {
off1();
off2();
});
};
main().catch((err) => console.error(err));
4. Content Script での参照(任意・ログだけ)
src/content.ts
import { appStorage } from "./lib/storage";
(async () => {
const g = await appStorage.get<string>("greeting", "Hello");
console.log("[ct] content loaded:", location.href, "greeting=", g);
})();
※ Content Script は classic 扱いですが、CRXJS のバンドルにより import を書けます(実行時は単一ファイル化)。
5. テスト手順
- 前提起動
- npm run dev 実行 → chrome://extensions で dist を読み込み(初回のみ)→ 以後は ⟳ リロード。
- Popup を開いたまま&Options を別タブで開いたままにしておく(Popup の watch でライブ反映を見るため)。
- 初期値の確認
- Options の入力欄:Greeting = Hello、Daily Limit = 3(既定値)。
- Popup の表示:Greeting: Hello / Daily Limit: 3。
- 任意のページのコンソール(Content):[ct] ... greeting=Hello が出る。
- Save の確認
- Options で Greeting=こんにちは、Daily Limit=5 → Save。
- ステータスに「Saved.」→ Popup 側が即座に こんにちは / 5 に更新(watch 動作)。
- 新しいページを開くと Content ログにも greeting=こんにちは。
- Load の確認
- Options で入力を一旦わざと変更(例:Greeting=xxx、Daily Limit=999)※まだ Save しない。
- Load を押す → フォームが保存済みの値(こんにちは / 5)に戻る。
- ステータスに「Loaded.」が出る(Popup 側は値が変わっていないので表示はそのまま)。
- Clear(名前空間クリア) の確認
- Clear を押す → フォームが初期状態(空文字 / 0)に戻る
お疲れ様でした。それでは Step 4: 「設定を壊れにくく(型・バリデーション・マイグレーション)」(予定)に続きます。
Discussion