Astro 「群島架構」Nano Stores 繼續學習 - 1

更新 發佈閱讀 24 分鐘

第一題:「購物車」系統

這個練習的重點在於學習如何使用 map 來處理物件型態的狀態,以及如何在多個元件之間同步這些數據。


練習目標

  1. 建立一個商品清單,點擊「加入」按鈕時更新購物車。
  2. 若商品已在購物車,數量 +1+1;若不在,則新增一筆。
  3. 即時顯示購物車內的商品總數。

第一步:建立 Store (src/cartStore.ts)

在 Nano Stores 中,處理集合(Collection)最適合用 map。我們用商品的 id 作為 Key。

import { map } from 'nanostores'

// 初始購物車是個空物件
export const $cart = map({})

/**
* 加入購物車的邏輯
* @param {Object} item - 包含 id, name, price 的物件
*/
export function addToCart(item) {
const existingItem = $cart.get()[item.id]

if (existingItem) {
// 如果已存在,更新該 Key 的數量
$cart.setKey(item.id, {
...existingItem,
quantity: existingItem.quantity + 1,
})
} else {
// 如果不存在,新增一筆並設定數量為 1
$cart.setKey(item.id, { ...item, quantity: 1 })
}
}

❌ 可能會出現錯誤

const existingItem = $cart.get()[item.id]
const existingItem = $cart.get()[item.id]
Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{}'.

這個錯誤是 TypeScript 的經典報錯,通常出現在你定義 Nanostores 的時候,沒有告訴它 Store 裡面裝的是什麼數據結構

當你寫 $cart = map({}) 時,TypeScript 會推斷這個 Store 的類型是 Record<string, never> 或是一個空的 {},所以當你嘗試用 item.id 去抓取內容時,它會覺得:「這東西明明是空的,你怎麼能用一個 key 去找它呢?」

如何修復?
你需要在建立 Store 時定義一個 Type(類型) 或 Interface(介面)

1. 定義資料結構

假設你的購物車裡面裝的是商品資訊:

import { map } from 'nanostores'

// 定義單個商品的結構
interface CartItem {
id: string
name: string
price: number
quantity: number
}

// 定義整個 Store 的結構,Key 是字串(ID),Value 是 CartItem
// 使用 map<Record<string, CartItem>>({})
export const $cart = map<Record<string, CartItem>>({})

2. 獲取資料時

現在你再寫這行,TypeScript 就會知道 existingItem 的類型是 CartItem | undefined 了:

const existingItem = $cart.get()[item.id]

為什麼會發生這個錯誤?

  1. 預設類型太嚴格:如果你寫 map({}),TypeScript 會把 {} 視為「沒有任何屬性的物件」。
  2. 索引簽名(Index Signature):在 TypeScript 中,如果你想用變數(如 item.id)來當作 Key 去讀取物件,該物件必須定義過「索引簽名」,例如 [key: string]: T

進階建議:使用 Nanostores 的 setKey

如果你是想要檢查是否存在並更新購物車,建議配合 $cart.setKey 使用,這在 Nanostores 中效能更好:

const itemId = item.id
const cartData = $cart.get()

if (cartData[itemId]) {
// 如果已存在,更新數量
$cart.setKey(itemId, {
...cartData[itemId],
quantity: cartData[itemId].quantity + 1,
})
} else {
// 如果不存在,新增項目
$cart.setKey(itemId, { ...item, quantity: 1 })
}

✅ 所以改成這樣

import { map } from 'nanostores'

interface CartItem {
id: string
name: string
price: number
quantity: number
}

export const $cart = map<Record<string, CartItem>>({})

/**
* 加入購物車的邏輯
* @param {CartItem} item - 包含 id, name, price 的物件
*/
export function addToCart(item: CartItem) {
const existingItem = $cart.get()[item.id]
if (existingItem) {
$cart.setKey(item.id, {
...existingItem,
quantity: existingItem.quantity + 1,
})
} else {
$cart.setKey(item.id, { ...item, quantity: 1 })
}
}

第二步:建立產品列表元件 (src/components/ProductList.astro)

這個元件純粹負責展示,並在點擊時呼叫我們寫好的 addToCart 函數。

---
const products = [
{ id: 'p1', name: 'Astro 貼紙', price: 50 },
{ id: 'p2', name: 'Nano 紀念衫', price: 500 },
];
---

