✏️ 專題二實作
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 主要通過兩種核心對象來管理數據:
atom: 用於管理單一的基礎數據(如數字、字符串)。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),用來監測這個來源。
核心運作邏輯
可以把這行程式碼拆解成三個層次來看:
- 監聽 (Listening):
useStore會告訴 React:「嘿,請幫我盯著$isDark這個原子。一旦它的值變了,請重新渲染(Re-render)這個組件。」 - 同步 (Syncing): 它會把
$isDark目前內含的值(例如 false)提取出來,賦值給本地變數isDark。 - 自動化: 當組件被銷毀(例如使用者切換頁面)時,
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) 時,它底層其實幫你做了這幾件事:
- 在組件掛載時,手動訂閱
$isDark。 - 當
$isDark改變時,觸發 React 內部的setState讓畫面更新。 - 在組件卸載時,自動取消訂閱。
3. 對比表格:區分不同來源的 Hook

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 懵懵懂懂,所以接下來幾小篇會再多練習一下 👍