📍 第十二章:Leaflet.js 自訂高階地圖標記與群集優化
在上一章的 CrewAI 爬蟲實戰中,我們成功地從網路上採集了幾千筆全台露營地的資料,包含名稱、價格、以及最重要的經緯度,並存入了 Supabase 資料庫。
現在,我們面臨前端工程的巨大挑戰。 如果你直接用 Leaflet 的預設語法,把這 5000 個點一次畫到地圖上:
- 視覺災難:畫面上會擠滿 5000 個一模一樣的藍色小水滴,使用者根本看不到地圖底圖,也分不出哪個營地比較便宜。
- 效能災難:Leaflet 預設會為每一個標記建立一個 DOM 元素 (div 或 img)。5000 個 DOM 會瞬間吃光手機的記憶體,讓滑動地圖變得無比卡頓,甚至讓瀏覽器直接崩潰閃退。
如果你看過 Airbnb 的地圖,你會發現他們的標記不是圖示,而是直接在地圖上顯示「價格」。當你縮小地圖時,這些價格會融合成一個圓圈寫著「此區有 150 個房源」。 這堂高階實戰課,我們就要用 Vibe Coding 的方式,指揮 AI 為我們實作這套 Airbnb 等級的 自訂 HTML 標記 (Custom HTML Markers) 與 標記叢集 (Marker Clustering) 系統!
🎨 實戰 1:拋棄藍色水滴,打造 Airbnb 風格價格標籤
原生的 Leaflet L.marker 只允許你更換圖片。但如果你要顯示動態的價格 (例如 NT$ 1200),你不可能去產生幾千張包含數字的圖片。
Leaflet 提供了一個強大的工具:L.divIcon。它允許你把一段真實的 HTML 與 Tailwind CSS,直接塞進地圖的座標點上!
💡 Vibe Prompt 實戰 1:請 AI 寫出高質感的 DivIcon
這個 Prompt 的難度非常高,因為我們必須結合 React、Leaflet、還有 Tailwind 的樣式。
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
我正在使用 React-Leaflet 開發地圖。我要把原生的藍色水滴圖示,換成像 Airbnb 那樣顯示價格的標籤。請幫我寫一個函數 createPriceIcon(price, isHot),它會回傳一個 L.divIcon 實體。需求:1. 使用 className: 'custom-div-icon',並將 html 屬性設定為一段字串格式的 HTML。2. HTML 結構:一個帶有圓角 (rounded-full) 的白色膠囊背景,裡面顯示 "NT$ 價格"。3. Tailwind 樣式:bg-white, text-zinc-900, font-bold, px-3, py-1, shadow-lg, border, border-zinc-200。4. 進階邏輯:如果 isHot 為 true (熱門營地),背景請改為 bg-rose-500,文字改為 text-white,並加上 animate-bounce 讓它稍微跳動。請給我完整的 JavaScript 程式碼,並附帶詳細的中文註解解釋 L.divIcon 的用法。
🤖 AI 生成的百萬級介面圖示:
import L from 'leaflet';
/**
* 建立 Airbnb 風格的自訂價格標記
* @param {number} price - 營地價格
* @param {boolean} isHot - 是否為熱門營地
* @returns {L.DivIcon} Leaflet DivIcon 實體
*/
export const createPriceIcon = (price, isHot) => {
// 根據是否熱門,動態決定 Tailwind 的 ClassName
const baseClass = "font-bold px-3 py-1 rounded-full shadow-lg border text-sm whitespace-nowrap transition-transform hover:scale-110";
const colorClass = isHot
? "bg-rose-500 text-white border-rose-600 animate-bounce" // 熱門:紅色跳動
: "bg-white text-zinc-900 border-zinc-200"; // 一般:白色乾淨
// 把 Tailwind 樣式組裝成純粹的 HTML 字串
const htmlContent = `
<div class="${baseClass} ${colorClass}">
NT$ ${price.toLocaleString()}
</div>
`;
// 使用 L.divIcon!這是 Leaflet 最強大的功能之一,可以渲染任意 HTML
return L.divIcon({
html: htmlContent,
className: '', // ⚠️ 關鍵防坑:這裡必須設為空字串,否則 Leaflet 會加上預設的白色正方形底圖
iconSize: [null, null], // 設定 null 代表讓 HTML 內容自動撐開寬高
iconAnchor: [30, 15], // 錨點:決定座標點對應到這個膠囊的哪個位置 (通常抓中心點偏下)
});
};
🔍 深度解析:
這是網頁工程師的秘密武器。我們巧妙地利用了字串模板 ` 來組合 Tailwind CSS。
其中最容易踩坑的就是 className: ''。如果你不寫這一行,Leaflet 會很熱心地幫你加上一段預設的 CSS,結果你的絕美膠囊外面就會多出一個醜陋的白色方塊框框。AI 在這裡已經幫我們完美避開了這個坑!
🗺️ 實戰 2:將自訂標籤綁定到 React-Leaflet 迴圈中
有了製造膠囊的機器,接下來我們要把資料庫撈出來的資料,一個一個套用上去。
💡 Vibe Prompt 實戰 2:在 Marker 身上掛載自訂 Icon
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
我現在的 React 元件 <CampingMap> 裡面有一個 camps 陣列 (從 Supabase 抓下來的)。請幫我用 React-Leaflet 的 <MapContainer> 和 <TileLayer> 渲染地圖。並且使用 camps.map 迴圈,渲染出所有的 <Marker>。請在 <Marker> 的 icon 屬性中,套用剛剛寫好的 createPriceIcon(camp.price, camp.is_hot)。點擊 <Marker> 時,要彈出一個 <Popup>,顯示營地名稱。請給我完整的 React 元件程式碼。
🤖 AI 的完美地圖渲染架構:
import React from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { createPriceIcon } from './iconUtils'; // 引入剛才的工具函數
export default function CampingMap({ camps }) {
// 預設中心點 (台灣中心點:埔里)
const center = [23.9738, 120.9820];
return (
// MapContainer 必須設定具體高度,否則地圖出不來
<div className="w-full h-[600px] rounded-2xl overflow-hidden shadow-2xl relative z-0">
<MapContainer
center={center}
zoom={7}
scrollWheelZoom={true}
className="w-full h-full"
>
{/* 底圖提供商 (這裡使用 CartoDB 的深色地圖,質感極佳) */}
<TileLayer
attribution='© <a href="https://carto.com/">CartoDB</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{/* 撒下所有的營地標記 */}
{camps.map((camp) => (
<Marker
key={camp.id}
position={[camp.lat, camp.lng]}
icon={createPriceIcon(camp.price, camp.is_hot)} // 🔥 套用神級自訂圖示
>
{/* 點擊標籤時彈出的漂亮氣泡 */}
<Popup className="custom-popup">
<div className="p-2">
<h3 className="font-bold text-lg">{camp.name}</h3>
<p className="text-sm text-gray-500">{camp.address}</p>
<button className="mt-2 w-full bg-emerald-500 text-white rounded py-1 text-sm font-bold">
立即預訂
</button>
</div>
</Popup>
</Marker>
))}
</MapContainer>
</div>
);
}
💥 實戰 3:當資料達到 5000 筆時的災難 (Marker Clustering)
如果你只有 50 筆資料,上面的程式碼跑起來非常完美。 但如果你有 5000 筆,這時候打開網頁,你的電腦風扇會開始狂轉,畫面會卡住長達 5 秒,甚至手機瀏覽器會直接當掉。
為什麼?因為在 React-Leaflet 中,5000 個 <Marker> 等同於你要叫 React 去建立 5000 個真實的 DOM 元素,還要綁定 5000 個點擊事件監聽器。這已經超出了瀏覽器的極限。
為了解決這個問題,地圖學中發明了 Marker Clustering (標記叢集)。 當你把地圖縮小 (Zoom out) 到整個台灣時,系統會把中部的 1000 個營地融合在一起,變成一個寫著「1000」的綠色大圓圈。當你慢慢放大 (Zoom in) 到苗栗山區,這 1000 個圓圈才會散開成十幾個小圓圈,最後才散開成真正的價格標籤。 這不僅解決了視覺太過密集的問題,更拯救了 90% 的效能!
💡 Vibe Prompt 實戰 3:引入 React-Leaflet-Cluster
這是一個超級高階的套件,自己看官方文件絕對會裝到發瘋。我們交給 AI!
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
我的地圖上有 5000 個 Marker,導致嚴重的效能問題。我要使用 react-leaflet-cluster 這個套件來實作 Marker Clustering。請幫我修改剛才的 <CampingMap> 程式碼。需求:1. 使用 <MarkerClusterGroup> 將所有的 <Marker> 包裝起來。2. 設定 chunkedLoading={true} 來分批載入,避免主執行緒卡死。3. 設定 maxClusterRadius={50},讓它們距離近一點再融合。請給我完整的修改後程式碼,並在註解中解釋為什麼這能解決效能問題。
🤖 AI 的終極效能救星:
import React from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
// 🔥 引入叢集套件 (請確保有 npm install react-leaflet-cluster)
import MarkerClusterGroup from 'react-leaflet-cluster';
import 'leaflet/dist/leaflet.css';
import { createPriceIcon } from './iconUtils';
export default function CampingMap({ camps }) {
const center = [23.9738, 120.9820];
return (
<div className="w-full h-[600px] rounded-2xl overflow-hidden shadow-2xl relative z-0">
<MapContainer center={center} zoom={7} className="w-full h-full">
<TileLayer url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" />
{/*
🌟 效能救星降臨!把所有的 Marker 放進 MarkerClusterGroup 中。
為什麼這能拯救效能?
因為它在背後做了一個複雜的數學運算:
在目前的縮放級別下,如果畫面上有 1000 個點靠得很近,
它「不會」去畫 1000 個 DOM,而是只畫「1 個」寫著數字的 DOM 圓圈。
當你把地圖放大時,它才動態計算並展開。這把 DOM 數量從 5000 降到了不到 50!
*/}
<MarkerClusterGroup
chunkedLoading={true} // 分批計算叢集,確保計算時瀏覽器不會整個凍結 (Freezing)
maxClusterRadius={50} // 像素距離:小於 50px 才會融合
showCoverageOnHover={false} // 滑鼠移上去不要顯示那個醜醜的多邊形範圍
>
{camps.map((camp) => (
<Marker
key={camp.id}
position={[camp.lat, camp.lng]}
icon={createPriceIcon(camp.price, camp.is_hot)}
>
<Popup>
<div className="p-2 font-bold">{camp.name}</div>
</Popup>
</Marker>
))}
</MarkerClusterGroup>
</MapContainer>
</div>
);
}
🎨 實戰 4:客製化「叢集大圓圈」的外觀
當你裝了上面的套件後,你會發現:價格膠囊變得很漂亮了,但是那些寫著「數字」的叢集圓圈,還是原本醜醜的預設樣式 (黃色、綠色的漸層大圓)。
身為一個有強迫症的高階開發者,我們絕對要把這個圓圈也改造成我們自己的 Tailwind 玻璃擬態風格!
💡 Vibe Prompt 實戰 4:使用 createClusterCustomIcon 改造圓圈
這需要使用 Leaflet 最深層的 API:取得底層叢集叢集的數量,並再次使用 L.divIcon 生成 HTML。
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
我希望自訂 react-leaflet-cluster 的叢集圓圈外觀 (Cluster Icon)。請幫我寫一個函數 createCustomClusterIcon(cluster),並傳給 <MarkerClusterGroup iconCreateFunction={}>。需求:1. 從 cluster 實體中取得裡面包含的點數量 (cluster.getChildCount())。2. 使用 L.divIcon 回傳一個 HTML 結構。3. Tailwind 樣式要求:背景是半透明黑 (bg-black/60)、毛玻璃特效 (backdrop-blur-md)、圓形 (rounded-full, w-12, h-12)、文字居中白字、外圍有螢光綠色的邊框 (border-2 border-emerald-400)。請給我完整的工具函數與元件更新。
🤖 AI 的高訂級客製化叢集:
// 在元件外定義這個強大的客製化函數
const createCustomClusterIcon = (cluster) => {
// 取得這個圓圈底下,總共包含了幾個營地
const count = cluster.getChildCount();
// 設計一個極具科技感與現代感的 Tailwind 毛玻璃圓圈
const htmlContent = `
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-black/60 backdrop-blur-md border-2 border-emerald-400 text-emerald-400 font-bold shadow-[0_0_15px_rgba(52,211,153,0.5)] transition-transform hover:scale-110">
${count}
</div>
`;
return L.divIcon({
html: htmlContent,
className: '', // 必加,去除預設背景
iconSize: L.point(48, 48, true), // 指定長寬為 48px (對應 w-12 h-12)
});
};
// ... 然後在元件中使用 ...
<MarkerClusterGroup
chunkedLoading={true}
iconCreateFunction={createCustomClusterIcon} // 🔥 把客製化函數綁定上去
>
{/* 裡面的 Marker 不變 */}
</MarkerClusterGroup>
🔍 深度解析: 當這段程式碼放上去後,你的地圖介面將產生質的飛躍。 深色的底圖上,漂浮著帶有螢光綠邊框的毛玻璃圓圈。當你點擊寫著「45」的圓圈時,地圖會自動放大,並如煙火般散開成 45 個絕美的 Airbnb 價格膠囊。 這就是「把免費套件做出百萬級專案價值」的最佳範例!
🚫 終極避坑指南:當 Leaflet 遇到 React 18 的 StrictMode
這是一個極度隱晦的深坑!在本地開發時 (npm run dev),因為 React 18 預設啟動了 <StrictMode>,它會把元件「渲染兩次」來檢查錯誤。
這會導致地圖被初始化兩次,結果整個地圖畫面會變成一片灰色,或是叢集圓圈重複出現。
💡 Vibe Prompt 實戰 5:解決 React 18 地圖灰屏地獄
當你遇到網頁正常,但地圖某個區塊灰掉、或是報錯 Map container is already initialized 時,把這個 Prompt 丟給 AI。
[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:
我在 React 18 中使用 React-Leaflet,在開發模式 (npm run dev) 下地圖有時會出現灰屏,控制台出現 "Map container is already initialized" 的錯誤。我知道這跟 React.StrictMode 有關。請教我兩種解法:1. 如何在 Vite 的設定或 main.jsx 中暫時關閉 StrictMode 來避開這個地圖套件的 Bug?2. 如果不想關閉 StrictMode,有沒有什麼 useEffect 或 useRef 的解法可以避免 Leaflet 重複初始化?請給出詳細說明。
🤖 AI 的排障專家指南:
這是 Leaflet 與 React 18 的著名歷史衝突,因為 Leaflet 的底層架構非常古老,它不喜歡同一個 DOM 被建立兩次。
【解法一:最快速暴力的解法 (關閉 StrictMode)】
這會立刻解決問題。開啟你的 `src/main.jsx` 或 `index.js`:
```jsx
// ❌ 舊的寫法:
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
// ✅ 移除 StrictMode,改成這樣:
ReactDOM.createRoot(document.getElementById('root')).render(
<App />
)
【解法二:不關閉 StrictMode 的優雅解法】
如果你在 Next.js 或一定要保留 StrictMode,可以透過給 <MapContainer> 強制設定一個每次都不同的 key,讓 React 放棄重用它:
const mapKey = useMemo(() => Date.now(), []);
return (
<MapContainer key={mapKey} ...>
...
</MapContainer>
)
---
## ✅ 本章總結與終極實戰心法
在這個長達 6000 字的進階實戰章節中,我們不僅解決了視覺問題,更解決了效能瓶頸。
回顧我們學到的高階技巧:
1. **L.divIcon 魔術**:拋棄了死板的圖片,用字串組合出 Tailwind CSS 寫的 `Airbnb 風格價格標籤`。
2. **解決 DOM 效能災難**:透過引入 `react-leaflet-cluster`,把 5000 個節點的運算壓縮到只剩下 50 個,讓手機滑動順暢無比。
3. **客製化叢集圓圈**:深入 API 底層,把毛玻璃、螢光陰影等賽博龐克風格套用在地圖叢集上。
4. **React StrictMode 避坑**:學會如何排解地圖套件在現代前端框架中的千年大坑。
**Vibe Coding 核心思維:**
你不需要去背誦 `cluster.getChildCount()` 這些底層 API。你只需要知道:「這個套件的圓圈很醜,我想要用 Tailwind 把它的背景改成透明模糊」。
當你把這個「視覺需求」用精準的語言描述給 AI 聽,並附上你想要的 Tailwind 關鍵字,AI 自然會幫你找到對應的底層 API 並寫出完美的程式碼。
到目前為止,我們的地圖已經美到無法挑剔了。
但在真實的網頁中,地圖不會是孤立的。通常左邊會有一個「側邊欄 (Sidebar)」,顯示營地的詳細列表。
當你點擊左邊清單的某個營地時,右邊的地圖要自動飛過去;當你拖動地圖時,左邊的清單要自動過濾出畫面內的營地。
這牽扯到複雜的「元件間狀態共享 (State Lifting)」。
準備好了嗎?我們將在下一章:**第十三章:地圖與側邊欄的靈魂共振 (State Interaction)** 中,教你如何用 AI 打通這套複雜的神經網路!