ブラウザにデータを保存する方法はいくつかありますが、容量制限が厳しい localStorage
では不十分なケースも多いです。
そこで、より大容量で柔軟な IndexedDB を使い、シンプルな キー・バリュー保存関数を作ってみました。
このコードを使えば、以下のようにデータを保存・取得できます。
await putData('greeting', 'こんにちは');
const v = await getData('greeting');
console.log(v); => "こんにちは"
デモページ
実際に動作するデモはこちらです。
👉 https://uni928.github.io/InnededDBTest/
実装コード
下記コードを JavaScript に挿入すれば、putData('key', value)
で保存、getData('key')
で取得できるようになります。
削除やキー一覧取得の便利関数も含めています。
// ======== 設定(決め打ち) ========
const DB_NAME = 'AppKVDB'; // DB名(固定)
const DB_VERSION = 1; // スキーマ更新時に上げる
const STORE_NAME = 'kv'; // オブジェクトストア名(固定)
// 暗号化設定(デモ用:固定パスフレーズ & ソルト)
const PASSPHRASE = 'AppKVDB#FixedKey#2025-09'; // 決め打ちの暗号化キー(パスフレーズ)
const SALT_STR = 'AppKVDB::kv::salt-v1'; // 決め打ちソルト(変更すると復号不可に注意)
const PBKDF2_ITERATIONS = 150_000; // 反復回数(体感バランス用)
const AES_KEY_LENGTH = 256; // 256-bit AES
const GCM_IV_BYTES = 12; // AES-GCM の推奨 IV 長
// ======== 基本ユーティリティ ========
function ensureSupport(){
if(!('indexedDB' in window)) throw new Error('このブラウザは IndexedDB をサポートしていません。');
if(!window.crypto?.subtle) throw new Error('このブラウザは WebCrypto(SubtleCrypto) をサポートしていません。');
}
function openDB(){
ensureSupport();
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if(!db.objectStoreNames.contains(STORE_NAME)){
db.createObjectStore(STORE_NAME, { keyPath: 'key' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
req.onblocked = () => console.warn('DB更新がブロックされています。古いタブを閉じてください');
});
}
// 取引(tx)の完了/失敗をPromise化
function awaitTx(tx, db){
return new Promise((resolve, reject) => {
tx.oncomplete = () => { try{ db && db.close(); } catch{} resolve(); };
tx.onerror = () => { try{ db && db.close(); } catch{} reject(tx.error); };
tx.onabort = () => { try{ db && db.close(); } catch{} reject(tx.error || new Error('Transaction aborted')); };
});
}
// ======== base64 ヘルパ ========
function bufToB64(buf){
const bytes = new Uint8Array(buf);
let bin = '';
for(let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function b64ToBuf(b64){
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for(let i=0;i<bin.length;i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
// ======== キー導出(PBKDF2 -> AES-GCM) ========
let _cachedAesKey = null;
async function getAesKey(){
if(_cachedAesKey) return _cachedAesKey;
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(PASSPHRASE), 'PBKDF2', false, ['deriveKey']
);
_cachedAesKey = await crypto.subtle.deriveKey(
{ name:'PBKDF2', salt: enc.encode(SALT_STR), iterations: PBKDF2_ITERATIONS, hash:'SHA-256' },
keyMaterial,
{ name:'AES-GCM', length: AES_KEY_LENGTH },
false,
['encrypt','decrypt']
);
return _cachedAesKey;
}
// ======== 暗号化 / 復号 ========
async function encryptValue(value){
// value を JSON 文字列にシリアライズして暗号化
const key = await getAesKey();
const iv = crypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
const enc = new TextEncoder();
const plaintext = enc.encode(JSON.stringify(value));
const ciphertext = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, key, plaintext);
return {
alg: 'AES-GCM',
iv_b64: bufToB64(iv.buffer),
data_b64: bufToB64(ciphertext),
};
}
async function decryptValue(record){
// 平文フォーマット(後方互換):{ key, value, updatedAt }
if (record && 'value' in record && !('data_b64' in record)) {
return record.value;
}
// 暗号フォーマット:{ key, alg, iv_b64, data_b64, updatedAt }
if (!record || !record.data_b64 || !record.iv_b64) return undefined;
const key = await getAesKey();
const iv = new Uint8Array(b64ToBuf(record.iv_b64));
const data= b64ToBuf(record.data_b64);
const decrypted = await crypto.subtle.decrypt({ name:'AES-GCM', iv }, key, data);
const dec = new TextDecoder();
return JSON.parse(dec.decode(new Uint8Array(decrypted)));
}
// ======== 公開API(暗号化対応) ========
async function putData(key, value){
if (typeof key !== 'string' || !key) {
throw new Error('key は非空の文字列で指定してください');
}
// 1) 先に重い処理(暗号化など)を済ませる
const sealed = await encryptValue(value);
// 2) それからトランザクションを開始し、すぐにリクエストを発行
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put({ key, ...sealed, updatedAt: new Date().toISOString() });
// 3) トランザクション完了を待つ
await awaitTx(tx, db);
}
async function getData(key){
if(typeof key !== 'string' || !key) throw new Error('key は非空の文字列で指定してください');
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.get(key);
const record = await new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result || undefined);
req.onerror = () => reject(req.error);
});
db.close();
if(record === undefined) return undefined;
return await decryptValue(record);
}
async function deleteData(key){
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(key);
await awaitTx(tx, db);
}
async function getAllKeys(){
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAllKeys();
const keys = await new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
db.close();
return keys;
}
// グローバル公開
window.putData = putData;
window.getData = getData;
window.deleteData = deleteData;
window.getAllKeys = getAllKeys;
使い方
非同期処理なので await
を付けて呼び出します。モジュールスクリプトで使う場合は、
トップレベルで await
が使えるか、即時実行関数にしてください。
(async () => {
await putData('score', { name: 'Ichiro', point: 99 });
console.log(await getData('score'));
=> { name: "Ichiro", point: 99 }
})();
注意点
- IndexedDB はほとんどのモダンブラウザで動作します(古い IE は非対応)。
- 大量データやバイナリも保存可能ですが、ブラウザごとにストレージ制限があります。
- タブをまたいで同時にスキーマ変更すると「blocked」イベントが出ることがあります。
これで IndexedDB を使った「簡単キー・バリュー保存」がすぐに試せます。
ぜひプロジェクトに組み込んでみてください。 🚀
データ永続性についての注意
IndexedDB は便利ですが、ストレージ不足などの理由で ブラウザが古いデータを自動的に削除する可能性があります。
そのため、家計簿や日記のような「失ってはいけないデータ」を扱う場合は、
- ファイルとしてエクスポート/インポートできる機能をつける
- 定期的にバックアップを取るようユーザーに促す
といった仕組みを組み合わせることをおすすめします。
追記1
記事を上げた後に知ったのですが、localForage というラッパーライブラリは script タグで反映させる事ができるそうです。
私の研究が足りないため、こちらの方法に関しては本当に動作するのか、私は分かっていませんが、こちらも検討頂けると良いと思われます。コードは簡単そうです。
<script src="https://cdnjs.cloudflare.com/ajax/libs/localforage/1.10.0/localforage.min.js"></script>
<script>
(async () => {
await localforage.setItem('greeting', 'こんにちは');
const value = await localforage.getItem('greeting');
console.log(value); // => "こんにちは"
})();
</script>
上記のコードを body タグで囲んだ html は動作する事は確認しています。
追記2
この記事は ChatGPT で添削しています。
生成 AI による添削が苦手な方は申し訳ありません。