<div class="product-grid">
{products.map(item => (
<div class="card">
<h3>{item.name}</h3>
<p>${item.price}</p>
<button class="add-btn" data-item={JSON.stringify(item)}>
加入購物車
</button>
</div>
))}
</div>

<script>
import { addToCart } from '../cartStore';

// 取得所有按鈕並綁定事件
const buttons = document.querySelectorAll('.add-btn');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
const item = JSON.parse(btn.getAttribute('data-item'));
addToCart(item);
});
});
</script>

❌ 可能會出現錯誤

const item = JSON.parse(btn.getAttribute('data-item'))
Argument of type 'string | null' is not assignable to parameter of type 'string'.
Type 'null' is not assignable to type 'string'.

這也是一個常見的 TypeScript 報錯。問題在於 getAttribute() 的回傳值可能是 string(找到屬性)或者是 null(沒找到屬性)。

但 JSON.parse() 的參數必須是字串,它不接受 null。TypeScript 為了保護你,怕屬性不存在導致程式崩潰,所以攔截了這個賦值。

以下是三種解決方法,按推薦程度排序:

1. 使用「短路運算子」提供預設值(最推薦)

這是最簡單且安全的做法。如果 getAttribute 回傳 null,就給它一個空物件的 JSON 字串 '{}'

const itemData = btn.getAttribute('data-item') || '{}'
const item = JSON.parse(itemData)

2. 先做「非空檢查」 (Type Guard)

如果你希望只有在確定有資料時才執行邏輯,可以用 if

const itemData = btn.getAttribute('data-item')

if (itemData) {
const item = JSON.parse(itemData)
// 在這裡處理 item...
} else {
console.error('找不到 data-item 屬性')
}

3. 使用「非空斷言」 ! (不推薦)

如果你百分之百確定 HTML 標籤上一定有 data-item,可以在後面加個 !。但如果萬一漏寫了 HTML 屬性,這行就會噴錯:

// 警告:如果 HTML 沒寫 data-item,這行會導致 JSON.parse 報錯
const item = JSON.parse(btn.getAttribute('data-item')!)

💡 為什麼要這樣寫?

因為在瀏覽器中,HTML 結構與 JavaScript 是分開的。TypeScript 沒辦法確定你的 HTML 檔案裡是否真的寫了 data-item 這個屬性。


第三步:建立購物車顯示元件 (src/components/Cart.astro)

這個元件需要訂閱 $cart 的變化。

<div class="cart-container">
<h2>你的購物車</h2>
<ul id="cart-items">
</ul>
<hr />
<p>總計項目:<span id="total-count">0</span></p>
</div>

<script>
import { $cart } from '../cartStore';

const cartList = document.querySelector('#cart-items');
const totalDisplay = document.querySelector('#total-count');

// 訂閱 Store 的變化
$cart.subscribe((cartContent) => {
// 1. 清空舊列表
cartList.innerHTML = '';

// 2. 計算總數與渲染列表
let total = 0;

Object.values(cartContent).forEach(item => {
total += item.quantity;

const li = document.createElement('li');
li.textContent = `${item.name} x ${item.quantity}`;
cartList.appendChild(li);
});

// 3. 更新總數顯示
totalDisplay.textContent = total.toString();
});
</script>

第四步:在頁面中使用

在你的 index.astro 頁面引入這兩個元件:

---
import ProductList from '../components/ProductList.astro';
import Cart from '../components/Cart.astro';
---

<main>
<h1>我的 Astro 商店</h1>
<div style="display: flex; gap: 50px;">
<ProductList />
<Cart />
</div>
</main>

扎實學習的小筆記:

  1. 為什麼用 setKey 而不是 set? 使用 setKey('id', value) 只會更新特定項目,對於效能更好,且代碼更簡潔,不需要每次都展開(spread)整個舊物件。
  2. subscribe 的威力: 注意到嗎?當你在 ProductList 點擊按鈕時,Cart 元件會自動感應並更新。這就是 Nano Stores 解決「跨元件溝通」的方式。

進階挑戰:

現在你已經完成了基礎加入功能,試著在 cartStore.js 增加一個 removeFromCart(id) 函數,並在 Cart.astro 裡為每個項目加上一個「刪除」按鈕。


📚 額外筆記

1. 關於 appendChild

// Cart 練習題
cartList.append(li)

vscode 提供關鍵字 appendappendChild,這兩者差在哪裡?

