1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザに簡単保存!IndexedDB で putData/getData を実装する

Last updated at Posted at 2025-09-05

ブラウザにデータを保存する方法はいくつかありますが、容量制限が厳しい 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 による添削が苦手な方は申し訳ありません。

1
1
1

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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?