HTMLとJavaScriptで学習サイトを作っても、ページを再読み込みするたびに回答状況が消えてしまうと、毎回最初からやり直すことになります。
私がAWS資格用の学習サイトを作ったときも、最初は問題を表示して採点するだけでした。しかし、SOA-C03の問題398問を扱うようになると、「前回どこまで進んだか」「どの問題を間違えたか」「分野別の正答率は何%か」を残せないことが大きな不便になりました。
そこで利用したのがlocalStorageです。localStorageを使えば、サーバーやデータベースを用意しなくても、同じブラウザへ学習進捗を保存できます。
この記事では、localStorageで学習進捗を保存する方法を、保存、読み込み、正答率の計算、不正解問題の抽出、履歴の初期化、書き出し、複数タブの同期まで順番に解説します。
この記事のコードについて
掲載するコードは、学習進捗を保存する仕組みを説明するための最小例です。実際のAWS認定試験問題や、第三者が作成した問題文は使用していません。
また、localStorageは秘密情報を安全に保管する仕組みではありません。パスワード、認証用トークン、AWSアクセスキーなどは保存しないでください。
AWS資格用の学習サイト全体の作り方は、次の記事で解説しています。
SOA-C03の398問を5分野へ分類した結果は、以下の記事をご覧ください。
localStorageで学習進捗を保存する仕組み
localStorageで学習進捗を保存する前に、どこへ、どの単位でデータが残るのかを理解しておきましょう。
localStorageは、Webブラウザが持つ保存領域です。ページ側のJavaScriptから、キーと値の組み合わせとしてデータを保存できます。
localStorageとは何か
localStorageは、同じオリジンに属するページから利用できる保存領域です。
オリジンとは、主に次の3つの組み合わせを指します。
- 通信方式
- ホスト名
- ポート番号
たとえば、次の2つは別のオリジンです。
http://example.com
https://example.com
通信方式がHTTPとHTTPSで異なるため、同じホスト名でもlocalStorageは共有されません。
一方、次のページは通常、同じオリジンとして同じlocalStorageを利用します。
https://example.com/study/
https://example.com/study/result/
学習サイトでaws-study-historyというキーへ進捗を保存すると、同じオリジン内の別ページからも読み込めます。
localStorageへ保存したデータには、通常の利用で自動的に決まる有効期限がありません。ブラウザを閉じて再び開いた後も残るため、学習進捗の保存に向いています。
ただし、利用者がブラウザのデータを削除した場合や、保存を拒否する設定を使用している場合は残りません。シークレットモードやプライベートブラウズでは、最後の専用タブを閉じた時点で削除されることがあります。
localStorageへ保存できる値は文字列
localStorageが保存するキーと値は文字列です。
次のような単純な文字列なら、そのまま保存できます。
localStorage.setItem("study-status", "completed");
しかし、学習履歴は問題番号、正誤、選んだ回答、日時などをまとめたオブジェクトです。
JavaScriptのオブジェクトをそのまま保存すると、期待した内容になりません。
const history = {
10: {
isCorrect: false
}
};
localStorage.setItem("history", history);
console.log(localStorage.getItem("history"));
// [object Object]
そこで、JSON.stringify()を使って文字列へ変換します。
const json = JSON.stringify(history);
localStorage.setItem("history", json);
読み込むときは、JSON.parse()でJavaScriptのオブジェクトへ戻します。
const saved = localStorage.getItem("history");
const restoredHistory = JSON.parse(saved);
この「保存前に文字列へ変換し、読み込み後に元へ戻す」という流れが、localStorageで学習進捗を保存する基本です。
localStorageとsessionStorageの違い
localStorageと似た仕組みにsessionStorageがあります。
主な違いは保存期間と利用範囲です。
| 項目 | localStorage | sessionStorage |
|---|---|---|
| 保存期間 | ブラウザを閉じても通常は残る | ページセッションが終わると消える |
| 利用範囲 | 同じオリジン | 同じオリジンかつ同じタブ単位 |
| 向いている用途 | 学習履歴、表示設定 | 入力途中の一時保存 |
| 別タブとの共有 | 同じオリジンなら可能 | 基本的にタブごとに分かれる |
AWS資格の問題を翌日も続けたい場合は、localStorageが適しています。
反対に、模擬試験の途中経過を「タブを閉じたら消す」仕様にしたいなら、sessionStorageも選択肢になります。
Cookieとの違い
Cookieもブラウザへ情報を残す仕組みですが、localStorageとは用途が異なります。
Cookieは、設定によってHTTPリクエストと一緒にサーバーへ送信されます。一方、localStorageの内容はブラウザから自動送信されません。
今回の学習サイトはサーバーを使わず、ブラウザ内だけで回答履歴を管理します。そのため、CookieよりlocalStorageの方が構成を単純にできます。
ただし、利用者認証や安全なセッション管理が必要な場合は話が別です。認証情報をlocalStorageへ保存するのではなく、サーバー側の設計も含めて検討してください。
学習進捗として保存するデータを決める
localStorageで学習進捗を保存するときは、先にデータの形を決めます。
後から項目を思いつくたびに追加すると、過去の保存データと新しいコードが合わなくなりやすいためです。
問題IDごとに回答結果を保存する
最小構成では、問題IDをキーとして回答結果を保存します。
{
"10": {
"isCorrect": false,
"selectedAnswers": [1],
"answeredAt": "2026-07-04T10:00:00.000Z"
}
}
各項目の役割は次のとおりです。
| 項目 | 保存する内容 |
|---|---|
| 問題ID | どの問題の履歴か |
| isCorrect | 正解か不正解か |
| selectedAnswers | 利用者が選んだ回答 |
| answeredAt | 回答した日時 |
問題10へ回答した場合は、history["10"]へ結果を入れます。
history["10"] = {
isCorrect: false,
selectedAnswers: [1],
answeredAt: new Date().toISOString()
};
問題IDを文字列にしているのは、JavaScriptのオブジェクトのキーとして扱いやすくするためです。
問題文ではなく問題IDを保存する
学習履歴には、問題文の全文を保存しない方が管理しやすくなります。
たとえば398問分の問題文と選択肢を回答のたびにlocalStorageへ重複保存すると、データ量が増えるだけでなく、問題文を修正した際に古い文章が履歴へ残ります。
問題データはquestions.jsonで管理し、localStorageには問題IDと回答結果だけを入れます。
questions.json
└─ 問題文・選択肢・正答・分野
localStorage
└─ 問題ID・正誤・回答日時
この分け方なら、問題文を修正しても進捗データをそのまま利用できます。
直近結果だけを保存するか、回答回数も残すか
最初は直近の回答結果だけでも十分です。
{
"isCorrect": true
}
しかし、「一度でも間違えた問題を残したい」「2回連続で正解したら復習対象から外したい」と考える場合は、項目を追加します。
{
"lastResult": true,
"attempts": 3,
"correctCount": 2,
"consecutiveCorrect": 1,
"hasEverFailed": true
}
実際に私が398問を管理したときも、最初から複雑な履歴を作るより、直近の正誤だけで動作を確認する方が進めやすくなりました。
まずは必要最低限で完成させ、復習方法が固まってから項目を増やすのがおすすめです。
保存形式の版番号を決める
保存形式を将来変更する可能性があるなら、版番号を持たせます。
{
"version": 1,
"answers": {
"10": {
"isCorrect": false
}
}
}
または、localStorageのキーへ版番号を付ける方法もあります。
const STORAGE_KEY = "aws-study-history-v1";
後から保存形式を変えた場合は、v2へ変更できます。
const STORAGE_KEY = "aws-study-history-v2";
古い形式と新しい形式を区別できるため、「以前のデータが原因で画面が動かない」という問題を減らせます。
localStorageへ学習進捗を保存する
データの形を決めたら、回答直後にlocalStorageへ保存します。
基本の流れは次のとおりです。
- 回答を取得する
- 正誤を判定する
- 履歴オブジェクトを更新する
- localStorageへ保存する
- 正答率を再計算する
setItemでデータを保存する
setItem()は、指定したキーへ値を保存するメソッドです。
localStorage.setItem(
"study-status",
"completed"
);
同じキーがすでに存在する場合は、値が更新されます。
学習履歴では、毎回新しいキーを増やすのではなく、履歴全体を1つのキーへ保存すると扱いやすくなります。
JSON.stringifyで履歴を文字列へ変換する
次の履歴を保存してみます。
const history = {
"10": {
isCorrect: false,
selectedAnswers: [1]
},
"11": {
isCorrect: true,
selectedAnswers: [0, 2]
}
};
JSON.stringify()で文字列へ変換してから保存します。
const json = JSON.stringify(history);
localStorage.setItem(
"aws-study-history-v1",
json
);
ブラウザの開発者ツールで確認すると、値はJSON形式の文字列として表示されます。
回答した直後に保存する
回答結果を記録する関数を作ります。
function recordAnswer(
questionId,
isCorrect,
selectedAnswers
) {
history[String(questionId)] = {
isCorrect,
selectedAnswers,
answeredAt: new Date().toISOString()
};
saveHistory();
}
問題10で選択肢1を選び、不正解だった場合は次のように呼び出します。
recordAnswer(10, false, [1]);
回答のたびに保存することで、利用者が次の問題へ進む前にページを閉じても、直前の結果まで残せます。
保存処理を関数へまとめる
保存処理は複数箇所へ直接書かず、関数へまとめます。
const STORAGE_KEY = "aws-study-history-v1";
function saveHistory() {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(history)
);
}
正答率画面、復習画面、模擬試験画面などから同じ履歴を更新する場合でも、保存処理を1か所だけ直せば済みます。
この段階では短いコードですが、後からエラー処理を加えると関数へ分けた効果が大きくなります。
保存した学習進捗を読み込む
次に、ページを開いたときにlocalStorageから学習進捗を読み込みます。
保存できても復元しなければ、画面上では進捗が消えたように見えてしまいます。
getItemで保存値を取得する
getItem()へ保存時と同じキーを渡します。
const saved = localStorage.getItem(
"aws-study-history-v1"
);
該当するキーがない場合、戻り値はnullです。
初回利用時は保存データがないため、nullを前提に処理する必要があります。
JSON.parseでオブジェクトへ戻す
保存値がある場合は、JSON.parse()でオブジェクトへ戻します。
const history = saved
? JSON.parse(saved)
: {};
保存値がなければ、空のオブジェクトを使います。
この1行だけでも動きますが、実際のサイトでは保存データが壊れている可能性も考え、後ほどtry...catchを追加します。
ページを開いたときに進捗を復元する
読み込み処理を関数にします。
function loadHistory() {
const saved = localStorage.getItem(
STORAGE_KEY
);
return saved ? JSON.parse(saved) : {};
}
let history = loadHistory();
ページを開いた後は、読み込んだ履歴を次の表示へ反映します。
- 回答済み問題の印
- 正解・不正解の状態
- 全体の正答率
- 分野別の正答率
- 不正解問題一覧
- 最後に回答した日時
たとえば問題10の履歴が存在するかは、次のように確認できます。
const savedResult = history["10"];
if (savedResult) {
console.log("問題10は回答済みです。");
}
保存データがない場合は空の履歴を使う
初回利用者にとって、保存データがないのは正常な状態です。
「履歴が見つかりません」というエラーを表示する必要はありません。
return {};
空の履歴を返し、回答数0問、正答率0%として画面を表示します。
保存と読み込みで起こるエラーに対応する
localStorageは主要ブラウザで広く利用できますが、必ず保存できるとは限りません。
ブラウザの設定、保存容量、プライベートブラウズ、壊れたJSONなどを考慮しておきましょう。
JSON.parseの失敗を考える
保存値がJSONとして正しくない場合、JSON.parse()は例外を発生させます。
たとえば利用者が開発者ツールから値を書き換えたり、以前のコードが不正な値を保存したりした場合です。
function loadHistory() {
try {
const saved = localStorage.getItem(
STORAGE_KEY
);
return saved ? JSON.parse(saved) : {};
} catch (error) {
console.error(
"学習履歴を読み込めませんでした。",
error
);
return {};
}
}
読み込みに失敗しても、空の履歴で学習を開始できるようにします。
読み込んだデータの形を確認する
JSONとして正しくても、期待するオブジェクトとは限りません。
function isHistoryObject(value) {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
);
}
読み込み処理へ組み込みます。
function loadHistory() {
try {
const saved = localStorage.getItem(
STORAGE_KEY
);
if (!saved) {
return {};
}
const parsed = JSON.parse(saved);
return isHistoryObject(parsed)
? parsed
: {};
} catch (error) {
console.error(
"学習履歴を読み込めませんでした。",
error
);
return {};
}
}
本格的な公開サイトでは、問題ID、isCorrect、selectedAnswersなどの項目まで検証すると安全です。
保存処理もtry…catchで囲む
保存容量の上限へ達した場合や、ブラウザが保存を拒否した場合、setItem()が例外を発生させる可能性があります。
function saveHistory() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(history)
);
return true;
} catch (error) {
console.error(
"学習進捗を保存できませんでした。",
error
);
return false;
}
}
回答処理側で結果を確認します。
const saved = saveHistory();
if (!saved) {
resultElement.textContent =
"回答は採点しましたが、進捗を保存できませんでした。";
}
localStorageへ書き込めるか確認する
ブラウザがlocalStorageの機能を持っていても、設定、プライベートブラウズ、容量不足などにより、実際の書き込みが失敗する場合があります。
学習進捗を保存できるか確認するなら、テスト用の値を書き込み、すぐ削除する方法が分かりやすいでしょう。
function canWriteLocalStorage() {
const testKey = "__storage_test__";
try {
localStorage.setItem(testKey, testKey);
localStorage.removeItem(testKey);
return true;
} catch (error) {
console.error(
"localStorageへ書き込めません。",
error
);
return false;
}
}
初期化時に確認します。
if (!canWriteLocalStorage()) {
alert(
"この環境では学習進捗を保存できません。"
);
}
この関数が確認するのは、その時点でテスト書き込みに成功するかです。機能自体が未対応なのか、保存が禁止されているのか、容量上限へ達したのかまでは判別しません。
原因まで区別したい場合は、例外名や既存データの有無を追加で確認してください。
ページを開いたときに回答状態を復元する
履歴を読み込んだら、問題画面へ回答状態を反映します。
ただし、前回の正答をすぐ見せると復習にならない場合があります。どこまで復元するかは、学習方法に合わせて決めてください。
回答済み問題へ印を付ける
問題一覧がある場合は、状態を表示します。
問題10 × 前回不正解
問題11 ○ 前回正解
問題12 - 未回答
状態を返す関数は次のように作れます。
function getQuestionStatus(questionId) {
const result = history[String(questionId)];
if (!result) {
return "unanswered";
}
return result.isCorrect
? "correct"
: "incorrect";
}
398問を一覧表示するときも、未回答と不正解を見分けやすくなります。
前回選んだ回答を再表示する
前回の選択肢を復元する場合は、保存したselectedAnswersを使います。
function restoreSelectedAnswers(question) {
const savedAnswer =
history[String(question.id)];
if (!savedAnswer) {
return;
}
savedAnswer.selectedAnswers.forEach(
value => {
const input = choicesForm.querySelector(
`input[value="${value}"]`
);
if (input) {
input.checked = true;
}
}
);
}
問題の選択肢を画面へ作成した後で、この関数を呼び出します。
前回の正誤を表示するか決める
復元方法には次の選択肢があります。
- 前回の正誤と解説をすぐ表示する
- 選んだ回答だけ復元し、正誤は隠す
- 「前回不正解」とだけ表示する
- 復習モードでは前回の回答を一切表示しない
私なら、通常の確認画面では前回結果を表示し、復習モードでは隠します。
正解を覚えているか確認したいのに、画面を開いた瞬間に答えが見えてしまうと、localStorageで学習進捗を保存した意味が薄れてしまうためです。
localStorageの履歴から正答率を計算する
保存した履歴を使えば、全体と分野別の正答率を計算できます。
計算式は次のとおりです。
正答率 = 正解数 ÷ 回答数 × 100
未回答問題は分母へ含めません。
回答済み問題だけを集計する
問題データをquestions、履歴をhistoryとします。
const answeredQuestions = questions.filter(
question =>
history[String(question.id)]
);
この処理では、履歴が存在する問題だけを残します。
正解数を数える
回答済み問題から、isCorrectがtrueの問題を抽出します。
const correctQuestions =
answeredQuestions.filter(
question =>
history[String(question.id)]
.isCorrect === true
);
正答率を計算する
回答数が0問の場合は、0で割らないように分岐します。
const rate =
answeredQuestions.length === 0
? 0
: (
correctQuestions.length /
answeredQuestions.length
) * 100;
表示時に小数点以下1桁へ整えます。
const rateText = rate.toFixed(1);
集計処理を関数にする
function calculateStats(targetQuestions) {
const answered = targetQuestions.filter(
question =>
history[String(question.id)]
);
const correct = answered.filter(
question =>
history[String(question.id)]
.isCorrect === true
);
const rate = answered.length === 0
? 0
: (
correct.length /
answered.length
) * 100;
return {
answered: answered.length,
correct: correct.length,
rate
};
}
全問題の正答率は次のように取得します。
const overallStats =
calculateStats(questions);
分野別の正答率を表示する
SOA-C03の問題には、1から5の分野番号を付けてあります。
const domain3Questions =
questions.filter(
question => question.domain === 3
);
const domain3Stats =
calculateStats(domain3Questions);
表示例は次のとおりです。
| 分野 | 回答数 | 正解数 | 正答率 |
|---|---|---|---|
| 分野1 | 30 | 24 | 80.0% |
| 分野2 | 20 | 13 | 65.0% |
| 分野3 | 15 | 7 | 46.7% |
全体の正答率が高くても、分野3だけ低いことがあります。
この偏りを見つけられる点が、localStorageで学習進捗を保存する大きな利点です。
不正解問題だけを復習する
履歴へ正誤を保存しておけば、不正解問題だけを抽出できます。
398問を毎回解き直すより、直近で間違えた問題へ絞る方が復習しやすくなります。
isCorrectがfalseの問題を抽出する
const incorrectQuestions =
questions.filter(
question =>
history[String(question.id)]
?.isCorrect === false
);
?.を使うことで、未回答問題の履歴が存在しなくてもエラーになりません。
直近の回答結果を使う
最小構成では、最後の回答が不正解なら復習対象にします。
問題10を最初に間違え、その後に正解した場合は、不正解一覧から外れます。
この方法は単純で分かりやすい反面、偶然正解した問題も外れる可能性があります。
一度でも間違えた問題を残す
一度間違えた問題を残したい場合は、hasEverFailedを追加します。
function recordAnswer(
questionId,
isCorrect,
selectedAnswers
) {
const key = String(questionId);
const previous = history[key];
history[key] = {
isCorrect,
selectedAnswers,
answeredAt: new Date().toISOString(),
hasEverFailed:
previous?.hasEverFailed === true ||
isCorrect === false
};
saveHistory();
}
最後に正解しても、過去に間違えた問題として検索できます。
連続正解で復習対象から外す
2回連続で正解したら復習対象から外す場合は、consecutiveCorrectを更新します。
const consecutiveCorrect = isCorrect
? (previous?.consecutiveCorrect ?? 0) + 1
: 0;
履歴へ保存します。
history[key] = {
isCorrect,
selectedAnswers,
answeredAt: new Date().toISOString(),
consecutiveCorrect
};
復習対象は次の条件で抽出できます。
const reviewQuestions =
questions.filter(question => {
const result =
history[String(question.id)];
return (
result &&
result.consecutiveCorrect < 2
);
});
学習方法に正解はありません。まずは直近結果で始め、必要になったら連続正解へ広げると無理なく作れます。
学習履歴を初期化する方法
学習を最初からやり直したい場合や、テスト用データを消したい場合は、履歴を初期化します。
削除処理は元に戻せないため、確認画面を表示しましょう。
removeItemで学習履歴だけを削除する
localStorage.removeItem(
STORAGE_KEY
);
これで指定したキーだけを削除できます。
clearは安易に使わない
localStorageにはclear()もあります。
localStorage.clear();
ただし、clear()は同じオリジンに保存されたlocalStorageの項目をすべて削除します。
同じサイトでテーマ設定、表示設定、別資格の履歴などを保存している場合、それらも消えてしまいます。
学習履歴だけを削除するなら、removeItem()を使用してください。
削除前に確認する
function resetHistory() {
const confirmed = window.confirm(
"すべての学習履歴を削除しますか?"
);
if (!confirmed) {
return;
}
localStorage.removeItem(STORAGE_KEY);
history = {};
renderQuestion();
renderStats();
}
「キャンセル」を押した場合は何もしません。
初期化後に画面も更新する
localStorageの値を削除しても、JavaScriptの変数historyに古い内容が残っている場合があります。
そのため、次の3つをセットで行います。
- localStorageのキーを削除する
historyを空にする- 問題画面と集計画面を再表示する
保存領域だけでなく、画面上の回答数や正答率も0へ戻ることを確認しましょう。
学習履歴をJSONファイルとして書き出す
localStorageのデータは、そのブラウザの中にあります。
ブラウザデータを削除する前や、別のパソコンへ移す場合に備え、JSONファイルとして書き出せると便利です。
学習履歴を書き出す
function exportHistory() {
const exportData = {
version: 1,
exportedAt: new Date().toISOString(),
answers: history
};
const json = JSON.stringify(
exportData,
null,
2
);
const blob = new Blob(
[json],
{ type: "application/json" }
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download =
"aws-study-history.json";
document.body.append(link);
link.click();
link.remove();
setTimeout(() => {
URL.revokeObjectURL(url);
}, 0);
}
一時的なURLは、ダウンロード処理を開始した後で破棄します。この処理では、履歴、版番号、書き出し日時をJSONへまとめています。
書き出した履歴を読み込む
HTMLへファイル選択欄を用意します。
<label for="history-file">
学習履歴を読み込む
</label>
<input
type="file"
id="history-file"
accept="application/json"
>
読み込み処理の例です。
async function importHistory(file) {
const text = await file.text();
const imported = JSON.parse(text);
if (
imported.version !== 1 ||
!isHistoryObject(imported.answers)
) {
throw new Error(
"対応していない履歴ファイルです。"
);
}
history = imported.answers;
if (!saveHistory()) {
throw new Error(
"読み込んだ履歴を保存できません。"
);
}
renderQuestion();
renderStats();
}
読み込む前にデータを確認する
JSONファイルだから安全とは限りません。
最低でも次の点を確認してください。
- JSONとして正しいか
- 版番号が対応しているか
answersがオブジェクトか- 問題IDが現在の問題集に存在するか
- 回答配列が想定した形か
- 不要な項目が混ざっていないか
読み込んだ文字列をinnerHTMLへ直接入れるのも避けます。画面へ表示するときは、原則としてtextContentを使用してください。
複数タブで学習進捗を同期する
同じ学習サイトを2つのタブで開くと、一方のタブで保存した結果が、もう一方の画面へすぐ反映されない場合があります。
このときはstorageイベントを利用できます。
storageイベントを使う
window.addEventListener(
"storage",
event => {
if (event.key !== STORAGE_KEY) {
return;
}
history = loadHistory();
renderQuestion();
renderStats();
}
);
別のタブでSTORAGE_KEYが変更されると、履歴を読み直して画面を更新します。
変更したタブ自身では発生しない
storageイベントは、localStorageを変更したページ自身では発生しません。
たとえばタブAで回答した場合は次のようになります。
タブA
保存処理の直後に通常の関数で画面更新
タブB
storageイベントを受けて画面更新
そのため、回答したタブでは従来どおりrenderStats()を呼び出し、別タブだけstorageイベントで同期します。
同じオリジンである必要がある
別のドメインや別の通信方式では、同じlocalStorageへアクセスできません。
次の2つは保存領域が分かれます。
http://example.com
https://example.com
開発中にHTTPの環境からHTTPSの公開サイトへ移したとき、履歴が消えたように見える場合があります。実際には、別のオリジンへ保存されている可能性があります。
localStorageの容量と制限を理解する
localStorageは、問題IDや正誤のような小さな文字データへ向いています。
一方、問題文、画像、動画などを大量に保存する用途には適していません。
保存できる容量には上限がある
MDNでは、Web Storageについて、オリジンごとにlocalStorageが5MiB、sessionStorageが5MiB、合計10MiBの上限があると説明しています。
上限へ達すると、setItem()でQuotaExceededErrorが発生する可能性があります。
398問分の問題ID、正誤、回答日時だけなら、通常は大きなデータにはなりません。
しかし、問題文や画像を毎回重複保存すると、容量を無駄に使います。
問題文を重複保存しない
次のように役割を分けます。
questions.json
├─ 問題文
├─ 選択肢
├─ 正答
└─ 分野
localStorage
├─ 問題ID
├─ 正誤
├─ 選択回答
└─ 回答日時
画像や長い解説は、localStorageへ入れず、問題データ側で管理してください。
localStorageは同期処理
localStorageの読み書きは同期処理です。
つまり、保存や読み込みが終わるまで、同じJavaScriptのほかの処理は待ちます。
MDNでも、localStorageとsessionStorageの操作は同期的に実行され、処理が完了するまで、ほかのJavaScript処理が止まる可能性があると説明されています。
問題IDや正誤だけを保存する小規模な学習サイトでは、影響を感じにくいでしょう。しかし、長い問題文や大量の回答履歴を頻繁に保存する構成では、画面の反応が遅くなる可能性があります。
扱うデータが増えた場合は、大量の構造化データを扱え、処理の多くが非同期で行われるIndexedDBへの移行を検討してください。
ブラウザや設定によって保存できない場合がある
localStorageが利用できない主な例は次のとおりです。
- 利用者が保存を拒否している
- プライベートブラウズで制限されている
- 保存容量の上限へ達した
- ブラウザデータが削除された
file:形式でHTMLを直接開いている- ブラウザのポリシーで永続化が禁止されている
特にfile:形式でのlocalStorageの扱いは、仕様上の動作が定められていません。
手元で学習サイトを試す場合は、簡易Webサーバーを使い、http://localhostから開く方が安定します。
複数端末では共有されない
パソコンのlocalStorageとスマートフォンのlocalStorageは別です。
localStorageの仕様には、端末間でデータを同期する機能がありません。ブラウザや拡張機能が独自の同期機能を提供する場合を除き、通常のWebページが保存した学習履歴は、別の端末へ自動では引き継がれません。
複数端末で学習進捗を共有したい場合は、次の仕組みが必要です。
- サーバー側の保存先
- データベース
- 利用者認証
- 通信の暗号化
- バックアップ
個人用サイトであれば、JSONの書き出しと読み込みで移行する方法も現実的です。
localStorageへ保存してはいけない情報
localStorageは、JavaScriptから読み書きできる保存領域です。
便利ですが、機密情報を安全に保管する場所ではありません。
パスワードを保存しない
学習サイトへログイン機能を追加しても、パスワードをlocalStorageへ保存しないでください。
暗号化したように見えても、復号に必要な情報を同じブラウザ側へ置けば、安全性を十分に確保できない場合があります。
認証が必要なら、サーバー側を含めて適切に設計します。
AWSアクセスキーを保存しない
次の情報はlocalStorageへ保存しないでください。
- AWSアクセスキーID
- AWSシークレットアクセスキー
- セッショントークン
- 認証用トークン
- セッション識別子
- パスワード
- 決済情報
- 重要な個人情報
AWS資格の学習サイトでAWS APIを試したくなっても、ブラウザのlocalStorageへ長期的なアクセスキーを置く構成は避けます。
ブラウザへ配信されるJavaScriptやlocalStorageは、利用者側の開発者ツールから確認できます。そのため、AWSアクセスキーを安全に隠す保管場所としては利用できません。
AWS公式ドキュメントでも、可能な限り長期的なアクセスキーを作成せず、IAMロールなどが発行する一時的な認証情報を使うよう推奨しています。一時的な認証情報には有効期限があるため、誤って公開された場合の影響を長期的なアクセスキーより抑えやすくなります。
学習サイトからAWSリソースへアクセスさせる必要がある場合も、長期的なアクセスキーをコードやlocalStorageへ直接保存せず、用途に応じてIAMロール、一時的な認証情報、Amazon Cognitoなどを検討してください。
参考:プログラムによるアクセスで使用するAWS認証情報|AWS
参考:SEC02-BP02 一時的な認証情報を使用する|AWS Well-Architected Framework
JavaScriptから読み取れる点を理解する
OWASPは、localStorageへ機密情報やセッション識別子を保存しないよう注意しています。
ページにクロスサイトスクリプティングの弱点がある場合、不正なJavaScriptからlocalStorageを読み書きされる可能性があるためです。
また、同じオリジン内の複数のページは同じlocalStorageへアクセスできます。
参考:HTML5 Security Cheat Sheet|OWASP
学習履歴だけを保存する
今回の用途では、次のデータに限定します。
- 問題ID
- 正解・不正解
- 選んだ回答
- 回答日時
- 回答回数
- 連続正解数
- 復習予定日
これらも利用者にとっては大切な記録です。絶対に消えてはいけない場合は、localStorageだけに頼らず、書き出しや別の保存先も用意してください。
localStorageの保存処理を確認するテスト項目
コードが完成したら、正常な操作だけでなく、保存できない場合も確認します。
初回表示でエラーにならないか
localStorageに何も保存されていない状態でページを開きます。
確認する内容は次のとおりです。
- 回答数が0問になる
- 正答率が0%になる
- 問題を選べる
- 開発者ツールにエラーが出ない
回答後に履歴が保存されるか
問題へ回答し、開発者ツールの保存領域を確認します。
ChromeやEdgeでは、開発者ツールのApplication画面からlocalStorageを確認できます。FirefoxではStorage画面が用意されています。
ブラウザの画面構成は更新される場合があるため、名称が多少異なることがあります。
ページを再読み込みしても残るか
回答後にページを再読み込みします。
次の項目が復元されるか確認してください。
- 回答済み問題
- 正解・不正解
- 全体の正答率
- 分野別正答率
- 不正解一覧
不正なJSONでも画面が止まらないか
開発者ツールから保存値を次のように変更します。
{broken-json
再読み込み後、空の履歴として開始できれば、try...catchが機能しています。
保存できない場合に案内されるか
保存処理が失敗したとき、開発者ツールだけでなく、画面にも案内が出るか確認します。
回答は採点しましたが、
学習進捗を保存できませんでした。
利用者が「保存できた」と誤解しない表現が必要です。
履歴初期化後に表示も戻るか
初期化ボタンを押した後、次の状態へ戻ることを確認します。
- localStorageのキーが消える
- 回答数が0問になる
- 正答率が0%になる
- 不正解一覧が空になる
- 選択肢のチェックが外れる
複数タブで同期されるか
同じ学習サイトを2つのタブで開きます。
片方で回答した後、もう一方の正答率が更新されれば、storageイベントが動いています。
変更したタブ自身ではstorageイベントが発生しないため、回答直後の通常更新も忘れないでください。
localStorageで学習進捗を保存するときの注意点
localStorageは、個人用の小さな学習サイトには便利です。
しかし、利用者が増えたり、端末間同期が必要になったりすると、localStorageだけでは対応しにくくなります。
localStorageをデータベース代わりにしすぎない
少量の進捗データなら、localStorageで十分です。
一方、次の条件へ広がった場合は、別の保存方法を検討します。
- 複数利用者が使う
- ログイン機能がある
- 複数端末で同期する
- 大量の問題データを保存する
- 履歴を長期間分析する
- 管理者が進捗を確認する
localStorageはブラウザ単位の保存領域であり、利用者管理用のデータベースではありません。
学習履歴はバックアップできるようにする
ブラウザデータを削除すると、学習履歴も消える可能性があります。
398問を何週間もかけて進めた後に記録が消えると、かなり困ります。
JSONの書き出し機能を用意し、定期的にバックアップできるようにしておくと安心です。
ブラウザへ保存されるサイトデータは、通常は「ベストエフォート」として扱われます。保存に成功しても、端末の空き容量、ブラウザの管理方針、利用者によるデータ削除などによって、将来も必ず残り続けるとは限りません。
Storage APIには、保存領域を永続的に扱うようブラウザへ要求するnavigator.storage.persist()が用意されています。ただし、要求が必ず認められるわけではなく、許可するかどうかはブラウザが判断します。
したがって、消失すると困る学習記録はlocalStorageだけへ依存せず、JSONファイルへの書き出しや、サーバー側の保存と組み合わせてください。
参考:StorageManager.persist()|MDN
問題IDを途中で変更しない
問題IDは、問題データと履歴を結び付ける大切な値です。
問題10のIDを後から110へ変更すると、過去のhistory["10"]を対応させられなくなります。
問題を削除する場合も、別の問題へ同じIDを再利用しない方が安全です。
保存形式を変更するときは版を分ける
次のように、保存する項目を変えることがあります。
旧形式
isCorrectだけ保存
新形式
isCorrect、回答回数、連続正解数を保存
古いデータを新形式へ変換する処理を作るか、保存キーをv2へ変更して初期化するかを決めます。
既存利用者がいる公開サイトでは、何の案内もなく履歴を消さないようにしてください。
正答をlocalStorageで隠すことはできない
正答をlocalStorageへ保存しなくても、questions.jsonやJavaScriptに正答を含めてブラウザへ配信している場合、利用者は開発者ツールから確認できます。
個人学習用であれば問題になりにくいものの、有料試験サービスや厳密な試験環境には向いていません。
正答を利用者の端末へ渡したくない場合は、サーバー側で採点する構成が必要です。
localStorageで学習進捗を保存するメリット
localStorageで学習進捗を保存する方法は、個人用の学習サイトと相性がよい仕組みです。
サーバーを準備せずに使える
HTML、CSS、JavaScriptだけで進捗保存を追加できます。
データベースの契約や利用者登録が不要なため、まず自分だけで試したい場合に向いています。
ページを閉じても進捗が残る
通常のブラウザ利用では、ページを閉じてもデータが残ります。
昨日の続きから問題を解けるため、398問のような長い問題集でも進めやすくなります。
実装するコードが比較的少ない
基本は次の4つです。
setItem()で保存getItem()で取得JSON.stringify()で文字列化JSON.parse()で復元
最初は問題IDと正誤だけを保存すれば、短いコードで動かせます。
正答率と不正解問題をすぐ集計できる
保存した履歴と問題データを組み合わせれば、全体や分野別の正答率を計算できます。
不正解だけを抽出できるため、限られた学習時間を弱点へ集中できます。
localStorageで学習進捗を保存するデメリット
便利な一方で、localStorageには制限があります。
別の端末へ自動で共有できない
パソコンで保存した進捗は、スマートフォンへ自動では移りません。
複数端末で使う場合は、JSONによる移行か、サーバー側の保存が必要です。
ブラウザデータを削除すると消える
利用者が閲覧データやサイトデータを削除すると、履歴が失われる場合があります。
大切な学習記録は、localStorageだけに任せず書き出せるようにしてください。
保存できる容量に上限がある
小さな進捗データには十分ですが、問題文、画像、長い解説を大量に入れる用途には向きません。
データ量が増えた場合は、IndexedDBやサーバー側の保存を検討します。
秘密情報を安全に保管する場所ではない
localStorageはJavaScriptから読み取れます。
パスワード、トークン、AWSアクセスキーなどは保存しないでください。
複数利用者の管理には向かない
ブラウザ内の1つの保存領域なので、利用者ごとの進捗管理には向きません。
ログイン機能を作る場合は、localStorageだけで認証や権限管理を行わないようにします。
localStorageでの進捗保存についてよくある質問
localStorageのデータはいつまで残りますか
localStorageには、通常の利用で自動的に決まる有効期限がありません。
ただし、利用者によるブラウザデータの削除、ブラウザの設定、保存領域の制限などにより消える場合があります。
絶対に失ってはいけない記録は、別の場所にも保存してください。
ブラウザを閉じても学習履歴は残りますか
通常のブラウザ利用では残ります。
sessionStorageはページセッション終了時に消えますが、localStorageはブラウザを閉じて再度開いた後も利用できます。
プライベートブラウズでは、最後の専用タブを閉じた後に削除されるのが一般的です。
パソコンとスマートフォンで共有できますか
localStorageだけでは共有できません。
端末ごと、ブラウザごとに保存領域が分かれます。
JSONの書き出しと読み込みを使うか、サーバーとデータベースを用意します。
localStorageにはどのくらい保存できますか
MDNでは、Web Storageの上限をオリジンごとに合計10MiBとし、その内訳をlocalStorageが5MiB、sessionStorageが5MiBと説明しています。
ただし、プライベートブラウズ、利用者の設定、組織のブラウザーポリシーなどによって、上限へ達していなくても保存できない場合があります。したがって、setItem()の例外処理は必要です。
localStorageの中身は利用者から見えますか
見えます。
ブラウザの開発者ツールを使えば、キーと値を確認・変更できます。
そのため、正答を隠す仕組みや秘密情報の保存場所としては使えません。
シークレットモードでも保存できますか
ブラウザによって扱いが異なる場合があります。
保存できたとしても、プライベートブラウズのセッション終了時に削除されるのが一般的です。
進捗を長く残したい場合は通常モードを利用してください。
保存した回答履歴を削除できますか
removeItem()で学習履歴のキーだけを削除できます。
localStorage.removeItem(
"aws-study-history-v1"
);
同じオリジン内のすべてのlocalStorageを消すclear()は、ほかの設定も削除するため注意が必要です。
sessionStorageとの違いは何ですか
localStorageはブラウザを閉じても通常は残ります。
sessionStorageはタブ単位で管理され、ページセッションが終了すると消えます。
翌日も学習を続ける用途では、localStorageの方が適しています。
Cookieを使う必要はありますか
今回のようにブラウザ内だけで学習進捗を保存する場合、Cookieは必須ではありません。
CookieはHTTP通信でサーバーへ送られる場合があり、認証など別の用途で使われます。
GitHub PagesでもlocalStorageは使えますか
使えます。
GitHub Pagesで公開した静的なHTMLとJavaScriptからも、localStorageを利用できます。
ただし、公開URLのオリジン単位で保存されるため、別のドメインへ移転した場合は自動で引き継がれません。
localStorageで学習進捗を保存する方法まとめ
localStorageを使えば、サーバーやデータベースを用意せず、ブラウザへ学習進捗を保存できます。
今回のポイントをまとめます。
- localStorageは同じオリジン単位で利用する
- ブラウザを閉じても通常はデータが残る
- 保存できるキーと値は文字列
- オブジェクトは
JSON.stringify()で保存する - 読み込み時は
JSON.parse()で元へ戻す - 問題文ではなく問題IDと回答結果を保存する
- 保存と読み込みは
try...catchで保護する - 未回答、正解、不正解を分けて集計する
- 分野別正答率を計算できる
- 不正解問題だけを復習できる
removeItem()で学習履歴だけを削除する- JSONの書き出しでバックアップできる
- 複数タブは
storageイベントで同期できる - 保存データは必ず残り続けるとは限らない
- localStorageは同期処理である
- 大量の構造化データにはIndexedDBを検討する
- パスワードやAWSアクセスキーを保存しない
- AWSへ接続する場合は一時的な認証情報を検討する
- 複数端末で共有する場合は別の仕組みが必要
最初から回答回数、連続正解数、復習予定日まで作り込む必要はありません。
まずは問題IDと正解・不正解だけをlocalStorageへ保存し、ページを再読み込みしても結果が残るところまで作ってみましょう。
基本の保存と復元が動いてから、正答率、不正解抽出、書き出し機能を追加する方が、エラーの原因を切り分けやすくなります。
参考にした公式情報
- Window.localStorage|MDN
- ウェブストレージAPIの使用|MDN
- Storage.setItem()|MDN
- Window storageイベント|MDN
- ブラウザーストレージの容量と削除基準|MDN
- Storage API|MDN
- StorageManager.persist()|MDN
- Web Storage API|MDN
- IndexedDB API|MDN
- HTML5 Security Cheat Sheet|OWASP
- プログラムによるアクセスで使用するAWS認証情報|AWS
- SEC02-BP02 一時的な認証情報を使用する|AWS Well-Architected Framework
コメント