這兩者在功能上很像,都是把元素塞進父節點,但 append() 是現代 JavaScript 的標準,而 appendChild() 是歷史悠久的傳統方法。

以下是它們的 4 大核心差異

① 能否同時加入「多個內容」

  • append():可以一次加入多個元素或字串。
  • appendChild():一次只能加入 一個 節點(Node)。
const parent = document.createElement('div')
const child = document.createElement('p')

// ✅ append 可以一次塞很多東西
parent.append(child, 'Hello', document.createElement('span'))

// ❌ appendChild 只能塞一個,且不接受字串
parent.appendChild(child)

② 是否支援「純文字字串」

  • append():支援直接傳入字串,它會自動幫你轉成「文字節點」(Text Node)。
  • appendChild()不支援字串,你必須先手動建立 createTextNode
// ✅ OK
cartList?.append('這是一段文字')

// ❌ 會報錯 (Uncaught TypeError: Failed to execute 'appendChild' on 'Node')
cartList?.appendChild('這是一段文字')

③ 回傳值 (Return Value)

  • append():回傳 undefined。
  • appendChild():會回傳剛被加入的那個節點
const returnedValue = parent.appendChild(child)
console.log(returnedValue === child) // true

④ 瀏覽器支援度 (Compatibility)

  • appendChild():老祖宗等級,支援所有瀏覽器(包括早已作古的 IE6)。
  • append():較新的 API(ES6 之後),不支援 IE。但在現代開發(如你的 Astro 專案)中,這完全不是問題,大家現在都優先使用 append()

總結

vocus|新世代的創作平台

2. 關於 addToCart

原先寫法 ✅

import { map } from 'nanostores'

interface CartItem {
id: string
name: string
price: number
quantity: number
}

export const $cart = map<Record<string, CartItem>>({})

/**
* 加入購物車的邏輯
* @param {CartItem} item - 包含 id, name, price 的物件
*/
export function addToCart(item: CartItem) {
const existingItem = $cart.get()[item.id]
if (existingItem) {
$cart.setKey(item.id, {
...existingItem,
quantity: existingItem.quantity + 1,
})
} else {
$cart.setKey(item.id, { ...item, quantity: 1 })
}
}

如果這樣寫 ❓

import { map } from 'nanostores'

interface CartItem {
id: string
name: string
price: number
quantity: number
}

// 初始購物車 = 空物件
export const $cart = map<Record<string, CartItem>>({})

export function addToCart(item: CartItem) {
const existingItem = $cart.get()[item.id]
if (existingItem) {
existingItem.quantity++
} else {
$cart.setKey(item.id, { ...item, quantity: 1 })
}
}

這段程式碼邏輯在一般的 JavaScript 物件操作中看起來很自然,
但在 Nanostores(以及大多數狀態管理庫)中,直接修改 existingItem.quantity++ 是行不通的

為什麼不可以這樣寫?

Nanostores 是基於「響應式」原理。當你執行 existingItem.quantity++ 時:

  1. 你直接修改了記憶體中的物件屬性。
  2. Nanostores 偵測不到這個變化,因為 Store 的參照(Reference)沒有改變。
  3. 結果:你的訂閱者(例如 UI 介面)不會更新,雖然數據變了,但畫面沒反應。

正確的寫法

你必須使用 $cart.setKey() 或 $cart.set(),這樣 Nanostores 才會發出「數據已更新」的通知。

✅ 推薦:使用 setKey(效能最好)

export function addToCart(item: CartItem) {
const cart = $cart.get()
const existingItem = cart[item.id]

if (existingItem) {
// 透過 setKey 更新,Nanostores 會觸發 UI 更新
$cart.setKey(item.id, {
...existingItem,
quantity: existingItem.quantity + 1,
})
} else {
$cart.setKey(item.id, { ...item, quantity: 1 })
}
}

為什麼要用 ...existingItem?(不可變性 Immutable)
在現代前端開發中,我們傾向於「產生一個新物件」而不是「修改舊物件」。

vocus|新世代的創作平台

3. 關於 <Cart />

① 原本寫法: 訂閱 $cart 的變化。
使用純 JavaScript 在 <script> 中手動更新

② 使用框架組件(如 React / Vue / Preact)
NanoStores 的強大之處在於它有針對不同框架的 Hook,能讓組件自動隨著 Store 更新。

如果把 Cart 改用 React 寫:

