(進階) 搭配 Nano Stores 完成「即時搜尋」
- 靜態 HTML: 先用 Astro 渲染出所有的技術卡片。
- Client Component: 建立一個 SearchBar.tsx(或 Vue/Svelte),當使用者輸入時,修改 techStore.js 中的搜尋字串。
- Vanilla JS / Framework JS: 監聽 Store 的變化,透過 CSS 類別(例如 Tailwind 的 hidden)來隱藏不符合條件的卡片。
1. 建立 react 的 TypeScript 定義檔
# 於 terminal 執行:
npm install @types/react @types/react-dom
💡 1. 什麼是 @types?
@types 是由一個叫 DefinitelyTyped 的社群專案維護的 TypeScript 定義檔。
💡 2. 這行指令具體做了什麼?
當執行 npm install @types/react @types/react-dom 時:
- @types/react: 下載 React 核心功能的「說明書」(定義檔)。
- @types/react-dom: 下載處理瀏覽器 DOM 相關功能的「說明書」。
💡 3. 安裝後有什麼好處?
就算不是寫 TypeScript,只要在 VS Code 裡開發,安裝這些定義檔後會立即獲得以下升級:
- 自動補完 (Auto-completion): 當你打 useState 或 useEffect 時,編輯器會主動提示用法。
- 錯誤提示 (Error Checking): 如果你傳錯了參數(例如應該傳數字卻傳了字串),編輯器會直接畫紅線提醒你。
- 文檔預覽: 滑鼠移到函數上方,會直接顯示該 API 的用途和參數說明。
💡 4. 誰需要用到它?
- TypeScript 開發者:必裝 。沒裝的話,TypeScript 會因為找不到類型定義而報錯,程式碼甚至無法編譯。
- 純 JavaScript 開發者: 強烈建議安裝。為了獲得更好的開發體驗(自動補完與防呆)。
💡 5. 如果沒有安裝的話?
在 TypeScript 環境下 (.tsx),IDE 會出現錯誤訊息
JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.
意思是,「老兄,我看到你寫了 <div> 或 <App />,但我根本不知道 React 是什麼,也不知道網頁標籤有哪些屬性可以放。因為我找不到對應的定義檔(IntrinsicElements),所以我只能把它當作是什麼都不確定的 any。」
為什麼會發生這個錯誤?
- TypeScript 太嚴謹: 它要求專案中所有的變數、組件、標籤都必須有明確的「身份證」。
- 缺少定義檔:
@types/react裡面就包含了JSX.IntrinsicElements的定義。它告訴 TypeScript:<div>是合法的,而且它有className、onClick等屬性。
2. 建立 Astro 框架下的 react 環境
Astro 預設只懂 HTML、CSS 和它自己的 .astro 語法,並沒有 UI 框架的渲染引擎。
當在 Astro 檔案中引入 React 組件 (例如 Search.jsx 或 Search.tsx) 時會出現錯誤:
NoMatchingRenderer
No matching renderer found.
Unable to render Search.
No valid renderer was found for this file extension.
Did you mean to enable the @astrojs/react, @astrojs/preact, @astrojs/solid-js, @astrojs/vue or @astrojs/svelte integration?
如何解決這個問題?
1. 自動安裝指令(最推薦)
在終端機輸入這行,Astro 的機器人會幫你處理好一切(包括修改設定檔):
npx astro add react這行指令會自動幫你:
- 安裝
@astrojs/react套件。 - 安裝
react與react-dom。 - 自動更新
astro.config.mjs,把 React 加入到integrations陣列中。
2. 手動安裝(如果你想知道背後發生什麼事)
如果你偏好手動操作,流程如下:
- 安裝套件:
npm install @astrojs/react react react-dom
- 修改
astro.config.mjs:
import { defineConfig } from 'astro/config'
import react from '@astrojs/react' // 1. 引入
export default defineConfig({
integrations: [react()], // 2. 啟動
})
💡 關鍵注意:Island Architecture (孤島架構)
在 Astro 中,即便你安裝好了 React,組件預設也只會在伺服器端渲染 (SSR),這意味著組件裡的 JS(如 onClick、useState)在瀏覽器端是不會動的。
如果你希望這個 Search 組件有互動功能,使用它時必須加上 Client Directive:
---
import Search from './Search';
---
<Search client:load />
3. 建立 SearchBar 元件
3.1 新增 src\components\SearchBar.tsx
const SearchBar = () => (
<div className="flex items-center max-w-md mx-auto border border-gray-300 rounded-lg overflow-hidden">
<input
type="text"
placeholder="搜尋..."
className="flex-1 px-4 py-2 text-gray-700 focus:outline-none"
/>
<button className="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700">
🔍
</button>
</div>
)
export default SearchBar
3.2 增加 輸入 效果
- 在
SearchBar.tsx使用 useState,宣告 input、setInput 作為輸入值的取值與設定 - 增加一個區塊測試輸入功能,檢查於輸入框輸入時,測試區塊是否會同步
// SearchBar.tsx
import { useState } from 'react'
const SearchBar = () => {
const [input, setInput] = useState('')
return (
<div className="flex items-center max-w-md mx-auto border border-gray-300 rounded-lg overflow-hidden">
<input
type="text"
placeholder="搜尋..."
className="flex-1 px-4 py-2 text-gray-700 focus:outline-none"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button className="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700">
🔍
</button>
<div role="search">
<h3>測試輸入功能</h3>
<span>{input}</span>
</div>
</div>
)
}
export default SearchBar
3.3 在 index.astro 引入 SearchBar.tsx
💡 使用 SearchBar 元件,需套用 client:load,useState 才會生效
---
import { getCollection } from 'astro:content'
import Layout from '../layouts/Layout.astro'
import SearchBar from '../components/SearchBar'
const allTechs = await getCollection('techs')
const category = {
mastered: allTechs.filter((t) => t.data.status === 'mastered'),
learning: allTechs.filter((t) => t.data.status === 'learning'),
wishlist: allTechs.filter((t) => t.data.status === 'wishlist'),
}
---
<Layout title="DevPulse">
....
<SearchBar client:load />
....
</main>
</Layout>
4. 使用 Nano Stores 管理 SearchBar 的值
4.1 新增 src\store\searchStore.ts
import { atom } from 'nanostores'
export const searchQuery = atom('') // 初始值為空字串
4.2 在 SearchBar.tsx 中實作
你需要使用 @nanostores/react 提供的 useStore 來讀取值,並使用 .set() 來修改值。
// SearchBar.tsx
import { useState } from 'react'
import { useStore } from '@nanostores/react'
import { searchQuery } from '../store/searchStore'
const SearchBar = () => {
// 1. 訂閱 Store 的值,當值改變時。組件會重新渲染
const $searchQuery = useStore(searchQuery)
//2. 當使用者輸入時,即時更新 Store 的內容
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
searchQuery.set(e.target.value)
}
// 3. 可以手動清空
const handleClear = () => searchQuery.set('')
return (
<div className="flex items-center max-w-md mx-auto border border-gray-300 rounded-lg overflow-hidden">
<input
type="text"
placeholder="搜尋..."
className="flex-1 px-4 py-2 text-gray-700 focus:outline-none"
value={$searchQuery}
onChange={handleChange}
/>
{$searchQuery && (
<button className="px-4 cursor-pointer" onClick={handleClear}>
✕
</button>
)}
<button className="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700">
🔍
</button>
<p>目前搜尋:{$searchQuery}</p>
</div>
)
}
export default SearchBar
5. 搜尋時,只顯示搜尋結果
- index.astro 的項目顯示改採用 TechList.tsx 元件顯示
- 以 react 元件取得 nanostores 的即時資料變更顯示內容
5.0 建立 Tech 定義
// src\type\tech.ts
export type TechStatus = 'mastered' | 'learning' | 'wishlist'
export type Tech = {
data: {
status: TechStatus
icon: string
name: string
}
body?: string
}
export type CategoryMap = Record<TechStatus, Tech[]>
5.1 建立 src\components\TechList.tsx
import type { CategoryMap } from '../type/tech'
type TechListProps = {
category: CategoryMap
}
const TechList = function ({ category }: TechListProps) {
return (
<div className="grid gap-8">
{Object.entries(category).map(([status, items]) => (
<section key={status}>
<h2 className="text-2xl font-bold capitalize mb-4 border-b border-gray-200 pb-2">
{status}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{items.map((item, index) => (
<div
key={index}
className="p-4 rounded-xl border border-gray-100 bg-white shadow-sm hover: shadow-md transition-shadow"
>
<span className="text-3xl mb-2 block"> {item.data.icon} </span>
<h3 className="font-bold"> {item.data.name} </h3>
<p className="text-sm text-gray-500 line-clamp-2">
{item.body}
</p>
</div>
))}
</div>
</section>
))}
</div>
)
}
export default TechList
5.2 TechList.tsx 依據 searchQuery 顯示對應項目
// TechList.tsx
import type { CategoryMap } from '../type/tech'
import { useStore } from '@nanostores/react'
import { searchQuery } from '../store/searchStore'
type TechListProps = {
category: CategoryMap
}
const TechList = function ({ category }: TechListProps) {
const $searchQuery = useStore(searchQuery)
const filterCategory: CategoryMap = {
mastered: category.mastered.filter((item) =>
item.body?.includes($searchQuery)
),
learning: category.learning.filter((item) =>
item.body?.includes($searchQuery)
),
wishlist: category.wishlist.filter((item) =>
item.body?.includes($searchQuery)
),
}
return (
<div className="grid gap-8">
{Object.entries(filterCategory).map(([status, items]) => (
<section key={status}>
<h2 className="text-2xl font-bold capitalize mb-4 border-b border-gray-200 pb-2">
{status}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{items.map((item, index) => (
<div
key={index}
className="p-4 rounded-xl border border-gray-100 bg-white shadow-sm hover: shadow-md transition-shadow"
>
<span className="text-3xl mb-2 block"> {item.data.icon} </span>
<h3 className="font-bold"> {item.data.name} </h3>
<p className="text-sm text-gray-500 line-clamp-2">
{item.body}
</p>
</div>
))}
</div>
</section>
))}
</div>
)
}
export default TechList
6. 標題增加展開收合功能
src\components\TechList.tsx
import type { CategoryMap, Tech } from '../type/tech'
import { useState } from 'react'
import { useStore } from '@nanostores/react'
import { searchQuery } from '../store/searchStore'
type TechListProps = {
category: CategoryMap
}
const CategorySection = ({
status,
items,
}: {
status: string
items: Tech[]
}) => {
const [isOpen, setIsOpen] = useState(true)
return (
<section>
<h2
className="flex justify-between items-center text-2xl font-bold capitalize mb-4 border-b border-gray-200 p-2 hover:bg-gray-100"
onClick={() => setIsOpen(!isOpen)}
>
{status}
<svg
className={`w-6 h-6 transition-transform duration-300 ${
isOpen ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</h2>
<div
className={`grid transition-all duration-300 ease-in-out
${
isOpen
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
}
`}
>
<div className="overflow-hidden">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{items.map((item, index) => (
<div
key={index}
className="p-4 rounded-xl border border-gray-100 bg-white shadow-sm hover: shadow-md transition-shadow"
>
<span className="text-3xl mb-2 block"> {item.data.icon} </span>
<h3 className="font-bold"> {item.data.name} </h3>
<p className="text-sm text-gray-500 line-clamp-2">
{item.body}
</p>
</div>
))}
</div>
</div>
</div>
</section>
)
}
const TechList = function ({ category }: TechListProps) {
const $searchQuery = useStore(searchQuery)
const filterCategory: CategoryMap = {
mastered: category.mastered.filter((item) =>
item.body?.includes($searchQuery)
),
learning: category.learning.filter((item) =>
item.body?.includes($searchQuery)
),
wishlist: category.wishlist.filter((item) =>
item.body?.includes($searchQuery)
),
}
return (
<div className="grid gap-8">
{Object.entries(filterCategory).map(([status, items]) => (
<CategorySection status={status} items={items} key={status} />
))}
</div>
)
}
export default TechList










