「あの GitHub PR で次に見るべき箇所を 3 つ」「読みかけの記事の続きどこから」を 見ているページに紐づけて覚えておきたい。フラットな TODO リストアプリでは「URL を毎回貼り付け直す」運用になって続かない。
Chrome MV3 拡張で URL ごとに別々の TODO リスト を持たせ、toolbar の badge にそのページの open 件数を出す。150 行 + 21 テスト +
host_permissionsゼロで実装した話です。
🧩 Demo: https://sen.ltd/portfolio/page-todo/
📦 GitHub: https://github.com/sen-ltd/page-todo
設計の出発点 — 「URL = key」
要件は単純:
- ユーザーがいま開いているタブの URL を見る
- その URL に紐づいた TODO リストを表示
- 件数 (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/42https://example.com/post/42?utm_source=newsletterhttps://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 つ:
-
ユーザーが別のタブに switch した →
chrome.tabs.onActivated -
タブの URL が変わった (リンククリック、SPA 遷移) →
chrome.tabs.onUpdated -
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 が古いまま残る。
onChanged の changes.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.js は storage (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/chrome、sinon-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 数の圧倒的シンプルさが効く。
onChangedlistener も 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 コピー) と合わせてどうぞ。