// Cart.tsx
import { useStore } from '@nanostores/react'
import { $cart } from './cartStore'

export const Cart = () => {
const items = useStore($cart) // 這是關鍵!它會自動監聽並觸發重新渲染
const cartItems = Object.values(items)

return (
<div>
<ul>
{cartItems.map((item) => (
<li key={item.id}>
{item.name} x {item.quantity}
</li>
))}
</ul>
</div>
)
}

在 index.astro 中引用這個 Cart.tsx 並加上 client:load,它就會在點擊按鈕時立刻更新了。

留言
avatar-img
李昀瑾的沙龍
0會員
37內容數
李昀瑾的沙龍的其他內容
2026/01/23
✏️ 專題二實作 1. 建立 Store (src/store/themeStore.ts) import { atom } from 'nanostores' // 預設為 false (淺色模式) export const $isDark = atom(false) 2. 建立切換按鈕
Thumbnail
2026/01/23
✏️ 專題二實作 1. 建立 Store (src/store/themeStore.ts) import { atom } from 'nanostores' // 預設為 false (淺色模式) export const $isDark = atom(false) 2. 建立切換按鈕
Thumbnail
2026/01/20
Nano Stores與其它的狀態管理工具(如 Redux 或 Pinia)不同 Nano Stores 是 不可知框架(Framework-agnostic) 的。這意味著你可以在同一個 Astro 專案中,讓 React、Vue、Svelte 和原生 JS 共享同一個狀態。 第一階段:核心概
2026/01/20
Nano Stores與其它的狀態管理工具(如 Redux 或 Pinia)不同 Nano Stores 是 不可知框架(Framework-agnostic) 的。這意味著你可以在同一個 Astro 專案中,讓 React、Vue、Svelte 和原生 JS 共享同一個狀態。 第一階段:核心概
2026/01/17
專題二:Nano Stores —— 連結孤島的橋樑 為什麼需要它? 在 Astro 中,每個互動組件(React, Vue, Svelte)都是一個獨立的「孤島 (Island)」。 問題:如果你在 Header.tsx (React) 有一個購物車圖示,在 ProductCard.tsx 
2026/01/17
專題二:Nano Stores —— 連結孤島的橋樑 為什麼需要它? 在 Astro 中,每個互動組件(React, Vue, Svelte)都是一個獨立的「孤島 (Island)」。 問題:如果你在 Header.tsx (React) 有一個購物車圖示,在 ProductCard.tsx 
看更多
你可能也想看
Thumbnail
Nuxt.js 是以 Vue 為基底所建構的框架,透過 Nuxt.js,我們能夠更輕鬆地開發靜態頁面 (Static Site)、操作體驗良好的單頁式網站 (SPA)、甚至是顧及 SEO 的伺服器端渲染 (SSR) 網站。
Thumbnail
Nuxt.js 是以 Vue 為基底所建構的框架,透過 Nuxt.js,我們能夠更輕鬆地開發靜態頁面 (Static Site)、操作體驗良好的單頁式網站 (SPA)、甚至是顧及 SEO 的伺服器端渲染 (SSR) 網站。
Thumbnail
《轉轉生》(Re:INCARNATION)為奈及利亞編舞家庫德斯.奧尼奎庫與 Q 舞團創作的當代舞蹈作品,結合拉各斯街頭節奏、Afrobeat/Afrobeats、以及約魯巴宇宙觀的非線性時間,建構出關於輪迴的「誕生—死亡—重生」儀式結構。本文將從約魯巴哲學概念出發,解析其去殖民的身體政治。
Thumbnail
《轉轉生》(Re:INCARNATION)為奈及利亞編舞家庫德斯.奧尼奎庫與 Q 舞團創作的當代舞蹈作品,結合拉各斯街頭節奏、Afrobeat/Afrobeats、以及約魯巴宇宙觀的非線性時間,建構出關於輪迴的「誕生—死亡—重生」儀式結構。本文將從約魯巴哲學概念出發,解析其去殖民的身體政治。
Thumbnail
本文分析導演巴里・柯斯基(Barrie Kosky)如何運用極簡的舞臺配置,將布萊希特(Bertolt Brecht)的「疏離效果」轉化為視覺奇觀與黑色幽默,探討《三便士歌劇》在當代劇場中的新詮釋,並藉由舞臺、燈光、服裝、音樂等多方面,分析該作如何在保留批判核心的同時,觸及觀眾的觀看位置與人性幽微。
Thumbnail
本文分析導演巴里・柯斯基(Barrie Kosky)如何運用極簡的舞臺配置,將布萊希特(Bertolt Brecht)的「疏離效果」轉化為視覺奇觀與黑色幽默,探討《三便士歌劇》在當代劇場中的新詮釋,並藉由舞臺、燈光、服裝、音樂等多方面,分析該作如何在保留批判核心的同時,觸及觀眾的觀看位置與人性幽微。
Thumbnail
這是一場修復文化與重建精神的儀式,觀眾不需要完全看懂《遊林驚夢:巧遇Hagay》,但你能感受心與土地團聚的渴望,也不急著在此處釐清或定義什麼,但你的在場感受,就是一條線索,關於如何找著自己的路徑、自己的聲音。
Thumbnail
這是一場修復文化與重建精神的儀式,觀眾不需要完全看懂《遊林驚夢:巧遇Hagay》,但你能感受心與土地團聚的渴望,也不急著在此處釐清或定義什麼,但你的在場感受,就是一條線索,關於如何找著自己的路徑、自己的聲音。
Thumbnail
背景:從冷門配角到市場主線,算力與電力被重新定價   小P從2008進入股市,每一個時期的投資亮點都不同,記得2009蘋果手機剛上市,當時蘋果只要在媒體上提到哪一間供應鏈,隔天股價就有驚人的表現,當時光學鏡頭非常熱門,因為手機第一次搭上鏡頭可以拍照,也造就傳統相機廠的殞落,如今手機已經全面普及,題
Thumbnail
背景:從冷門配角到市場主線,算力與電力被重新定價   小P從2008進入股市,每一個時期的投資亮點都不同,記得2009蘋果手機剛上市,當時蘋果只要在媒體上提到哪一間供應鏈,隔天股價就有驚人的表現,當時光學鏡頭非常熱門,因為手機第一次搭上鏡頭可以拍照,也造就傳統相機廠的殞落,如今手機已經全面普及,題
Thumbnail
這是一場從「網路連結 → 線下見面」的活動,我一開始其實有些猶豫,畢竟地點對我來說不近,加上平常在社群裡其實不太主動互動。 但因為主辦人西打誠意滿滿地邀請,甚至還提出補貼車資,最後我決定自費參加。現在回頭看,真的很值得! 場地很有感,氛圍超溫暖 一踏進場地就被暖黃的燈光包圍,小閣樓超舒適,還
Thumbnail
這是一場從「網路連結 → 線下見面」的活動,我一開始其實有些猶豫,畢竟地點對我來說不近,加上平常在社群裡其實不太主動互動。 但因為主辦人西打誠意滿滿地邀請,甚至還提出補貼車資,最後我決定自費參加。現在回頭看,真的很值得! 場地很有感,氛圍超溫暖 一踏進場地就被暖黃的燈光包圍,小閣樓超舒適,還
Thumbnail
網站開發專案成功的關鍵在於與客戶的有效溝通。本文分享一個成功案例,說明如何透過明確掌握專案需求、主動提供技術方案、定期回報進度、完善技術協助及建立良好客戶關係,順利完成一個中文影片學習分享網站的建置,並獲得客戶高度滿意與後續合作機會。
Thumbnail
網站開發專案成功的關鍵在於與客戶的有效溝通。本文分享一個成功案例,說明如何透過明確掌握專案需求、主動提供技術方案、定期回報進度、完善技術協助及建立良好客戶關係,順利完成一個中文影片學習分享網站的建置,並獲得客戶高度滿意與後續合作機會。
Thumbnail
從實際應用中學習 Python 程式設計,提升技能並建立作品集。文章提供八個循序漸進的 Python 專案範例,涵蓋檔案操作、網路爬蟲、Web 應用、自動化腳本、數據分析、遊戲開發、API 互動及應用程式部署,並附上實戰建議及學習資源。
Thumbnail
從實際應用中學習 Python 程式設計,提升技能並建立作品集。文章提供八個循序漸進的 Python 專案範例,涵蓋檔案操作、網路爬蟲、Web 應用、自動化腳本、數據分析、遊戲開發、API 互動及應用程式部署,並附上實戰建議及學習資源。
追蹤感興趣的內容從 Google News 追蹤更多 vocus 的最新精選內容追蹤 Google News