Học bằng cách đọc theo thứ tự

localStorage — lưu dữ liệu trong trình duyệt

Lưu giá trị trong trình duyệt bằng setItem, getItem, removeItem, lưu mảng dưới dạng chuỗi JSON, xử lý null và giới hạn dung lượng.

Nếu bạn giữ sản phẩm trong giỏ hàng hay cài đặt hiển thị người dùng đã chọn trong biến, chúng sẽ mất khi trang được tải lại hoặc đóng, và lần mở sau mọi thứ trở về trạng thái ban đầu. Code bạn viết từ đầu khóa học đến giờ chưa có chỗ nào giữ được giá trị qua những lần mở lại trang.

Bài này nói về localStorage, nơi lưu giá trị trong trình duyệt, và chuỗi JSON dùng để lưu mảng và object.

Giữ giá trị sau khi đóng trang — setItem và getItem

Giả sử một trang tin tức muốn nhớ cỡ chữ người dùng đã chọn để dùng lại ở lần mở sau. Dù bạn gán vào biến như const fontSize = "large";, biến sẽ mất khi trang được tải lại hoặc đóng, nên trang mở lần sau không đọc được nó.

localStorage (nơi lưu các cặp key và giá trị do trình duyệt cung cấp) lưu giá trị bằng setItem(key, giá trị), đọc ra bằng getItem(key) và xóa bằng removeItem(key).

