Astro 專題 - DevPulse:極簡開發者技術雷達 (Tech Radar)-3

更新 發佈閱讀 29 分鐘

(進階) 搭配 Nano Stores 完成「即時搜尋」

  1. 靜態 HTML: 先用 Astro 渲染出所有的技術卡片。
  2. Client Component: 建立一個 SearchBar.tsx(或 Vue/Svelte),當使用者輸入時,修改 techStore.js 中的搜尋字串。
  3. Vanilla JS / Framework JS: 監聽 Store 的變化,透過 CSS 類別(例如 Tailwind 的 hidden)來隱藏不符合條件的卡片。

1. 建立 react 的 TypeScript 定義檔

# 於 terminal 執行:
npm install @types/react @types/react-dom

💡 1. 什麼是 @types

在 JavaScript 的世界裡,很多套件(包含 React)是用純 JS 寫的。雖然代碼能跑,但開發工具並不知道這些函數「應該」接收什麼參數、回傳什麼資料。

@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。」

為什麼會發生這個錯誤?

  1. TypeScript 太嚴謹: 它要求專案中所有的變數、組件、標籤都必須有明確的「身份證」。
  2. 缺少定義檔: @types/react 裡面就包含了 JSX.IntrinsicElements 的定義。它告訴 TypeScript:<div> 是合法的,而且它有 classNameonClick 等屬性。

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

這行指令會自動幫你:

  1. 安裝 @astrojs/react 套件。
  2. 安裝 react 與 react-dom
  3. 自動更新 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 增加 輸入 效果

  1. 在 SearchBar.tsx 使用 useState,宣告 input、setInput 作為輸入值的取值與設定
  2. 增加一個區塊測試輸入功能,檢查於輸入框輸入時,測試區塊是否會同步
