0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

URL ごとに別々の TODO を持つ Chrome MV3 拡張を 150 行で書く — chrome.storage.local の単一キー設計、URL 正規化の罠、3 経路で同期する toolbar badge

0
Last updated at Posted at 2026-04-29

「あの GitHub PR で次に見るべき箇所を 3 つ」「読みかけの記事の続きどこから」を 見ているページに紐づけて覚えておきたい。フラットな TODO リストアプリでは「URL を毎回貼り付け直す」運用になって続かない。

Chrome MV3 拡張で URL ごとに別々の TODO リスト を持たせ、toolbar の badge にそのページの open 件数を出す。150 行 + 21 テスト + host_permissions ゼロで実装した話です。

page-todo の hosted playground。https://example.com/post/42 を URL に指定し、3 つの TODO (Reply with feedback / Quote the third paragraph / 完了済の Already done) が表示。下部に raw chrome.storage.local の JSON 構造が展開されている。ダークテーマ。

🧩 Demo: https://sen.ltd/portfolio/page-todo/
📦 GitHub: https://github.com/sen-ltd/page-todo

設計の出発点 — 「URL = key」

要件は単純:

  1. ユーザーがいま開いているタブの URL を見る
  2. その URL に紐づいた TODO リストを表示
  3. 件数 (open のみ) を toolbar badge にする

chrome.storage.local にどう持たせるか、これが最初の設計判断。3 通り考えました:

shape pros cons
A { "https://...": [...todos] } を直接 top-level flat、書き込み単位が key storage の上限管理が読みにくい
B { todos: { url: [...] } } の単一トップキー 全データを 1 read で取れる、onChanged listener が 1 key 一度に全データ書き込み
C URL ごとに別 key (url:https://...) 部分書き込みできる listener が複雑、storage.get(null) が必要

B を選びました。理由:

  • このユースケースの典型データサイズは 1 ユーザーで数 KB〜数十 KB。1 read/write のオーバーヘッドは無視できる
  • chrome.storage.onChanged で「todos キーが変わったら badge 再計算」の 1 行で済む
  • A は top-level に URL を散らすので、後から「全件 export」「URL 一覧」のときに storage.get(null) でゴミ込みになる
// 単一キー
const KEY = "todos";

// shape
{
  todos: {
    "https://example.com/post/42": [
      { id, text, done, created },
      ...
    ],
    "https://github.com/sen-ltd/page-todo/pulls": [...]
  }
}

URL 正規化 — ?utm_source#section を吸収する

「同じページ」をどう定義するか。生 URL を key にすると以下が別ページ扱いになる:

  • https://example.com/post/42
  • https://example.com/post/42?utm_source=newsletter
  • https://example.com/post/42#comments

これは UX として明らかに悪い。記事を Twitter から踏むと ?utm_* が付き、目次から踏むと #section が付く。同じ「post/42 の記事」として扱いたい。

正規化方針: origin + pathname のみ使う。query と hash は捨てる。

function urlKey(rawUrl) {
  if (!rawUrl) return null;
  try {
    const u = new URL(rawUrl);
    if (u.protocol === "chrome:" || u.protocol === "about:" || u.protocol === "file:") {
      return rawUrl;  // 内部ページ系はそのまま
    }
    let path = u.pathname;
    if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
    return u.origin + path;
  } catch {
    return null;
  }
}

末尾スラッシュも除去 (/post//post を合わせる)。ただし root path / は残す (空文字列にならないため)。

chrome://, about:, file:// は origin/pathname に分解する意味が薄いので 生 URL のまま key にしました。

Query が大事なケース

?id=42 がページの本体」の Web アプリ (古い PHP 製とか、SPA 以前の世代) では query を捨てるのは惜しい。仕様で対応するなら:

  • ホストごとに「query を保持する allow-list」を持つ
  • ユーザーが手動で「このページは別キーで保存」フラグを立てる

v1 では割り切って デフォルト挙動を優先。query 駆動 SPA は今どき少数派なのと、ユーザーの体感では「同じ URL に近い見た目」を 1 リストにまとめてくれた方が嬉しいので。

badge を 3 経路で同期する

toolbar の badge (open 件数) は 「いまユーザーが見ているタブ」の値 を出したい。これを変える契機は 3 つ:

  1. ユーザーが別のタブに switch したchrome.tabs.onActivated
  2. タブの URL が変わった (リンククリック、SPA 遷移) → chrome.tabs.onUpdated
  3. storage の中身が変わった (popup で TODO 追加/削除) → chrome.storage.onChanged

3 つの listener が同じ helper を呼ぶ shape に:

async function refreshBadgeForTab(tabId, url) {
  const count = await openCountFor(storage, url);
  const text = count > 0 ? String(count) : "";
  await chrome.action.setBadgeText({ tabId, text });
  if (count > 0) {
    await chrome.action.setBadgeBackgroundColor({ tabId, color: "#58a6ff" });
  }
}

chrome.tabs.onActivated.addListener(async ({ tabId }) => {
  const tab = await chrome.tabs.get(tabId);
  if (tab?.url) await refreshBadgeForTab(tabId, tab.url);
});

chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
  if (changeInfo.url || changeInfo.status === "complete") {
    if (tab?.url) await refreshBadgeForTab(tabId, tab.url);
  }
});

