はじめに#
Webアプリケーション開発では、ユーザーデータやアプリケーション状態をブラウザ側に保存する場面が頻繁に発生します。ログイン状態の維持、フォーム入力の一時保存、オフライン時のデータキャッシュなど、用途に応じて最適なクライアントサイドストレージを選択する必要があります。
本記事では、Cookie、localStorage、sessionStorage、IndexedDBという4つの主要なクライアントサイドストレージについて、それぞれの特性、容量制限、セキュリティ上の考慮点を詳しく解説します。さらに、PWA(Progressive Web App)開発で重要となるオフライン対応アプリでのIndexedDB活用例も紹介します。
対象読者#
- フロントエンドでデータ管理を行う開発者
- PWA開発に興味があるエンジニア
- クライアントサイドストレージの使い分けを理解したい方
実行環境#
- モダンブラウザ(Chrome 80以上、Firefox 78以上、Safari 14以上、Edge 80以上)
- Node.js環境での実行は対象外(ブラウザAPI)
クライアントサイドストレージの種類と特性#
ブラウザには複数のデータ保存方法が用意されています。それぞれの特性を理解することで、適切なストレージを選択できます。
ストレージ比較表#
| 特性 |
Cookie |
localStorage |
sessionStorage |
IndexedDB |
| 容量制限 |
約4KB |
約5MB |
約5MB |
数百MB以上 |
| データ形式 |
文字列 |
文字列 |
文字列 |
オブジェクト、Blob等 |
| 有効期限 |
設定可能 |
永続 |
セッション終了まで |
永続 |
| サーバー送信 |
自動送信 |
送信なし |
送信なし |
送信なし |
| API |
同期 |
同期 |
同期 |
非同期 |
| Web Worker対応 |
不可 |
不可 |
不可 |
可能 |
各ストレージのアーキテクチャ#
graph TB
subgraph "ブラウザストレージ"
subgraph "小容量・シンプル"
A[Cookie<br/>4KB]
B[localStorage<br/>5MB]
C[sessionStorage<br/>5MB]
end
subgraph "大容量・構造化"
D[IndexedDB<br/>数百MB以上]
end
end
E[Webアプリケーション] --> A
E --> B
E --> C
E --> D
A -->|自動送信| F[サーバー]
style A fill:#ffcc00,color:#000
style B fill:#4CAF50,color:#fff
style C fill:#2196F3,color:#fff
style D fill:#9C27B0,color:#fffCookieの特性と適切な使用場面#
Cookieは最も古くから存在するクライアントサイドストレージです。HTTPリクエストごとにサーバーへ自動送信される特性を持ちます。
Cookieの基本操作#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// Cookieの設定
document.cookie = "username=tanaka; max-age=86400; path=/; secure; samesite=strict";
// Cookieの読み取り
function getCookie(name) {
const cookies = document.cookie.split("; ");
for (const cookie of cookies) {
const [key, value] = cookie.split("=");
if (key === name) {
return decodeURIComponent(value);
}
}
return null;
}
// 使用例
const username = getCookie("username");
console.log(username); // "tanaka"
// Cookieの削除(有効期限を過去に設定)
document.cookie = "username=; max-age=0; path=/";
|
Cookieの適切な使用場面#
- セッションID管理(HttpOnly属性と併用)
- サーバーサイドで参照が必要な設定値
- CSRF対策トークン
Cookieの制限事項#
- 容量が約4KBと非常に小さい
- 毎回のHTTPリクエストに含まれるためパフォーマンスに影響
- JavaScriptからの操作APIが直感的でない
Web Storage(localStorage / sessionStorage)の仕組み#
Web Storage APIは、Cookieの制限を解消するために設計されたシンプルなキーバリューストアです。localStorageとsessionStorageの2種類があり、データの永続性に違いがあります。
localStorageとsessionStorageの違い#
sequenceDiagram
participant User as ユーザー
participant Tab1 as タブ1
participant Tab2 as タブ2
participant LS as localStorage
participant SS as sessionStorage
User->>Tab1: データ保存
Tab1->>LS: setItem("key", "value")
Tab1->>SS: setItem("key", "value")
Note over LS: 同一オリジンで共有
Note over SS: タブごとに独立
User->>Tab2: 新しいタブを開く
Tab2->>LS: getItem("key")
LS-->>Tab2: "value" を返す
Tab2->>SS: getItem("key")
SS-->>Tab2: null を返す
User->>Tab1: タブを閉じる
Note over SS: データ消去
Note over LS: データ維持Web Storageの基本操作#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// localStorageへの保存
localStorage.setItem("theme", "dark");
localStorage.setItem("language", "ja");
// localStorageからの取得
const theme = localStorage.getItem("theme");
console.log(theme); // "dark"
// 特定のキーを削除
localStorage.removeItem("theme");
// すべてのデータを削除
localStorage.clear();
// 保存されているキーの数を取得
console.log(localStorage.length);
// キー名を取得(インデックス指定)
const keyName = localStorage.key(0);
|
オブジェクトや配列の保存#
Web Storageは文字列のみを保存できるため、オブジェクトや配列はJSON形式に変換する必要があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// オブジェクトの保存
const userSettings = {
theme: "dark",
fontSize: 16,
notifications: true
};
localStorage.setItem("userSettings", JSON.stringify(userSettings));
// オブジェクトの取得
const savedSettings = JSON.parse(localStorage.getItem("userSettings"));
console.log(savedSettings.theme); // "dark"
// 配列の保存
const recentSearches = ["JavaScript", "TypeScript", "React"];
localStorage.setItem("recentSearches", JSON.stringify(recentSearches));
// 配列の取得
const searches = JSON.parse(localStorage.getItem("recentSearches"));
console.log(searches); // ["JavaScript", "TypeScript", "React"]
|
ストレージイベントによるタブ間同期#
localStorageはstorageイベントを通じて、同一オリジンの別タブ間でデータ変更を検知できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// 別タブでのlocalStorage変更を検知
window.addEventListener("storage", (event) => {
console.log("ストレージが変更されました");
console.log("キー:", event.key);
console.log("古い値:", event.oldValue);
console.log("新しい値:", event.newValue);
console.log("URL:", event.url);
// テーマ変更の同期例
if (event.key === "theme") {
applyTheme(event.newValue);
}
});
function applyTheme(themeName) {
document.documentElement.setAttribute("data-theme", themeName);
}
|
Web Storageの適切な使用場面#
| ストレージ |
適切な使用場面 |
具体例 |
| localStorage |
永続的なユーザー設定 |
テーマ設定、言語設定、表示オプション |
| localStorage |
キャッシュデータ |
API応答のキャッシュ、計算結果の保存 |
| sessionStorage |
一時的なフォームデータ |
マルチステップフォームの途中データ |
| sessionStorage |
ページ間の状態引き継ぎ |
検索条件、フィルタ設定 |
IndexedDBの仕組みと基本操作#
IndexedDBは、ブラウザ内で動作する低レベルのNoSQLデータベースです。Web Storageでは対応できない大容量データや複雑なクエリが必要な場合に使用します。
IndexedDBの主要概念#
graph TB
subgraph "IndexedDB構造"
A[Database] --> B[Object Store 1]
A --> C[Object Store 2]
B --> D[Index 1]
B --> E[Index 2]
B --> F[レコード群]
C --> G[Index 3]
C --> H[レコード群]
end
I[トランザクション] --> B
I --> C
style A fill:#9C27B0,color:#fff
style B fill:#4CAF50,color:#fff
style C fill:#4CAF50,color:#fff
style I fill:#FF9800,color:#fff
| 概念 |
説明 |
| Database |
データベース全体。アプリケーションごとに1つ以上作成可能 |
| Object Store |
RDBMSのテーブルに相当。オブジェクトを保存する領域 |
| Index |
高速検索のためのインデックス。特定のプロパティで検索可能 |
| Transaction |
データ操作の単位。読み取り専用または読み書き可能 |
| Cursor |
レコードを順番に処理するためのポインタ |
IndexedDBの基本操作#
IndexedDBはすべての操作が非同期で行われます。以下に基本的なCRUD操作を示します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
// データベースを開く
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("MyAppDB", 1);
// データベース作成またはバージョンアップ時
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Object Storeが存在しない場合は作成
if (!db.objectStoreNames.contains("users")) {
const store = db.createObjectStore("users", { keyPath: "id", autoIncrement: true });
store.createIndex("email", "email", { unique: true });
store.createIndex("name", "name", { unique: false });
}
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
// データの追加
async function addUser(user) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(["users"], "readwrite");
const store = transaction.objectStore("users");
const request = store.add(user);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// データの取得
async function getUser(id) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(["users"], "readonly");
const store = transaction.objectStore("users");
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// データの更新
async function updateUser(user) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(["users"], "readwrite");
const store = transaction.objectStore("users");
const request = store.put(user);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// データの削除
async function deleteUser(id) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(["users"], "readwrite");
const store = transaction.objectStore("users");
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
|
使用例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
// 実行例
async function main() {
try {
// ユーザーを追加
const userId = await addUser({
name: "田中太郎",
email: "tanaka@example.com",
createdAt: new Date()
});
console.log("追加されたユーザーID:", userId);
// ユーザーを取得
const user = await getUser(userId);
console.log("取得したユーザー:", user);
// ユーザーを更新
user.name = "田中次郎";
await updateUser(user);
console.log("ユーザーを更新しました");
// ユーザーを削除
await deleteUser(userId);
console.log("ユーザーを削除しました");
} catch (error) {
console.error("エラーが発生しました:", error);
}
}
main();
|
インデックスを使用した検索#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
// メールアドレスでユーザーを検索
async function getUserByEmail(email) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(["users"], "readonly");
const store = transaction.objectStore("users");
const index = store.index("email");
const request = index.get(email);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 全ユーザーを取得
async function getAllUsers() {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(["users"], "readonly");
const store = transaction.objectStore("users");
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// カーソルを使用した範囲検索
async function getUsersInRange(minId, maxId) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(["users"], "readonly");
const store = transaction.objectStore("users");
const range = IDBKeyRange.bound(minId, maxId);
const request = store.openCursor(range);
const results = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
|
各ストレージの容量制限と削除基準#
ブラウザのストレージ容量はブラウザやデバイスによって異なります。容量制限を超えた場合の挙動も理解しておく必要があります。
ブラウザ別の容量制限目安#
| ブラウザ |
localStorage |
IndexedDB |
| Chrome |
約5MB |
ディスク容量の最大80% |
| Firefox |
約5MB |
ディスク容量の最大50% |
| Safari |
約5MB |
約1GB(ユーザー許可で拡張可能) |
| Edge |
約5MB |
ディスク容量の最大80% |
容量確認と見積もり#
StorageManager APIを使用して、利用可能な容量と使用量を確認できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
// ストレージ使用量の確認
async function checkStorageQuota() {
if ("storage" in navigator && "estimate" in navigator.storage) {
const estimate = await navigator.storage.estimate();
const usedMB = (estimate.usage / (1024 * 1024)).toFixed(2);
const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);
const percentUsed = ((estimate.usage / estimate.quota) * 100).toFixed(2);
console.log(`使用量: ${usedMB} MB`);
console.log(`上限: ${quotaMB} MB`);
console.log(`使用率: ${percentUsed}%`);
return {
usage: estimate.usage,
quota: estimate.quota,
percentUsed: parseFloat(percentUsed)
};
}
console.warn("StorageManager APIがサポートされていません");
return null;
}
// 永続ストレージのリクエスト
async function requestPersistentStorage() {
if ("storage" in navigator && "persist" in navigator.storage) {
const isPersisted = await navigator.storage.persisted();
if (!isPersisted) {
const granted = await navigator.storage.persist();
if (granted) {
console.log("永続ストレージが許可されました");
} else {
console.log("永続ストレージは許可されませんでした");
}
return granted;
}
console.log("すでに永続ストレージが有効です");
return true;
}
console.warn("永続ストレージAPIがサポートされていません");
return false;
}
|
容量超過時の対処#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
// localStorageへの安全な保存
function safeSetItem(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch (error) {
if (error.name === "QuotaExceededError") {
console.error("ストレージ容量が不足しています");
// 古いデータを削除するなどの対処
cleanupOldData();
return false;
}
throw error;
}
}
// 古いデータのクリーンアップ
function cleanupOldData() {
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith("cache_")) {
keysToRemove.push(key);
}
}
// 古いキャッシュを削除
keysToRemove.forEach(key => localStorage.removeItem(key));
console.log(`${keysToRemove.length}件のキャッシュを削除しました`);
}
|
セキュリティ上の考慮点#
クライアントサイドストレージはユーザーのブラウザに直接保存されるため、適切なセキュリティ対策が必要です。
XSS攻撃への対策#
クライアントサイドストレージに保存されたデータは、XSS(クロスサイトスクリプティング)攻撃によって窃取される可能性があります。
1
2
3
4
5
6
7
8
|
// 危険なパターン: 機密情報をlocalStorageに保存
// 絶対に避けるべき
localStorage.setItem("accessToken", "eyJhbGciOiJIUzI1NiIs...");
// XSS攻撃によるトークン窃取の例
// 悪意のあるスクリプトが実行された場合
const stolenToken = localStorage.getItem("accessToken");
// attackerServer.send(stolenToken);
|
推奨されるセキュリティ対策#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
// 1. 機密性の高いトークンはHttpOnly Cookieで管理
// サーバー側で設定
// Set-Cookie: session_id=xxx; HttpOnly; Secure; SameSite=Strict
// 2. 保存前にデータを検証・サニタイズ
function sanitizeInput(input) {
if (typeof input !== "string") {
return JSON.stringify(input);
}
// HTMLエンティティをエスケープ
return input
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// 3. 機密データの暗号化(クライアント側の暗号化は補助的な対策)
async function encryptData(data, key) {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(JSON.stringify(data));
const cryptoKey = await crypto.subtle.importKey(
"raw",
key,
{ name: "AES-GCM" },
false,
["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
cryptoKey,
dataBuffer
);
return {
iv: Array.from(iv),
data: Array.from(new Uint8Array(encrypted))
};
}
// 4. 有効期限付きデータの保存
function setWithExpiry(key, value, ttlMs) {
const item = {
value: value,
expiry: Date.now() + ttlMs
};
localStorage.setItem(key, JSON.stringify(item));
}
function getWithExpiry(key) {
const itemStr = localStorage.getItem(key);
if (!itemStr) return null;
const item = JSON.parse(itemStr);
if (Date.now() > item.expiry) {
localStorage.removeItem(key);
return null;
}
return item.value;
}
|
同一オリジンポリシーによる保護#
クライアントサイドストレージは同一オリジンポリシーにより保護されています。異なるオリジン(プロトコル、ホスト、ポートの組み合わせ)からはデータにアクセスできません。
graph TB
subgraph "https://example.com"
A[localStorage] --> B[データA]
end
subgraph "https://other.com"
C[localStorage] --> D[データB]
end
subgraph "http://example.com"
E[localStorage] --> F[データC]
end
A -.->|アクセス不可| D
A -.->|アクセス不可| F
style A fill:#4CAF50,color:#fff
style C fill:#2196F3,color:#fff
style E fill:#FF9800,color:#fffオフライン対応アプリでのIndexedDB活用#
PWA(Progressive Web App)では、オフライン時でもアプリケーションが動作することが求められます。IndexedDBを活用したオフラインファースト設計の実装例を紹介します。
オフラインファースト設計のアーキテクチャ#
sequenceDiagram
participant App as アプリケーション
participant IDB as IndexedDB
participant API as サーバーAPI
App->>IDB: 1. ローカルデータ取得
IDB-->>App: キャッシュデータを返却
App->>App: 2. UIを即座に表示
alt オンライン
App->>API: 3. 最新データを取得
API-->>App: 最新データを返却
App->>IDB: 4. ローカルデータを更新
App->>App: 5. UIを更新
else オフライン
App->>App: キャッシュデータで継続動作
App->>IDB: 操作をキューに保存
end
Note over App,IDB: オンライン復帰時にキューを処理実践的なIndexedDBラッパークラス#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
|
// IndexedDBを使いやすくラップするクラス
class OfflineDB {
constructor(dbName, version = 1) {
this.dbName = dbName;
this.version = version;
this.db = null;
}
async init(stores) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onupgradeneeded = (event) => {
const db = event.target.result;
stores.forEach(store => {
if (!db.objectStoreNames.contains(store.name)) {
const objectStore = db.createObjectStore(store.name, {
keyPath: store.keyPath,
autoIncrement: store.autoIncrement || false
});
if (store.indexes) {
store.indexes.forEach(index => {
objectStore.createIndex(index.name, index.keyPath, {
unique: index.unique || false
});
});
}
}
});
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
async add(storeName, data) {
return this._transaction(storeName, "readwrite", store => store.add(data));
}
async put(storeName, data) {
return this._transaction(storeName, "readwrite", store => store.put(data));
}
async get(storeName, key) {
return this._transaction(storeName, "readonly", store => store.get(key));
}
async getAll(storeName) {
return this._transaction(storeName, "readonly", store => store.getAll());
}
async delete(storeName, key) {
return this._transaction(storeName, "readwrite", store => store.delete(key));
}
async clear(storeName) {
return this._transaction(storeName, "readwrite", store => store.clear());
}
async _transaction(storeName, mode, operation) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], mode);
const store = transaction.objectStore(storeName);
const request = operation(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
|
オフライン同期キューの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
// オフライン時の操作をキューに保存し、オンライン復帰時に同期
class SyncQueue {
constructor(db) {
this.db = db;
this.isOnline = navigator.onLine;
this.setupEventListeners();
}
setupEventListeners() {
window.addEventListener("online", () => {
this.isOnline = true;
this.processQueue();
});
window.addEventListener("offline", () => {
this.isOnline = false;
});
}
async enqueue(operation) {
const queueItem = {
id: Date.now().toString(),
operation: operation.type,
data: operation.data,
endpoint: operation.endpoint,
method: operation.method,
timestamp: new Date().toISOString(),
retryCount: 0
};
await this.db.add("syncQueue", queueItem);
if (this.isOnline) {
this.processQueue();
}
return queueItem.id;
}
async processQueue() {
const queueItems = await this.db.getAll("syncQueue");
for (const item of queueItems) {
try {
await this.executeOperation(item);
await this.db.delete("syncQueue", item.id);
console.log(`同期完了: ${item.id}`);
} catch (error) {
console.error(`同期失敗: ${item.id}`, error);
if (item.retryCount < 3) {
item.retryCount++;
await this.db.put("syncQueue", item);
} else {
// 最大リトライ回数を超えた場合は失敗キューへ移動
await this.db.add("failedQueue", item);
await this.db.delete("syncQueue", item.id);
}
}
}
}
async executeOperation(item) {
const response = await fetch(item.endpoint, {
method: item.method,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(item.data)
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
}
}
|
完全なオフライン対応Todoアプリの実装例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
|
// オフライン対応Todoアプリケーション
class OfflineTodoApp {
constructor() {
this.db = new OfflineDB("TodoAppDB", 1);
this.syncQueue = null;
}
async init() {
await this.db.init([
{
name: "todos",
keyPath: "id",
indexes: [
{ name: "completed", keyPath: "completed" },
{ name: "createdAt", keyPath: "createdAt" }
]
},
{
name: "syncQueue",
keyPath: "id"
},
{
name: "failedQueue",
keyPath: "id"
}
]);
this.syncQueue = new SyncQueue(this.db);
console.log("Todoアプリの初期化が完了しました");
}
async addTodo(title) {
const todo = {
id: crypto.randomUUID(),
title: title,
completed: false,
createdAt: new Date().toISOString(),
synced: false
};
// まずローカルに保存
await this.db.add("todos", todo);
// 同期キューに追加
await this.syncQueue.enqueue({
type: "CREATE",
endpoint: "/api/todos",
method: "POST",
data: todo
});
return todo;
}
async toggleTodo(id) {
const todo = await this.db.get("todos", id);
if (!todo) return null;
todo.completed = !todo.completed;
todo.updatedAt = new Date().toISOString();
todo.synced = false;
await this.db.put("todos", todo);
await this.syncQueue.enqueue({
type: "UPDATE",
endpoint: `/api/todos/${id}`,
method: "PUT",
data: todo
});
return todo;
}
async deleteTodo(id) {
await this.db.delete("todos", id);
await this.syncQueue.enqueue({
type: "DELETE",
endpoint: `/api/todos/${id}`,
method: "DELETE",
data: { id }
});
}
async getAllTodos() {
return this.db.getAll("todos");
}
async getCompletedTodos() {
const todos = await this.getAllTodos();
return todos.filter(todo => todo.completed);
}
async getPendingTodos() {
const todos = await this.getAllTodos();
return todos.filter(todo => !todo.completed);
}
}
// 使用例
async function runTodoApp() {
const app = new OfflineTodoApp();
await app.init();
// Todoを追加
await app.addTodo("IndexedDBについて学ぶ");
await app.addTodo("オフライン対応を実装する");
// すべてのTodoを取得
const todos = await app.getAllTodos();
console.log("Todoリスト:", todos);
// 最初のTodoを完了にする
if (todos.length > 0) {
await app.toggleTodo(todos[0].id);
}
// 完了したTodoを取得
const completed = await app.getCompletedTodos();
console.log("完了したTodo:", completed);
}
runTodoApp();
|
期待される動作結果#
上記のコードを実行すると、以下の動作が確認できます。
- IndexedDBに「TodoAppDB」データベースが作成される
- 「todos」「syncQueue」「failedQueue」のObject Storeが作成される
- オフライン状態でもTodoの追加・更新・削除が可能
- オンライン復帰時に自動的にサーバーと同期
各ストレージの使い分け判断フロー#
以下のフローチャートを参考に、要件に応じた最適なストレージを選択してください。
flowchart TD
A[データを保存したい] --> B{サーバーへ自動送信<br/>が必要?}
B -->|はい| C[Cookie]
B -->|いいえ| D{データ容量は<br/>5MB以上?}
D -->|はい| E[IndexedDB]
D -->|いいえ| F{複雑なクエリ<br/>が必要?}
F -->|はい| E
F -->|いいえ| G{永続化が<br/>必要?}
G -->|はい| H[localStorage]
G -->|いいえ| I[sessionStorage]
C --> J{機密データ?}
J -->|はい| K[HttpOnly属性を付与]
J -->|いいえ| L[通常のCookie]
style C fill:#ffcc00,color:#000
style E fill:#9C27B0,color:#fff
style H fill:#4CAF50,color:#fff
style I fill:#2196F3,color:#fffユースケース別の推奨ストレージ#
| ユースケース |
推奨ストレージ |
理由 |
| セッションID管理 |
Cookie(HttpOnly) |
サーバー送信が必要、XSS対策 |
| ユーザー設定(テーマ等) |
localStorage |
永続化が必要、容量小 |
| フォームの一時保存 |
sessionStorage |
タブ単位で独立、一時的 |
| オフラインデータキャッシュ |
IndexedDB |
大容量、構造化データ |
| 画像・ファイルキャッシュ |
IndexedDB |
Blobの保存が可能 |
| アクセストークン |
メモリ or HttpOnly Cookie |
セキュリティ最優先 |
まとめ#
本記事では、クライアントサイドストレージの4種類(Cookie、localStorage、sessionStorage、IndexedDB)について、それぞれの特性と使い分けを解説しました。
主要なポイントを整理します。
- Cookie: サーバーへの自動送信が必要な場合に使用。容量は約4KBと小さい
- localStorage: 永続的なユーザー設定の保存に最適。約5MBまで保存可能
- sessionStorage: タブを閉じるまでの一時データに使用。タブごとに独立
- IndexedDB: 大容量データや複雑なクエリが必要な場合に使用。オフライン対応に最適
セキュリティ面では、機密情報はHttpOnly Cookieで管理し、クライアントサイドストレージにはセンシティブなデータを保存しないことが重要です。
PWA開発においては、IndexedDBを活用したオフラインファースト設計により、ネットワーク状況に関係なく快適なユーザー体験を提供できます。同期キューパターンを実装することで、オフライン時の操作をオンライン復帰後に確実にサーバーへ反映できます。
参考リンク#