順番に読み進めながら学べます

localStorage — ブラウザに保存する

ブラウザに値を保存する localStorage の setItem・getItem・removeItem、値が文字列に変わるため JSON で配列を保存する書き方、無いキーで返る null、容量の上限を扱います。

カートに入れた商品や、選んだ表示の設定を変数に入れておいても、再読み込みやページを閉じたときに消え、次に開くと最初の状態に戻ります。ここまでのコードには、ページを開き直しても値を残せる場所がありません。

この記事では、ブラウザに値を保存する localStorage と、配列やオブジェクトを保存するための JSON の文字列を扱います。

ページを閉じても値を残す — setItem と getItem

ニュースサイトで、利用者が選んだ文字サイズを、次に開いたときも使いたいとします。const fontSize = "large"; のように変数へ入れても、変数は再読み込みやページを閉じたときに消えるので、次に開いたページからは読めません。

localStorage(ブラウザが持つ、キーと値の保存場所)は、setItem(キー, 値) で保存し、getItem(キー) で読み出し、removeItem(キー) で取り除きます。

保存場所はオリジンhttps://news.example/settings のパスより前の部分)ごとに分かれ、同じオリジンのページは同じ値を読み書きします。

// 以前に保存した値が残っていれば取り除き、何も保存していない状態から始める
localStorage.removeItem("fontSize");

// 利用者が文字サイズを「大」に切り替えたときに保存する
localStorage.setItem("fontSize", "large");

// ページを開いたときに、保存した値を読み出して使う
const fontSize = localStorage.getItem("fontSize");
console.log(fontSize);                            // large

// 同じキーで保存すると、前の値を置き換える
localStorage.setItem("fontSize", "small");
console.log(localStorage.getItem("fontSize"));    // small
保存先はオリジンごとに分かれる
オリジン https://news.example
  • このオリジンのページとタブが、1 つの localStorage を共有する
localStorage — キーと値の組
  • "fontSize""small"(同じキーへの保存で置き換わった値)
  • ページを閉じても、再読み込みしても残る
開いているページ
  • const fontSize などの変数
  • 再読み込みすると、変数は作り直される
オリジン https://shop.example
  • 別の localStorage を持つ
  • news.example が保存した "fontSize"読めない
変数は再読み込みで作り直されますが、localStorage の値は残ります。別のオリジンのページからは、同じキーでも読めません

ページを開いたときに getItem で読み、利用者が設定を変えたときに setItem で書けば、再読み込みの後も同じ文字サイズで表示できます。保存した値は、ページのコードで取り除くか、利用者がブラウザの設定から消すまで残ります。

練習問題の保存は次の実行にも残る

練習問題のコンソールは、サイト内のすべての JavaScript の演習を同じオリジンで実行するので、localStorage も 1 つを共有し、ページを開き直しても残ります。前の実行で保存した値が結果に混ざらないよう、演習ではコードの最初で、使うキーを removeItem で取り除いてから始めます。

地図アプリで、地図の種類と距離の単位を別々のキーに保存します。前回の単位は保存済みで、選んだ種類の selectedMapType は宣言済みです。

① 選んだ地図の種類を保存してください。

② 保存されている距離の単位を、変数に読み出してください。

③ 距離の単位だけを「mile」で保存し直してください。

④ ② の変数と 2 つのキーの値を「km / mile / satellite」と表示してください。

(正しく実行できれば解説が表示されます)

JavaScript / TypeScript エディタ

コードを実行してください

配列やオブジェクトを保存する — JSON.stringify

旅行予約サイトで、お気に入りに入れたホテルを配列で持ち、次に開いたときも一覧に出したいとします。配列を setItem にそのまま渡してもエラーにはなりませんが、読み出した値からは、ホテルの番号も名前も取り出せません。

setItem は、渡された値を文字列に変換してから保存します。配列やオブジェクトは JSON.stringify で JSON の文字列にしてから保存し、読み出した文字列を JSON.parse で元の配列やオブジェクトに戻します。