// 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
留言
avatar-img
李昀瑾的沙龍
0會員
35內容數
李昀瑾的沙龍的其他內容
2026/03/05
(進階) 在 Astro 5 中讀取並顯示 我們可以直接在頁面中把這些資料抓出來,並根據 status 分類。
2026/03/05
(進階) 在 Astro 5 中讀取並顯示 我們可以直接在頁面中把這些資料抓出來,並根據 status 分類。
2026/02/26
這是一個讓開發者管理自己「想學、正在學、已精通」技術清單的工具,強調極速的頁面切換與流暢的 UI。 環境架構設計 astro v5 tailwind v4 Nano Stores vercel 佈署
2026/02/26
這是一個讓開發者管理自己「想學、正在學、已精通」技術清單的工具,強調極速的頁面切換與流暢的 UI。 環境架構設計 astro v5 tailwind v4 Nano Stores vercel 佈署
2026/02/19
要在 Astro v5 儀表板中加入互動圖表,最推薦的方式是使用 Recharts 或 Chart.js 配合 React 或 Vue 組件,因為圖表需要客戶端(Client-side)的 JavaScript 來達成縮放、懸停提示等互動功能。
2026/02/19
要在 Astro v5 儀表板中加入互動圖表,最推薦的方式是使用 Recharts 或 Chart.js 配合 React 或 Vue 組件,因為圖表需要客戶端(Client-side)的 JavaScript 來達成縮放、懸停提示等互動功能。
看更多
你可能也想看
Thumbnail
本文分析導演巴里・柯斯基(Barrie Kosky)如何運用極簡的舞臺配置,將布萊希特(Bertolt Brecht)的「疏離效果」轉化為視覺奇觀與黑色幽默,探討《三便士歌劇》在當代劇場中的新詮釋,並藉由舞臺、燈光、服裝、音樂等多方面,分析該作如何在保留批判核心的同時,觸及觀眾的觀看位置與人性幽微。
Thumbnail
本文分析導演巴里・柯斯基(Barrie Kosky)如何運用極簡的舞臺配置,將布萊希特(Bertolt Brecht)的「疏離效果」轉化為視覺奇觀與黑色幽默,探討《三便士歌劇》在當代劇場中的新詮釋,並藉由舞臺、燈光、服裝、音樂等多方面,分析該作如何在保留批判核心的同時,觸及觀眾的觀看位置與人性幽微。
Thumbnail
這是一場修復文化與重建精神的儀式,觀眾不需要完全看懂《遊林驚夢:巧遇Hagay》,但你能感受心與土地團聚的渴望,也不急著在此處釐清或定義什麼,但你的在場感受,就是一條線索,關於如何找著自己的路徑、自己的聲音。
Thumbnail
這是一場修復文化與重建精神的儀式,觀眾不需要完全看懂《遊林驚夢:巧遇Hagay》,但你能感受心與土地團聚的渴望,也不急著在此處釐清或定義什麼,但你的在場感受,就是一條線索,關於如何找著自己的路徑、自己的聲音。
Thumbnail
5 月將於臺北表演藝術中心映演的「2026 北藝嚴選」《海妲・蓋柏樂》,由臺灣劇團「晃晃跨幅町」製作,本文將以從舞台符號、聲音與表演調度切入,討論海妲・蓋柏樂在父權社會結構下的困境,並結合榮格心理學與馮.法蘭茲對「阿尼姆斯」與「永恆少年」原型的分析,理解女人何以走向精神性的操控、毀滅與死亡。
Thumbnail
5 月將於臺北表演藝術中心映演的「2026 北藝嚴選」《海妲・蓋柏樂》,由臺灣劇團「晃晃跨幅町」製作,本文將以從舞台符號、聲音與表演調度切入,討論海妲・蓋柏樂在父權社會結構下的困境,並結合榮格心理學與馮.法蘭茲對「阿尼姆斯」與「永恆少年」原型的分析,理解女人何以走向精神性的操控、毀滅與死亡。
Thumbnail
《轉轉生》(Re:INCARNATION)為奈及利亞編舞家庫德斯.奧尼奎庫與 Q 舞團創作的當代舞蹈作品,結合拉各斯街頭節奏、Afrobeat/Afrobeats、以及約魯巴宇宙觀的非線性時間,建構出關於輪迴的「誕生—死亡—重生」儀式結構。本文將從約魯巴哲學概念出發,解析其去殖民的身體政治。
Thumbnail
《轉轉生》(Re:INCARNATION)為奈及利亞編舞家庫德斯.奧尼奎庫與 Q 舞團創作的當代舞蹈作品,結合拉各斯街頭節奏、Afrobeat/Afrobeats、以及約魯巴宇宙觀的非線性時間,建構出關於輪迴的「誕生—死亡—重生」儀式結構。本文將從約魯巴哲學概念出發,解析其去殖民的身體政治。
Thumbnail
這份是我在使用Lovable做網站時,邊做邊紀錄的完整攻略,分享給需要的人。 給開發者/接案者的前言: Lovable 是一個強大的 AI 全端開發工具,要用它來賺錢,您必須清楚它的邊界在哪裡。這份指南將協助您完全掌控這個工具,從而自信地向客戶報價。 第一章:深度認識 Lovable 1.1
Thumbnail
這份是我在使用Lovable做網站時,邊做邊紀錄的完整攻略,分享給需要的人。 給開發者/接案者的前言: Lovable 是一個強大的 AI 全端開發工具,要用它來賺錢,您必須清楚它的邊界在哪裡。這份指南將協助您完全掌控這個工具,從而自信地向客戶報價。 第一章:深度認識 Lovable 1.1
Thumbnail
這是一場從「網路連結 → 線下見面」的活動,我一開始其實有些猶豫,畢竟地點對我來說不近,加上平常在社群裡其實不太主動互動。 但因為主辦人西打誠意滿滿地邀請,甚至還提出補貼車資,最後我決定自費參加。現在回頭看,真的很值得! 場地很有感,氛圍超溫暖 一踏進場地就被暖黃的燈光包圍,小閣樓超舒適,還
Thumbnail
這是一場從「網路連結 → 線下見面」的活動,我一開始其實有些猶豫,畢竟地點對我來說不近,加上平常在社群裡其實不太主動互動。 但因為主辦人西打誠意滿滿地邀請,甚至還提出補貼車資,最後我決定自費參加。現在回頭看,真的很值得! 場地很有感,氛圍超溫暖 一踏進場地就被暖黃的燈光包圍,小閣樓超舒適,還
Thumbnail
從實際應用中學習 Python 程式設計,提升技能並建立作品集。文章提供八個循序漸進的 Python 專案範例,涵蓋檔案操作、網路爬蟲、Web 應用、自動化腳本、數據分析、遊戲開發、API 互動及應用程式部署,並附上實戰建議及學習資源。
Thumbnail
從實際應用中學習 Python 程式設計,提升技能並建立作品集。文章提供八個循序漸進的 Python 專案範例,涵蓋檔案操作、網路爬蟲、Web 應用、自動化腳本、數據分析、遊戲開發、API 互動及應用程式部署,並附上實戰建議及學習資源。
Thumbnail
網站開發專案成功的關鍵在於與客戶的有效溝通。本文分享一個成功案例,說明如何透過明確掌握專案需求、主動提供技術方案、定期回報進度、完善技術協助及建立良好客戶關係,順利完成一個中文影片學習分享網站的建置,並獲得客戶高度滿意與後續合作機會。
Thumbnail
網站開發專案成功的關鍵在於與客戶的有效溝通。本文分享一個成功案例,說明如何透過明確掌握專案需求、主動提供技術方案、定期回報進度、完善技術協助及建立良好客戶關係,順利完成一個中文影片學習分享網站的建置,並獲得客戶高度滿意與後續合作機會。
追蹤感興趣的內容從 Google News 追蹤更多 vocus 的最新精選內容追蹤 Google News