chrome.storage.onChanged.addListener(async (changes, area) => {
  if (area !== "local" || !changes.todos) return;
  const tabs = await chrome.tabs.query({ active: true });
  for (const t of tabs) {
    if (t.url && t.id !== undefined) await refreshBadgeForTab(t.id, t.url);
  }
});

onUpdated の判定で changeInfo.url || changeInfo.status === "complete" を見ているのが地味な工夫:

  • SPA 内部の URL 遷移は changeInfo.url だけ立つ
  • 通常のナビゲーションは最後に status: "complete" が来る

両方 carve out しないと SPA で badge が古いまま残る。

onChangedchanges.todos チェックは「自分が監視している key だけ」絞る (他の key — もし将来追加したら — のために無駄に走らない)。

単一バッファの罠 — 全件 read / 全件 write

shape B (単一キー) を選んだトレードオフは「TODO 1 つ追加でも全件 read & 全件 write」。

async function addTodo(storage, rawUrl, text) {
  const key = urlKey(rawUrl);
  const all = await loadAll(storage);   // 全 URL × 全 TODO read
  const list = all[key] || [];
  list.push(/* new todo */);
  all[key] = list;
  await saveAll(storage, all);          // 全件 write
}

5MB の chrome.storage.local の上限に対して、平均的なユーザーは数百 〜 千 TODO 程度なので read/write のコストは数 ms。実用上は無視できる。本番運用で 10,000+ TODO 持つ人が出てきたら shape C (URL 単位 key) への移行を考える。

ID は Date.now() ではなく crypto.getRandomValues() で 9 hex char:

function makeId() {
  const arr = new Uint8Array(5);
  crypto.getRandomValues(arr);
  return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("").slice(0, 9);
}

「同じミリ秒で 100 件 add しても全 ID ユニーク」を test に入れてあります — Date.now() だと同一秒で衝突するので、UI から 2 連 add するときの危険性を排除したかった。

空リストは prune する

「全項目を削除した URL key」を storage に残しておくと、長く使う人の storage が膨れる:

async function removeTodo(storage, rawUrl, id) {
  const all = await loadAll(storage);
  const list = all[key];
  list.splice(idx, 1);
  if (list.length === 0) delete all[key];   // prune
  else all[key] = list;
  await saveAll(storage, all);
}

clearDone も同じく、「open 件 0 件、done 件のみ」になった key は完全削除。

これで 1 年使っても storage が 100 件以上の URL key を持つことは稀。

テスト — chrome 不要、jsdom 不要

todos.jsstorage (chrome.storage.local 互換 shape) を 依存性注入 で受け取る純粋ロジック。テスト側は 10 行のメモリ実装を渡すだけ:

function makeStorage(initial = {}) {
  const data = JSON.parse(JSON.stringify(initial));
  return {
    data,
    async get(keys) {
      const out = {};
      const arr = Array.isArray(keys) ? keys : [keys];
      for (const k of arr) if (k in data) out[k] = data[k];
      return out;
    },
    async set(items) { Object.assign(data, items); },
  };
}

これで node --test で 21 ケース 0.08 秒。Chrome polyfill (@types/chromesinon-chrome 等) を入れない方針:

  • 拡張側で実際に使う API は chrome.storage.local.{get,set} の 2 つだけ → polyfill 入れる費用対効果が悪い
  • DOM 寄りの popup 部分は smoke test で済ませる (実際の Chrome で load unpacked して触る)

カバレッジ的には storage logic = 90% LOC を Node でフルカバー。残り (popup の DOM 操作、background SW の listener wiring) は手動 smoke test。

まとめ

  • shape B (単一トップキー) は中規模データで read/write 数の圧倒的シンプルさが効く。onChanged listener も 1 key 監視で済む
  • URL key 正規化?query#hash を捨てる、末尾スラッシュ除去、chrome:// / about: / file:// は生のまま、で 90% のケースを期待通りにできる
  • badge sync は 3 経路 (tabs.onActivated, tabs.onUpdated で URL 変化 / status complete, storage.onChanged) を同じ helper に集約
  • 空 key の prune で長期使用の storage 膨張を回避
  • 依存性注入 + chrome.storage.local 互換 mock で、Chrome polyfill なしの Node 21 テストで storage logic を完全カバー
  • 権限は storage + tabs のみ。<all_urls>host_permissions も不要

コード全文todos.js (storage CRUD)、background.js (badge sync)、popup.{html,css,js}tests/todos.test.js (21 ケース)、MIT。

ブラウザ拡張シリーズ第 2 弾。第 1 弾 #214 copy-as-md (HTML→Markdown コピー) と合わせてどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?