Nơi lưu được tách riêng theo origin (phần đứng trước path của URL, như https://news.example trong https://news.example/settings), và các trang cùng origin đọc ghi cùng một bộ giá trị.

// Nếu vẫn còn giá trị đã lưu từ trước thì xóa đi, để bắt đầu từ trạng thái chưa lưu gì
localStorage.removeItem("fontSize");

// Lưu khi người dùng chuyển cỡ chữ sang "lớn"
localStorage.setItem("fontSize", "large");

// Khi mở trang, đọc giá trị đã lưu ra để dùng
const fontSize = localStorage.getItem("fontSize");
console.log(fontSize);                            // large

// Lưu với cùng key thì giá trị cũ bị thay thế
localStorage.setItem("fontSize", "small");
console.log(localStorage.getItem("fontSize"));    // small
Nơi lưu tách riêng theo từng origin
Origin https://news.example
  • Các trang và tab của origin này dùng chung một localStorage
localStorage — các cặp key và giá trị
  • "fontSize""small" (giá trị đã bị thay khi lưu vào cùng key)
  • Vẫn còn sau khi đóng trang hay tải lại
Trang đang mở
  • Các biến như const fontSize
  • Khi tải lại trang, biến được tạo lại
Origin https://shop.example
  • Có localStorage riêng
  • Không đọc được "fontSize" do news.example lưu
Biến được tạo lại khi tải lại trang, còn giá trị trong localStorage thì vẫn còn. Trang thuộc origin khác không đọc được, kể cả với cùng key.

Nếu đọc bằng getItem khi trang mở và ghi bằng setItem khi người dùng đổi cài đặt, trang vẫn hiển thị đúng cỡ chữ đó sau khi tải lại. Giá trị đã lưu còn nguyên cho đến khi code của trang xóa nó, hoặc người dùng xóa trong phần cài đặt của trình duyệt.

Giá trị lưu trong bài tập còn lại đến lần chạy sau

Console bài tập chạy mọi bài tập JavaScript trên trang web này trong cùng một origin, nên chúng dùng chung một localStorage, và dữ liệu vẫn còn dù bạn mở lại trang. Để giá trị lưu từ những lần chạy trước không lẫn vào kết quả, các bài tập bắt đầu bằng việc dùng removeItem xóa những key sẽ dùng.

Trong một app bản đồ, bạn lưu loại bản đồ và đơn vị khoảng cách vào hai key riêng. Đơn vị của lần trước đã được lưu sẵn, và loại bản đồ được chọn selectedMapType đã được khai báo sẵn.

① Hãy lưu loại bản đồ đã chọn.

② Hãy đọc đơn vị khoảng cách đang lưu vào một biến.

③ Hãy đổi riêng đơn vị khoảng cách thành "mile" và lưu lại.

④ Hãy in ra biến ở ② và giá trị của hai key theo dạng "km / mile / satellite".

(Nếu chạy đúng, phần giải thích sẽ xuất hiện.)

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Lưu mảng và object — JSON.stringify

Giả sử một trang đặt phòng du lịch giữ các khách sạn người dùng đã thêm vào mục yêu thích trong một mảng, và muốn hiện lại danh sách đó ở lần mở sau. Truyền thẳng mảng cho setItem không báo lỗi, nhưng từ giá trị đọc ra, bạn không lấy được mã hay tên khách sạn nào cả.

setItem đổi giá trị được truyền vào thành chuỗi rồi mới lưu. Vì vậy, hãy đổi mảng và object thành chuỗi JSON bằng JSON.stringify trước khi lưu, rồi dùng JSON.parse để đổi chuỗi đọc ra về lại mảng hay object ban đầu.

// Nếu vẫn còn giá trị đã lưu từ trước thì xóa đi trước khi bắt đầu
localStorage.removeItem("favoriteHotels");
const favoriteHotels = [{ id: "H-101", name: "Khách sạn Biển" }, { id: "H-204", name: "Nhà nghỉ Phố Cổ" }];

// Truyền thẳng thì mảng bị đổi thành chuỗi ngay lúc lưu
localStorage.setItem("favoriteHotels", favoriteHotels);
console.log(localStorage.getItem("favoriteHotels"));   // [object Object],[object Object]

// Đổi thành chuỗi JSON rồi mới lưu
localStorage.setItem("favoriteHotels", JSON.stringify(favoriteHotels));
const saved = localStorage.getItem("favoriteHotels");
console.log(saved);                                    // [{"id":"H-101","name":"Khách sạn Biển"},{"id":"H-204","name":"Nhà nghỉ Phố Cổ"}]

// Đổi chuỗi đọc ra về lại mảng rồi mới dùng
const restored = JSON.parse(saved);
console.log(restored[1].name);                         // Nhà nghỉ Phố Cổ
Nội dung mất ngay lúc lưu
Truyền thẳng mảngcho setItem[object Object],…được lưugetItem trả vềđúng chuỗi đóKhông còn đọc đượctên khách sạnJSON.stringifyrồi mới setItem[{"id":"H-101",…được lưugetItem rồiJSON.parserestored[1].namelà Nhà nghỉ Phố Cổ
Mảng truyền thẳng vào bị đổi thành chuỗi [object Object] ngay lúc lưu. Sau getItem, không còn cách nào lấy lại mảng ban đầu.

Số và giá trị boolean cũng bị đổi thành chuỗi mà không báo lỗi, nên bạn chỉ nhận ra khi phép tính hay điều kiện if trên giá trị đọc ra cho kết quả sai. Bảng dưới đây cho biết, với mỗi giá trị truyền cho setItem, chuỗi nào được lưu và điều gì xảy ra khi bạn đọc nó ra.

Giá trị truyền cho setItemChuỗi được lưuKhi đọc ra
Số 20"20""20" + 1 thành "201" (dùng Number)
Boolean false"false"Là true trong if (dùng JSON.parse)
Mảng chuỗi ["A-1", "B-2"]"A-1,B-2"Một chuỗi nối bằng dấu phẩy

Trong một app học ngoại ngữ, bạn thêm 1 từ vào danh sách từ cần ôn. Danh sách đã được lưu với key reviewWords, và từ cần thêm newWord đã được khai báo sẵn.

① Hãy đọc danh sách đang lưu và in ra kiểu của giá trị đó.

② Hãy đổi giá trị đọc được về lại mảng và thêm newWord vào cuối.

③ Hãy lưu lại mảng sau khi thêm vào cùng key đó.

④ Hãy đọc lại lần nữa, đổi về mảng, rồi in ra số từ và word của từ cuối cùng.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Đọc key chưa được lưu — null mà getItem trả về

Trên một trang công thức nấu ăn, mã các công thức đã xem được lưu làm lịch sử xem, và khi mở trang thì hiện số lượng. Người mở lần đầu hoặc đã xóa lịch sử thì không có gì được lưu, nên nếu đọc length của kết quả JSON.parse với giả định lịch sử đã có, dòng đó sẽ báo lỗi TypeError.

Khi nhận một key chưa được lưu, getItem trả về null chứ không phải undefined. Nếu viết JSON.parse(localStorage.getItem(key)) ?? [], mảng rỗng chỉ được dùng khi chưa có gì được lưu.

// Đọc lịch sử xem. Nếu chưa lưu gì thì dùng mảng rỗng
function loadHistory() {
  return JSON.parse(localStorage.getItem("viewHistory")) ?? [];
}

// Lần mở đầu tiên — chưa lưu gì nên getItem trả về null
localStorage.removeItem("viewHistory");
console.log(localStorage.getItem("viewHistory"));   // null
console.log(loadHistory().length);                  // 0

// Khi đã lưu — chuỗi được đổi về lại mảng
localStorage.setItem("viewHistory", JSON.stringify(["R-12", "R-30"]));
console.log(loadHistory().length);                  // 2

// Sau removeItem — lại thành null
localStorage.removeItem("viewHistory");
console.log(loadHistory().length);                  // 0
Chưa lưu gì thì parse cũng trả về null
Chưa lưu gì(lần đầu, đã xóa)getItemtrả về nullJSON.parse(null)cũng trả về null?? cho [],nên length là 0Đã lưu["R-12","R-30"]getItem trả vềchuỗi JSONJSON.parse đổi vềmảng 2 phần tử?? không dùng tới,length là 2
Với null mà getItem trả về, JSON.parse không báo lỗi mà trả về luôn null. Thêm ?? [] là bạn dùng được nó như một mảng.

Nếu gom phần đọc vào một hàm, phía gọi dùng được kết quả như một mảng mà không cần kiểm tra null, kể cả sau khi removeItem được gọi lúc đăng xuất. removeItem không báo lỗi dù key chưa từng được lưu, nên bạn cũng không cần kiểm tra có dữ liệu hay không trước khi xóa.

Trong một app báo cáo hằng ngày, bạn giữ báo cáo đang viết dở làm bản nháp theo từng ngày. Chỉ bản nháp ngày 10 đã được lưu, và hàm loadDraft còn trống đã được khai báo sẵn.

① Hãy để loadDraft trả về bản nháp của ngày được truyền vào, hoặc chuỗi rỗng nếu không có.

② Hãy đọc key của ngày 11 bằng getItem và in ra giá trị trả về.

③ Hãy dùng loadDraft để in ra số ký tự trong bản nháp của ngày 10 và ngày 11.

④ Hãy xóa bản nháp ngày 10, rồi in ra số ký tự bằng loadDraft.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Giữ lại giỏ hàng sau khi tải lại trang — hàm lưu và hàm đọc

Tiếp theo, với giỏ hàng của một cửa hàng online, bạn muốn giữ nguyên nội dung giỏ khi trang được tải lại sau lúc thêm sản phẩm. Thêm sản phẩm bằng cart.push chỉ làm thay đổi biến, nên chỉ cần quên viết dòng lưu ở một chỗ là sản phẩm đó biến mất khi tải lại trang.

Hãy gom việc lưu vào hàm saveCart và việc đọc vào hàm loadCart, rồi gọi chúng ngay sau khi giỏ hàng thay đổi và khi trang mở. setItem chạy đồng bộ (lưu xong rồi mới chuyển sang dòng tiếp theo), nên khác với fetch, bạn đọc được giá trị vừa lưu ở dòng kế tiếp mà không cần await.

localStorage.removeItem("cart");   // Nếu vẫn còn giỏ hàng đã lưu từ trước thì xóa đi
// Lưu giỏ hàng dưới dạng chuỗi JSON. Không cần await
function saveCart(cart) {
  localStorage.setItem("cart", JSON.stringify(cart));
}
// Đổi giỏ hàng đã lưu về lại mảng. Nếu chưa lưu gì thì trả về giỏ rỗng
function loadCart() {
  return JSON.parse(localStorage.getItem("cart")) ?? [];
}

// Đọc khi mở trang, và lưu sau khi thêm sản phẩm
const cart = loadCart();
cart.push({ id: "K-310", qty: 1 });
saveCart(cart);

// Thay cho việc tải lại trang: không dùng biến cart mà đọc lại từ chuỗi đã lưu
const reloaded = loadCart();
console.log(reloaded.length, reloaded[0].id);     // 1 K-310
Quên saveCart thì không lấy lại được
Thêm K-310bằng pushGọisaveCart(cart)Tải lại trang,rồi loadCart()reloaded.lengthlà 1Thêm K-310bằng pushQuên gọisaveCartTải lại trang,rồi loadCart()reloaded.lengthlà 0
Nếu không gọi saveCart, K-310 vừa push chỉ nằm trong biến cart. Sau khi tải lại, bạn chỉ lấy lại được nội dung đã lưu gần nhất.

Nếu gom mọi thao tác thay đổi giỏ hàng vào một hàm và gọi saveCart ở cuối hàm đó, bạn không phải viết lệnh lưu ở từng nơi gọi, nên cũng không thể quên. Việc đọc cũng đã gom vào loadCart, nên không có chỗ nào quên viết ?? [].

Vượt quá dung lượng thì setItem báo lỗi

Trên Chrome, một origin lưu được khoảng 5,24 triệu ký tự, tính cả key lẫn giá trị. Vượt quá mức đó, setItem báo lỗi QuotaExceededError và giá trị không được lưu. Việc đọc ghi là đồng bộ, nên trong lúc lưu một giá trị lớn, trang cũng ngừng phản hồi thao tác. Hãy dùng localStorage cho những giá trị nhỏ như giỏ hàng hay cài đặt.

Trên màn hình kiểm tra hàng của một dây chuyền sản xuất, bạn lưu các lỗi tìm thấy kèm số lần theo từng mã linh kiện. saveDefects, loadDefects và hàm addDefect còn trống đã được khai báo sẵn.

① Trong addDefect, hãy đọc các bản ghi đã lưu và tìm bản ghi có cùng mã linh kiện.

② Nếu tìm thấy, hãy tăng số lần thêm 1; nếu không, hãy thêm một bản ghi với số lần là 1. Sau đó hãy lưu lại.

③ Hãy ghi nhận lỗi cho W-12, W-40, W-12 theo đúng thứ tự đó.

④ Hãy đọc lại bằng loadDefects và in ra từng mã linh kiện theo dạng "W-40: 1 lỗi".

JavaScript / TypeScript Editor

Chạy code để xem đầu ra
QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

Câu 1Chạy localStorage.getItem("theme") với một key chưa từng được lưu thì sao?

Câu 2Sau localStorage.setItem("darkMode", false), nếu dùng giá trị đọc ra làm điều kiện if thì sao?

Câu 3Nếu tải lại trang sau cart.push(book) mà không gọi saveCart, loadCart() trả về gì?