🔨

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. テスト手順

  1. 前提起動
    1. npm run dev 実行 → chrome://extensions で dist を読み込み(初回のみ)→ 以後は ⟳ リロード。
    2. Popup を開いたまま&Options を別タブで開いたままにしておく(Popup の watch でライブ反映を見るため)。
  2. 初期値の確認
    1. Options の入力欄:Greeting = Hello、Daily Limit = 3(既定値)。
    2. Popup の表示:Greeting: Hello / Daily Limit: 3。
    3. 任意のページのコンソール(Content):[ct] ... greeting=Hello が出る。
  3. Save の確認
    1. Options で Greeting=こんにちは、Daily Limit=5 → Save。
    2. ステータスに「Saved.」→ Popup 側が即座に こんにちは / 5 に更新(watch 動作)。
    3. 新しいページを開くと Content ログにも greeting=こんにちは。
  4. Load の確認
    1. Options で入力を一旦わざと変更(例:Greeting=xxx、Daily Limit=999)※まだ Save しない。
    2. Load を押す → フォームが保存済みの値(こんにちは / 5)に戻る。
    3. ステータスに「Loaded.」が出る(Popup 側は値が変わっていないので表示はそのまま)。
  5. Clear(名前空間クリア) の確認
    1. Clear を押す → フォームが初期状態(空文字 / 0)に戻る

お疲れ様でした。それでは Step 4: 「設定を壊れにくく(型・バリデーション・マイグレーション)」(予定)に続きます。
https://github.com/Igusaya/chrome-extension-start/tree/vite%2Bts_chrome_ex_3

Discussion