Astro 進階課程 2. 狀態管理與跨組件通訊 —— Nano Stores - 實作筆記

更新 發佈閱讀 17 分鐘

✏️ 專題二實作

1. 建立 Store (src/store/themeStore.ts)

import { atom } from 'nanostores'

// 預設為 false (淺色模式)
export const $isDark = atom(false)

2. 建立切換按鈕 (src/components/ThemeToggle.tsx)

import { useStore } from '@nanostores/react'
import { $isDark } from '../store/themeStore'

export default function ThemeToggle() {
const isDark = useStore($isDark)

return (
<button
onClick={() => $isDark.set(!isDark)}
className="p-2 rounded-lg bg-gray-200 dark:bg-slate-700 transition-colors"
>
{isDark ? '🌙 深色模式' : '☀️ 淺色模式'}
</button>
)
}

3. 在 Layout 中監聽並切換 Class (src/layouts/BaseLayout.astro)

這是最進階的部分。因為 <html> 標籤在 Astro 組件中,而 useStore是 React 的 Hook,我們不能直接在 Astro 的 Frontmatter 使用它來控制 HTML 屬性。

---
import ThemeToggle from '../components/ThemeToggle';
---

<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<title>NanoStore Dark Mode</title>
</head>
<body class="bg-white text-black dark:bg-slate-900 dark:text-white transition-colors duration-300">
<nav class="p-4 border-b">
<ThemeToggle client:load />
</nav>

<slot />

<script>
import { $isDark } from '../store/themeStore';

// 訂閱 Store 的變化
$isDark.subscribe((isDark) => {
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
});
</script>
</body>
</html>

4. 修改 src/styles/global.css (或你的主 CSS 檔)

在 Tailwind v4 中,要啟用手動切換深色模式,你必須在 CSS 中明確指定 variant

@import 'tailwindcss';

/* 關鍵設定:手動指定 dark 模式的觸發方式 */
@custom-variant dark (&:where(.dark, .dark *));

5. 測試效果

可以在任何頁面的元素上加上 dark: 前綴來測試:

<div class="p-10 bg-blue-500 dark:bg-red-500">
此區塊在淺色時是藍色,深色時會變成紅色。
</div>

📚 額外筆記

✅ Nanostores 是什麼

Nanostores 是一個專為現代前端開發設計的 狀態管理庫 (State Management Library)。它的核心理念是「極簡」、「原生支持多框架」以及「極小體積」。

如果你用過 Redux、Vuex 或 Pinia,Nanostores 提供的功能類似,但其哲學非常不同。

1. 核心特性

  • 極小體積 (Tiny Weight): 它的代碼量非常少(通常小於 1 KB),對應用的加載速度幾乎沒有影響。
  • 框架無關 (Framework Agnostic): 這是它最大的特色。你可以同一套狀態邏輯,同時用在 React、Vue、Svelte、Solid 甚至是原生 JavaScript 中。
  • 原子化 (Atomic State): 狀態被拆分成許多微小的「原子(Atoms)」,只有訂閱了該原子的組件才會在數據變化時重新渲染。
  • 樹搖優化 (Tree-shakable): 只會打包你實際用到的代碼,適合對性能要求極高的項目。

2. 為何選擇 Nanostores?

通常我們在開發大型應用時,狀態管理往往與特定的框架綁定。但隨著 Astro 這種多框架並存的方案流行,Nanostores 變得非常有用:

  • 跨框架通信: 假設你的頁面頂部導航欄是用 React 寫的,而側邊欄是用 Vue 寫的,你可以使用 Nanostores 讓這兩個組件共享同一個「購物車」或「用戶登入」狀態。
  • 代碼可移植性: 邏輯與 UI 框架分離,方便未來更換前端技術棧。

3. 基本運作邏輯

Nanostores 主要通過兩種核心對象來管理數據:

  1. atom: 用於管理單一的基礎數據(如數字、字符串)。
  2. map: 用於管理複雜的對象或字典。

代碼示例(以簡單計數器為例):

// store.js - 定義狀態
import { atom } from 'nanostores'

export const $counter = atom(0) // 加上 $ 是慣例,代表這是一個 store