// 以前に保存した値が残っていれば取り除いてから始める
localStorage.removeItem("favoriteHotels");
const favoriteHotels = [{ id: "H-101", name: "港ホテル" }, { id: "H-204", name: "駅前イン" }];

// そのまま渡すと、保存の時点で文字列に変換される
localStorage.setItem("favoriteHotels", favoriteHotels);
console.log(localStorage.getItem("favoriteHotels"));   // [object Object],[object Object]

// JSON の文字列にしてから保存する
localStorage.setItem("favoriteHotels", JSON.stringify(favoriteHotels));
const saved = localStorage.getItem("favoriteHotels");
console.log(saved);                                    // [{"id":"H-101","name":"港ホテル"},{"id":"H-204","name":"駅前イン"}]

// 読み出した文字列を、配列に戻してから使う
const restored = JSON.parse(saved);
console.log(restored[1].name);                         // 駅前イン
保存する時点で中身が消える
setItem に配列をそのまま[object Object],…の文字列で残るgetItem でも同じ文字列ホテルの名前はもう読めないJSON.stringifyしてから setItem[{"id":"H-101",…の文字列で残るgetItem してJSON.parserestored[1].nameは 駅前イン
そのまま渡した配列は、保存の時点で [object Object] の文字列に変わります。getItem の後では、もう元の配列に戻せません

エラーにならないため、数値や真偽値でも、読み出した後の計算や if の判定で初めて食い違いに気づきます。下の表は、setItem に渡した値ごとに、保存される文字列と、読み出したときに起きることをまとめたものです。

setItem に渡す値保存される文字列読み出したときに起きること
数値の 20"20""20" + 1 が "201" になる(Number で数に直す)
真偽値の false"false"空でない文字列なので if の条件では true になる(JSON.parse で false に戻す)
文字列の配列 ["A-1", "B-2"]"A-1,B-2"カンマでつながった 1 つの文字列になり、配列ではなくなる

語学アプリで、復習用の単語の一覧に 1 語を足します。一覧はキー reviewWords に保存済みで、足す単語 newWord は宣言済みです。

① 保存されている一覧を読み出し、値の型を表示してください。

② 読み出した値を配列に戻し、newWord を末尾に加えてください。

③ 加えた後の配列を、同じキーに保存し直してください。

④ もう一度読み出して配列に戻し、単語の数と最後の単語の word を表示してください。

JavaScript / TypeScript エディタ

コードを実行してください

保存が無いキーを読む — getItem が返す null

レシピサイトで、見たレシピの番号を閲覧履歴として保存し、ページを開いたときに件数を表示したいとします。初めて開いた人や履歴を消した人には保存が無いので、保存がある前提で JSON.parse の結果の length を読むと、その行で TypeError になって止まります。

getItem は、保存されていないキーを渡すと undefined ではなく null を返します。JSON.parse(localStorage.getItem(キー)) ?? [] と書けば、保存が無いときだけ空の配列が使われます。

// 閲覧履歴を読み出す。保存が無ければ空の配列にする
function loadHistory() {
  return JSON.parse(localStorage.getItem("viewHistory")) ?? [];
}

// 初めて開いたとき — 保存が無いので、getItem は null を返す
localStorage.removeItem("viewHistory");
console.log(localStorage.getItem("viewHistory"));   // null
console.log(loadHistory().length);                  // 0

// 保存してあるとき — 文字列から配列に戻る
localStorage.setItem("viewHistory", JSON.stringify(["R-12", "R-30"]));
console.log(loadHistory().length);                  // 2

// removeItem で取り除いた後 — また null になる
localStorage.removeItem("viewHistory");
console.log(loadHistory().length);                  // 0
保存が無いと parse も null を返す
保存が無い(初回・削除後)getItem はnull を返すJSON.parse(null)も null を返す?? で [] になりlength は 0保存がある["R-12","R-30"]getItem はJSON の文字列を返すJSON.parse で配列 2 件に戻す?? は使われずlength は 2
JSON.parse は、getItem が返す null をエラーにせず、null のまま返します。?? [] を付ければ配列として扱えます

読み出しを関数にまとめておけば、ログアウトで removeItem を呼んだ後も、呼び出し側は null を確かめずに配列として扱えます。removeItem は保存の無いキーに呼んでもエラーにならないので、取り除く前に保存があるかを調べる必要もありません。

日報アプリで、書きかけの日報を日付ごとに残します。10 日の下書きだけ保存済みで、loadDraft の枠は宣言済みです。

loadDraft で日付の下書きを返し、無ければ空の文字列にしてください。

② 11 日のキーを getItem で読み、戻り値を表示してください。

loadDraft で 10 日と 11 日の文字数を表示してください。

④ 10 日の下書きを取り除き、loadDraft で文字数を表示してください。

JavaScript / TypeScript エディタ

コードを実行してください

再読み込みしてもカートを戻す — 保存と復元の関数

ネットショップで、カートに商品を入れた後にページを再読み込みしても、中身をそのまま表示したいとします。cart.push で商品を加えても変わるのは変数だけで、保存の行を書き忘れた場所があると、再読み込みでその商品が消えます。

保存を saveCart、読み込みを loadCart の 2 つの関数にまとめ、カートを変えた直後とページを開いたときに呼びます。setItem同期の処理(保存を終えてから次の行へ進む処理)なので、fetch と違って await を付けなくても、次の行で保存した値を読めます。

localStorage.removeItem("cart");   // 以前に保存したカートが残っていれば取り除く
// カートを JSON の文字列にして保存する。await は要らない
function saveCart(cart) {
  localStorage.setItem("cart", JSON.stringify(cart));
}
// 保存したカートを配列に戻す。保存が無ければ空のカート
function loadCart() {
  return JSON.parse(localStorage.getItem("cart")) ?? [];
}

// ページを開いたときに読み込み、商品を入れたら保存する
const cart = loadCart();
cart.push({ id: "K-310", qty: 1 });
saveCart(cart);

// 再読み込みの代わりに、変数 cart を使わず保存した文字列から読み込み直す
const reloaded = loadCart();
console.log(reloaded.length, reloaded[0].id);     // 1 K-310
saveCart を忘れると戻らない
push でK-310 を加えるsaveCart(cart)を呼ぶ再読み込みしてloadCart()reloaded.lengthは 1push でK-310 を加えるsaveCart を呼び忘れる再読み込みしてloadCart()reloaded.lengthは 0
saveCart を呼ばないと、push した K-310 は変数の cart にしか入りません。再読み込みの後に戻るのは、最後に保存した中身です

カートを変える処理を 1 つの関数にまとめ、その最後で saveCart を呼ぶ形にしておけば、呼び出す場所ごとに保存を書く必要がなくなり、呼び忘れも起きません。読み込みも loadCart にまとめてあるので、?? [] を書き忘れる場所もありません。

容量を超えると setItem が例外を投げる

Chrome では、1 つのオリジンに保存できるのはキーと値を合わせて約 524 万文字で、超えると setItemQuotaExceededError を投げ、値は保存されません。読み書きは同期なので、大きな値を保存している間はページの操作も止まります。カートや設定のような小さな値に使います。

製造ラインの検品画面で、見つかった不良を品番ごとに件数付きで保存します。addDefect の枠と、saveDefectsloadDefects は宣言済みです。

addDefect で保存済みの記録を読み、同じ品番の記録を探してください。

② 見つかれば件数を 1 増やし、無ければ件数 1 で加えて保存してください。

③ W-12、W-40、W-12 の順に不良を記録してください。

loadDefects で読み直し、「W-40: 1 件」の形で品番ごとに表示してください。

JavaScript / TypeScript エディタ

コードを実行してください
QUIZ

理解度チェック

まずは1問ずつ答えてみましょう。

Q1一度も保存していないキーで localStorage.getItem("theme") を実行すると?

Q2localStorage.setItem("darkMode", false) の後、読み出した値を if の条件にすると?

Q3cart.push(book) の後に saveCart を呼ばず再読み込みすると、loadCart() で戻るのは?