🧠 第十三章:地圖與側邊欄的靈魂共振 (雙向互動)
在上一章,我們用叢集技術與 Airbnb 風格的價格標籤,打造了一張絕美的露營地圖。 但如果你去用 Google Maps、Agoda 或是真正的 Airbnb,你會發現它們的介面絕對不會只有一張地圖。 通常畫面的左半邊(或手機版的下半部)會有一個**「側邊欄清單 (Sidebar List)」**,顯示附近營地的照片、詳細價格與評價。
這時候,真正的技術挑戰來了:
- 點擊清單聯動地圖:當你在左邊點擊了「星空營地」,右邊的地圖必須滑順地飛到苗栗山區,並把星空營地的標籤放大。
- 拖動地圖聯動清單:當你把地圖拖曳到宜蘭時,左邊的清單必須「自動過濾」,只顯示目前地圖範圍內(宜蘭)的營地,不能再顯示苗栗的營地!
在 React 的世界中,側邊欄 (<Sidebar>) 和地圖 (<CampingMap>) 通常是兩個完全獨立的元件。它們就像是兩個住在不同房間的員工,互相聽不到對方講話。
這堂 6000 字的高階架構課,我們將教你如何用 Vibe Coding,指使 AI 為這兩個元件牽起一條通訊神經,實作這套價值幾十萬的「雙向連動系統」!
🏗️ 實戰 1:狀態提升 (State Lifting) 的核心概念
最直接的解法,就是找一個「主管」,把這兩個元件包起來。這個主管通常就是最外層的 <App> 或 <MapPage> 元件。
由主管來保管「目前選中的營地 (selectedCamp)」這個狀態,然後同時把這個狀態往下發給地圖和側邊欄。
💡 Vibe Prompt 實戰 1:建立主管層級的架構
與其自己去寫複雜的 prop drilling (屬性傳遞),我們直接跟 AI 描述整個系統的「資料流動方向」。
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
我正在用 React 開發一個雙欄佈局的頁面 <ExplorePage>。左邊是 <Sidebar> (顯示營地清單),右邊是 <CampingMap> (顯示 Leaflet 地圖)。請幫我用「狀態提升 (State Lifting)」的模式,在 <ExplorePage> 中建立以下狀態:1. allCamps: 從資料庫抓回來的所有營地資料。2. activeCampId: 目前使用者選中的營地 ID (預設為 null)。請寫出 <ExplorePage> 的程式碼結構,包含如何把 activeCampId 和 setActiveCampId 當作 props 傳遞給 <Sidebar> 和 <CampingMap>。這裡只需寫出骨架結構即可。
🤖 AI 的高階架構規劃:
import React, { useState, useEffect } from 'react';
import Sidebar from './Sidebar';
import CampingMap from './CampingMap';
import { fetchCamps } from './api';
export default function ExplorePage() {
// 主管層級保管的狀態
const [allCamps, setAllCamps] = useState([]);
// 🔥 聯動的核心靈魂:目前被選中的營地 ID
const [activeCampId, setActiveCampId] = useState(null);
useEffect(() => {
// 假設這裡去 Supabase 抓了資料
fetchCamps().then(data => setAllCamps(data));
}, []);
return (
<div className="flex h-screen overflow-hidden">
{/* 左邊側邊欄:佔據 30% 寬度 */}
<div className="w-1/3 h-full bg-white shadow-2xl z-10 overflow-y-auto">
<Sidebar
camps={allCamps}
activeCampId={activeCampId}
// 把修改狀態的遙控器交給 Sidebar
onCampSelect={setActiveCampId}
/>
</div>
{/* 右邊地圖:佔據 70% 寬度 */}
<div className="w-2/3 h-full relative z-0">
<CampingMap
camps={allCamps}
activeCampId={activeCampId}
// 地圖也可以修改狀態!(例如點擊了地圖上的標籤)
onCampSelect={setActiveCampId}
/>
</div>
</div>
);
}
🔍 深度解析:
這就是 React 的核心哲學:「Single Source of Truth (單一資料來源)」。
地圖和清單自己都不知道現在選了誰,它們只聽主管 (ExplorePage) 的。當你在 Sidebar 點了一個項目,Sidebar 會呼叫 onCampSelect,通知主管把 activeCampId 變成 5。主管一更新,React 就會瞬間把這個 5 同時發送給 Sidebar 和地圖,兩個元件就會同時做出反應!
✈️ 實戰 2:從清單飛向地圖 (FlyTo)
現在我們的地圖拿到了 activeCampId。我們希望當這個 ID 改變時,地圖要自動像無人機一樣飛到那個營地的上空。
在 React-Leaflet 中,地圖的實體 (Map instance) 是隱藏在底層的。我們必須寫一個自訂的 Hook 元件來控制地圖。
💡 Vibe Prompt 實戰 2:指揮 AI 寫出 FlyTo 元件
這是一個極度困難的 Leaflet 考題。我們不能重新渲染整個地圖,我們只要呼叫地圖的 flyTo 方法。
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
在 React-Leaflet 中,我需要在 props.activeCampId 改變時,讓地圖平滑地飛到該營地的座標。請幫我寫一個名為 <MapController> 的內部元件,放在 <MapContainer> 裡面。需求:1. 使用 useMap() hook 取得 Leaflet 地圖實體。2. 接收 activeCampId 和 camps 陣列作為 props。3. 使用 useEffect 監聽 activeCampId 的變化。4. 當 ID 改變且找到對應的營地時,呼叫 map.flyTo([lat, lng], 14, { duration: 1.5 })。請給我這個 MapController 元件的程式碼,並示範如何把它放入 <MapContainer> 中。
🤖 AI 的高階飛行控制塔:
import { useMap } from 'react-leaflet';
import { useEffect } from 'react';
// 塔台控制器元件 (它不渲染任何畫面,純粹用來在背後操縱地圖)
function MapController({ activeCampId, camps }) {
// 🔥 神奇的 hook:取得目前的地圖實體!
const map = useMap();
useEffect(() => {
// 如果沒有選中任何東西,就什麼都不做
if (!activeCampId || !camps) return;
// 找出目前被選中的那筆營地資料
const targetCamp = camps.find(c => c.id === activeCampId);
if (targetCamp) {
// 啟動無人機飛行模式!
// 參數:[緯度, 經度], 縮放等級(14代表滿近的), 動畫設定
map.flyTo(
[targetCamp.lat, targetCamp.lng],
14,
{
duration: 1.5, // 飛行花費 1.5 秒
easeLinearity: 0.25 // 飛行軌跡的平滑度
}
);
}
}, [activeCampId, camps, map]); // 當這三個東西任何一個改變時,觸發飛行
return null; // 控制器不需要長出畫面
}
/*
【AI 示範如何使用】:
<MapContainer center={[23.5, 121]} zoom={7}>
<TileLayer ... />
{/* 把控制塔放在這裡!它會默默在背後工作 */}
<MapController activeCampId={activeCampId} camps={camps} />
{/* ...下面是 Marker 等等... */}
</MapContainer>
*/
🔍 實戰 3:從地圖逆向過濾清單 (Bounds Detection)
「飛過去」只解決了一半的問題。更難的需求是:「當我用滑鼠拖曳地圖,拖到台南的時候,左邊的 Sidebar 必須自動只顯示台南(目前螢幕畫面內)的營地。」
這意味著地圖元件必須「偵測目前的經緯度邊界 (Bounds)」,並把這個邊界傳回給主管,讓主管去過濾 allCamps。
💡 Vibe Prompt 實戰 3:實作地圖邊界偵測器
我們再請 AI 寫一個隱形的間諜元件,專門負責通報地圖目前看得到哪裡。
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
我需要在拖曳/縮放 Leaflet 地圖時,取得目前地圖的可視邊界 (Bounds),藉此過濾左側的清單。請幫我寫一個名為 <BoundsObserver> 的隱藏元件,放進 <MapContainer> 裡。需求:1. 使用 useMapEvents 監聽 'moveend' (移動結束) 與 'zoomend' 事件。2. 當事件觸發時,呼叫 map.getBounds() 取得目前邊界。3. 透過 props.onBoundsChange 將邊界資料傳出去。請給我這個元件的完整程式碼,並在註解中解釋如何使用 L.LatLngBounds.contains() 來判斷營地是否在畫面內。
🤖 AI 的高階雷達偵測器:
import { useMapEvents } from 'react-leaflet';
// 隱形的雷達偵測器
function BoundsObserver({ onBoundsChange }) {
// 使用 Leaflet 專屬的事件監聽 hook
const map = useMapEvents({
// 當地圖「移動結束」時觸發
moveend: () => {
onBoundsChange(map.getBounds());
},
// 當地圖「縮放結束」時觸發
zoomend: () => {
onBoundsChange(map.getBounds());
}
});
return null;
}
/*
【AI 深度解析過濾邏輯】:
當主管 <ExplorePage> 收到雷達傳回來的 bounds (邊界物件) 後,
你可以用陣列的 .filter() 配合 bounds.contains() 來過濾營地:
const visibleCamps = allCamps.filter(camp => {
// 把營地的經緯度轉成 Leaflet 格式,然後問雷達:「這個點有在你的掃描範圍內嗎?」
return bounds.contains([camp.lat, camp.lng]);
});
然後把這個 visibleCamps 傳給左邊的 Sidebar,清單就會神奇地跟著地圖連動了!
*/
🎨 實戰 4:極致的選中狀態視覺回饋
當一個營地被選中時,除了地圖要飛過去之外,我們還希望在視覺上讓它「脫穎而出」。 例如:地圖上該營地的價格標籤要變成黃色並發光;左側 Sidebar 的該筆清單要加上深色背景與邊框。
💡 Vibe Prompt 實戰 4:動態 Tailwind 樣式設計
這不需要寫新元件,只需要極致的 Tailwind 邏輯判斷。
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
在 Sidebar 的營地清單中,我使用 .map 迴圈渲染每一個營地。我現在有 activeCampId。請幫我寫一個 <div> 的 className,使用 Tailwind CSS 動態判斷:如果 camp.id === activeCampId:背景必須是 bg-emerald-500/20,邊框是 border-emerald-500,且左邊要有 4px 的粗綠色飾條。如果沒有被選中:背景是 bg-white,邊框是 border-gray-200,hover 時會變成 bg-gray-50。請用 React 樣板字面值 (Template literals) 給出乾淨的寫法。
🤖 AI 的百萬級視覺回饋程式碼:
{visibleCamps.map(camp => {
// 判斷這張卡片是不是「天選之卡」
const isActive = camp.id === activeCampId;
return (
<div
key={camp.id}
onClick={() => onCampSelect(camp.id)} // 點擊時通知主管換人
className={`
relative p-4 mb-3 rounded-lg border-2 cursor-pointer transition-all duration-300
${isActive
? 'bg-emerald-500/10 border-emerald-500 shadow-md shadow-emerald-500/20 scale-[1.02]'
: 'bg-white border-gray-100 hover:border-gray-300 hover:bg-gray-50'
}
`}
>
{/* 如果被選中,在最左邊畫一條霸氣的綠色螢光線 */}
{isActive && (
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-emerald-500 rounded-l-lg"></div>
)}
<h3 className={`font-bold ${isActive ? 'text-emerald-700' : 'text-gray-900'}`}>
{camp.name}
</h3>
<p className="text-gray-500 text-sm mt-1">NT$ {camp.price} / 晚</p>
</div>
)
})}
🔍 深度解析:
這種視覺設計被稱為 "Micro-interactions (微互動)"。當你加上 scale-[1.02] (稍微放大) 和霸氣的左側綠線,整個應用程式看起來就會像是由頂尖矽谷設計師操刀的產品,而不是大學生的期末專題。
🤯 高階升級:打破框架的 Context API
上面的「狀態提升 (State Lifting)」非常棒,但如果你的專案變得超級龐大呢?
假設你的 Sidebar 裡面還包了 <SearchBox>,裡面又包了 <FilterPanel>,裡面又包了 <PriceSlider>。
你要把 activeCampId 和 setActiveCampId 像接力賽一樣,一層一層地傳遞 5 層下去。
這叫做 "Prop Drilling (屬性鑽探地獄)",不僅程式碼醜到爆,而且超容易出錯。
這時候,真正的高手會使用 React 的終極武器:Context API。
它就像是在你的專案上空打一顆通訊衛星,任何角落的元件,只要連上這顆衛星,就能直接拿到 activeCampId,不需要經過中間的層層傳遞!
💡 Vibe Prompt 實戰 5:指揮 AI 打造通訊衛星 (Context)
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
為了避免 Props Drilling,我想要重構我的地圖狀態管理,改用 React Context API。請幫我寫一個 MapContext.jsx 檔案。需求:1. 建立並 export MapContext。2. 寫一個 MapProvider 元件,裡面包含 state: activeCampId, setActiveCampId, visibleBounds, setVisibleBounds。3. 寫一個自訂 Hook (useMapState),讓其他元件可以方便地取得這些狀態。如果不在 Provider 內呼叫,要噴出明確的錯誤。請給我極度專業、可直接用於商業產品的完整程式碼。
🤖 AI 的企業級通訊架構:
import React, { createContext, useState, useContext } from 'react';
// 1. 建立通訊衛星
const MapContext = createContext();
// 2. 建立發射站 (Provider):負責保管所有重要狀態,並把狀態打包廣播出去
export function MapProvider({ children }) {
const [activeCampId, setActiveCampId] = useState(null);
const [visibleBounds, setVisibleBounds] = useState(null);
// 把所有要分享的東西打包成一個大禮包
const value = {
activeCampId,
setActiveCampId,
visibleBounds,
setVisibleBounds
};
return (
<MapContext.Provider value={value}>
{children}
</MapContext.Provider>
);
}
// 3. 建立接收器 Hook:其他元件只要呼叫 useMapState() 就能直接拿到狀態
export function useMapState() {
const context = useContext(MapContext);
// 🔥 防呆機制:如果工程師忘記把元件包在 Provider 裡面,就給他一個超大聲的警告!
if (context === undefined) {
throw new Error('useMapState 必須被包裝在 MapProvider 內部才能使用!');
}
return context;
}
🚀 實戰 6:全面重構,接入衛星訊號
有了這顆衛星後,你的 <ExplorePage> 變得無比乾淨!
💡 Vibe Prompt 實戰 6:使用 Provider 重構頁面
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
請示範如何把剛剛寫好的 <MapProvider> 應用在最外層的 <App> 或 <ExplorePage> 中。並且示範,在深層的 <SidebarItem> 元件中,如何使用 useMapState() 直接拿到 activeCampId 和修改它的函數,而不需要依賴 props。
🤖 AI 示範終極優雅的寫法:
// 在最外層,用衛星發射站把大家包起來
function App() {
return (
<MapProvider>
<div className="flex h-screen">
{/* 你看!現在完全不需要傳遞任何 Props 了!清爽到爆! */}
<Sidebar />
<CampingMap />
</div>
</MapProvider>
);
}
// 在 100 層深的 Sidebar 某個角落元件...
import { useMapState } from './MapContext';
function SidebarItem({ camp }) {
// 📡 裝上接收器,直接從太空中抓下狀態!
const { activeCampId, setActiveCampId } = useMapState();
const isActive = camp.id === activeCampId;
return (
<div onClick={() => setActiveCampId(camp.id)}>
{camp.name} {isActive ? "(已選中)" : ""}
</div>
);
}
✅ 本章總結與終極架構心法
這個長達 6000 字的超深章節,是全端工程師在前端領域的分水嶺。 許多人會刻死板的地圖,但只有真正的高手,才知道如何讓數十個元件像交響樂一樣完美連動。
回顧這堂價值連城的架構課:
- 單一資料來源 (Single Source of Truth):元件不要各說各話,找一個主管(或 Provider)來統籌。
- 無形的控制器:利用
<MapController>這種不渲染畫面的隱藏元件,在背後優雅地操控 Leaflet API。 - 雷達偵測器:利用
map.getBounds()逆向將地圖的可視區域回傳,實現「只顯示地圖內營地」的高級體驗。 - 終極武器 Context API:當專案大到 props 傳不下去時,直接發射一顆狀態衛星,讓所有元件直接連線取值。
這就是 露營車泊地圖資訊平台 (Camping Map Platform) 的最後一塊拼圖。 從第一天的 Astro 基礎、到 Supabase 資料庫規劃、到 CrewAI 讓幾千個機器人幫你搜集全台資料、再到最後做出跟 Airbnb 一樣神級的雙向連動地圖。
你現在做出來的系統,已經完全具備了商業變現的價值。你可以把它包裝成一個訂閱制的找營地平台,或是賣給露營車出租公司當作他們的官方找點系統。 請深呼吸,為自己感到驕傲。因為在 Vibe Coding 的加持下,你一個人完成了一整個工程團隊要花半年才能做完的事!
準備好挑戰另一種截然不同的商業模式了嗎?我們下一個專案:Line 打卡與薪資系統 見!