export function increaseCounter() {
$counter.set($counter.get() + 1)
}
// React 組件中使用
import { useStore } from '@nanostores/react'
import { $counter, increaseCounter } from './store.js'

export const Counter = () => {
const count = useStore($counter) // 自動訂閱並更新
return <button onClick={increaseCounter}>{count}</button>
}

4. 總結

Nanostores 非常適合以下場景:

  • 使用 Astro 構建的多框架項目。
  • 對 Bundle Size(打包體積)有嚴苛要求的微型應用。
  • 希望將業務邏輯從 UI 框架中完全解耦。

如果你正在尋找一個比 Redux 輕量、比原生 API 更強大且能跨框架使用的方案,Nanostores 是一個極佳的選擇。


❓ const isDark = useStore($isDark)

在 Nanostores 的語境下,$isDark 是資料來源(Store),而 useStore 是 React 提供的一個掛鉤(Hook),用來監測這個來源。

核心運作邏輯

可以把這行程式碼拆解成三個層次來看:

  1. 監聽 (Listening): useStore 會告訴 React:「嘿,請幫我盯著 $isDark 這個原子。一旦它的值變了,請重新渲染(Re-render)這個組件。」
  2. 同步 (Syncing): 它會把 $isDark 目前內含的值(例如 false)提取出來,賦值給本地變數 isDark
  3. 自動化: 當組件被銷毀(例如使用者切換頁面)時,useStore 會自動取消訂閱,防止記憶體洩漏。

✅ 關於 @nanostores/react

1. 為什麼 React 官方沒有 useStore

React 官方提供的工具(Hooks)通常是以 use... 開頭,例如:

  • useState: 用來管理組件內部的私有狀態。
  • useEffect: 用來處理副作用。
  • useContext: 用來讀取 React 自身的 Context。

React 並不知道 Nanostores 的存在。Nanostores 是一個獨立於框架之外的狀態管理庫(它甚至可以用在 Vue, Svelte 或純 JS 中)。

2. @nanostores/react 的角色:翻譯官

@nanostores/react 是一個專門為 React 寫的「適配器(Adapter)」。

它的作用是將 Nanostores 的原始資料轉換成 React 能理解的狀態。當你呼叫 useStore($isDark) 時,它底層其實幫你做了這幾件事:

  1. 在組件掛載時,手動訂閱 $isDark
  2. 當 $isDark 改變時,觸發 React 內部的 setState 讓畫面更新。
  3. 在組件卸載時,自動取消訂閱。

3. 對比表格:區分不同來源的 Hook

raw-image

4. 總結你的程式碼邏輯

在你的任務中:
你的資料(Store)放在 themeStore.ts
你的組件(React)需要讀取它。
結論:你必須使用 @nanostores/react 提供的 useStore 作為兩者之間的橋樑。

💡 小提醒: 如果你在 Astro 專案中同時使用了 Vue 組件,你會發現你需要 import { useStore } from '@nanostores/vue'。這就是 Nanostores 強大的地方——同一個 Store,不同的橋樑。


✅ Atom

  • import { Atom } from "nanostores";
  • import { atom } from "nanostores";

在 nanostores 的庫中,這兩者的區別在於 「執行邏輯」 與 「類型定義」 的不同。
簡單來說:atom 是你拿來「用」的工具,而 Atom 是你拿來「看」的規格

1. atom (小寫):建立 Store 的函數

這是一個 JavaScript 函數。當你想創建一個新的狀態(State)時,你會呼叫它。

  • 用途:初始化一個存儲數據的實體。
  • 特性:它會返回一個包含 .get().set().subscribe() 等方法的物件。

1.1 常用的三大方法

  • .set(newValue): 直接覆蓋 Store 的值。
  • .subscribe(callback): 最重要的方法。它會在你訂閱的那一刻「立即執行一次」,之後每當值變動時都會再次執行。它會返回一個 unsub函數,呼叫它就可以停止監聽。
  • .get(): 當你只需要「現在這一秒」的值,而不需要後續監聽時使用。例如:在一個 Function 裡判斷 if ($isDark.get()) { ... }

1.2 進階的「生命週期」方法

這部分通常是進階開發者用來優化效能的,隱藏在 onMount 等工具函數中:

  • onMount: 這不是 Atom 的方法,但它是配合 Atom 使用的。你可以定義當「有人開始聽我」和「所有人都停止聽我」時要做什麼。
    • 應用場景:當有人訂閱時,才開始建立 WebSocket 連線;沒人聽時就斷開連線,節省效能。

1.3 與 Map Store 的區別

如果你使用的是 atom,它就像一個單一儲存格。 但如果你改用 map(另一種 Nanostores 類型),它會有更多方法:

  • .setKey(key, value):只修改物件中的某個屬性,而不是覆蓋整個物件。

2. 為什麼要有 .get() 而不直接用變數?

這涉及到 JavaScript 的引用機制。 如果 $isDark 只是個普通變數,一旦它被導入到其他檔案,它就死掉了(無法追蹤更新)。 包裝成 Atom 後,它變成了一個物件。當你執行 $isDark.get(),你是在向這個物件「請求」它肚子裡的最新資料。

import { atom } from 'nanostores'

// 建立一個初始值為 0 的 store
export const $counter = atom(0)

// 操作 store
$counter.set(10)
console.log($counter.get()) // 輸出 10

2. Atom (大寫):TypeScript 的類型 (Type)

這是一個 TypeScript 接口(Interface)。它在程式執行時不存在,只在開發階段用來標記型別。

  • 用途:當你寫一個函數,而這個函數需要接收一個 store 作為參數時,用來告訴編輯器這個參數必須符合 Atom 的規格。
  • 特性Atom<T> 通常代表「唯讀」的規格(只有 .get 和 .subscribe)。如果你需要代表「可寫入」的規格,會使用 WritableAtom
  • WritableAtom<T>:代表一個「可寫入」的 Store 接口(有 .set() 和 .setKey())。
import { Atom, atom } from 'nanostores'

const $status = atom('loading')

// 使用 Atom 作為參數類型,確保傳進來的是一個 nanostores 實體
function checkStatus(store: Atom<string>) {
console.log('當前狀態是:' + store.get())
}

checkStatus($status)

🧠 專題二 - 運作流程

1. themeStore.ts:資料的中心

它不屬於任何一個頁面,它是一個獨立的 Atom (原子)。它的作用只有一個:守護 isDark 這個變數。

  • 它提供 set() 方法讓別人修改值。
  • 它提供 subscribe() 方法讓別人監聽變化。

2. ThemeToggle.tsx:使用者互動

這是一個 React 組件

  • 它讀取值:透過 useStore($isDark)。當 Store 變動時,React 會發現 isDark 變了,於是按鈕上的文字會從「🌙」變成「☀️」。

-- 它修改值:當你點擊 onClick 時,它執行 $isDark.set(!isDark)。這一行程式碼會把新訊息傳回給 themeStore.ts

3. BaseLayout.astro:全局控制

這是最關鍵的一步。雖然按鈕變了,但如果我們不操作 DOM,網頁背景還是不會變。

  • 為什麼要寫在 <script> 裡? 因為 Astro 的 HTML 渲染完後就「固定」了。為了讓網頁能實時變色,我們需要一段 JavaScript 在瀏覽器裡跑。
  • 運作機制: 這段腳本會向 themeStore.ts 訂閱(Subscribe)。

完成 進階課程 2 ,照理說下一篇要是 專題三:持久化資料存放 —— 整合 Supabase 或 Drizzle ORM ,但還是對 課程2 懵懵懂懂,所以接下來幾小篇會再多練習一下 👍

留言
avatar-img
李昀瑾的沙龍
0會員
24內容數
李昀瑾的沙龍的其他內容
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 
2026/01/15
🚀 專題一:Tailwind CSS 實戰開始 我們先從最能直接提升成就感的 Tailwind CSS 開始。 1. 安裝 Tailwind 在你的專案目錄執行: npx astro add tailwind (全部選 Yes,這會自動設定 astro.config.mjs 並引入指令
2026/01/15
🚀 專題一:Tailwind CSS 實戰開始 我們先從最能直接提升成就感的 Tailwind CSS 開始。 1. 安裝 Tailwind 在你的專案目錄執行: npx astro add tailwind (全部選 Yes,這會自動設定 astro.config.mjs 並引入指令
看更多