SSR React应用中Leaflet懒加载时集群样式失效问题排查
解决SSR React应用中Leaflet地图的两个核心问题
问题概述
- 未懒加载模态组件时:本地运行正常,部署后Pod崩溃,Leaflet加载时抛出错误:
/node_modules/leaflet/dist/leaflet-src.js:230 var requestFn = window.requestAnimationFrame || getPrefixed('RequestAnimationFrame') || timeoutDefer; ^ ReferenceError: window is not defined - 添加懒加载后:部署正常,但集群样式修改功能失效,仅标记图标修改正常,改用react-leaflet重写后问题仍存在
解决方案
一、修复SSR下window未定义问题
Leaflet在模块导入阶段就会访问window对象,而SSR环境没有浏览器全局对象,必须确保Leaflet相关代码只在客户端执行。
动态导入Leaflet依赖
移除组件顶层的Leaflet导入,改为在客户端渲染时动态加载:useEffect(() => { if (isMapViewModalOpen) { import('leaflet').then(async (L) => { const markerCluster = await import('leaflet.markercluster'); // 动态加载CSS import('leaflet/dist/leaflet.css'); import('leaflet.markercluster/dist/MarkerCluster.css'); import('leaflet.markercluster/dist/MarkerCluster.Default.css'); // 执行地图初始化逻辑 initializeMap(L, markerCluster); }); } }, [isMapViewModalOpen]);用React.lazy包裹模态组件
把包含地图的模态组件用React.lazy和Suspense包裹,确保组件只在客户端渲染:import { lazy, Suspense } from 'react'; const MapModal = lazy(() => import('./MapModal')); // 使用时 <Suspense fallback={<div>Loading...</div>}> {isModalOpen && <MapModal />} </Suspense>
二、修复集群样式不更新问题
refreshClusters()不会重新触发iconCreateFunction,需要强制集群重新计算图标逻辑:
优化hover状态更新逻辑
当hoveredProductId变化时,更新标记状态后,重新设置集群的图标生成函数并触发重绘:useEffect(() => { if (!markerClusterRef.current || !L) return; // 更新标记图标和状态 markerClusterRef.current.eachLayer(layer => { if ('productID' in layer) { const shouldBeHighlighted = layer.productID === hoveredProductId; layer.setIcon(shouldBeHighlighted ? highlightedIcon : defaultIcon); layer.isHovered = shouldBeHighlighted; } }); // 重新设置iconCreateFunction markerClusterRef.current.options.iconCreateFunction = (cluster) => { const childCount = cluster.getChildCount(); const hasRelatedProduct = cluster .getAllChildMarkers() .some((marker) => marker.isHovered); return hasRelatedProduct ? highlightedClusterIcon(childCount) : defaultClusterIcon(childCount); }; // 强制集群重绘 markerClusterRef.current.refreshClusters(); markerClusterRef.current.redraw(); }, [hoveredProductId, L]);避免硬编码图标生成函数
把图标生成函数改为可复用变量,方便后续更新:let clusterIconCreator; const createMarkerCluster = (L, markerCluster) => { clusterIconCreator = (cluster) => { const childCount = cluster.getChildCount(); const hasRelatedProduct = cluster .getAllChildMarkers() .some((marker) => marker.isHovered); return hasRelatedProduct ? highlightedClusterIcon(childCount) : defaultClusterIcon(childCount); }; const clusters = markerCluster.MarkerClusterGroup({ iconCreateFunction: clusterIconCreator }); markerClusterRef.current = clusters; return clusters; };
修改后的完整代码示例
import { useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import mapMarkerHover from './assets/mapMarkerHover.png'; import { CustomMapMarker } from '../../types/customMapMarker'; import { ProductListItem } from '../../types/productListItem'; import mapMarker from './assets/mapMarker.png'; import { MapViewPopUpProduct } from './MapViewPopUpProduct'; const MapView = ({ isMapViewModalOpen, productListItems, hoveredProductId }) => { const mapRef = useRef(null); const markerClusterRef = useRef(null); const [L, setL] = useState(null); const [markerClusterLib, setMarkerClusterLib] = useState(null); const defaultIcon = () => L?.icon({ iconUrl: mapMarker, iconSize: [24, 32], }); const highlightedIcon = () => L?.icon({ iconUrl: mapMarkerHover, iconSize: [24, 32], }); const getClusterSize = (childCount) => { if (childCount < 10) return '-small'; if (childCount < 100) return '-medium'; return '-large'; }; const defaultClusterIcon = (childCount) => L?.divIcon({ html: `<div><span>${childCount}</span></div>`, className: `marker-cluster marker-cluster${getClusterSize(childCount)}`, iconSize: [40, 40], }); const highlightedClusterIcon = (childCount) => L?.divIcon({ html: `<div><span>${childCount}</span></div>`, className: `marker-cluster marker-cluster${getClusterSize(childCount)} cluster-hovered`, iconSize: [40, 40], }); let clusterIconCreator; const closePopup = () => { if (mapRef.current) mapRef.current.closePopup(); }; const initializeMap = () => { const container = document.getElementById('map'); if (!container || !L) return null; const map = L.map(container, { center: [51.1657, 10.4515], zoom: 6, zoomControl: false, }); mapRef.current = map; map.addControl(new L.Control.Zoom({ position: 'bottomright' })); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map); return map; }; const createMarkerCluster = () => { if (!markerClusterLib) return null; clusterIconCreator = (cluster) => { const childCount = cluster.getChildCount(); const hasRelatedProduct = cluster .getAllChildMarkers() .some((marker) => marker.isHovered); return hasRelatedProduct ? highlightedClusterIcon(childCount) : defaultClusterIcon(childCount); }; const clusters = markerClusterLib.MarkerClusterGroup({ iconCreateFunction: clusterIconCreator, }); markerClusterRef.current = clusters; return clusters; }; const addMarkersToCluster = (map, markers) => { if (!L) return; productListItems.forEach((item) => { const currentItem = { ...item }; if (currentItem.product?.locations) { const popupNode = document.createElement('div'); const root = createRoot(popupNode); root.render( <MapViewPopUpProduct {...currentItem.product} closePopup={closePopup} /> ); currentItem.product.locations.forEach(location => { const marker = L.marker([location.lat, location.long], { icon: defaultIcon(), }); marker.productID = currentItem.product.productId; marker.isHovered = false; const popup = L.popup({ closeButton: false, autoClose: false, closeOnClick: true, closeOnEscapeKey: true, minWidth: 350, className: 'custom-popup', }) .setLatLng(map.getBounds().getCenter()) .setContent(popupNode); marker.bindPopup(popup); markers.addLayer(marker); }); } }); map.addLayer(markers); }; useEffect(() => { if (isMapViewModalOpen && !L) { // 动态加载Leaflet及相关依赖 import('leaflet').then(async (leafletModule) => { setL(leafletModule.default); const mcModule = await import('leaflet.markercluster'); setMarkerClusterLib(mcModule.default); // 加载CSS import('leaflet/dist/leaflet.css'); import('leaflet.markercluster/dist/MarkerCluster.css'); import('leaflet.markercluster/dist/MarkerCluster.Default.css'); }); } }, [isMapViewModalOpen, L]); useEffect(() => { if (isMapViewModalOpen && L && markerClusterLib && !mapRef.current) { setTimeout(() => { const map = initializeMap(); const clusters = createMarkerCluster(); if (map && clusters) addMarkersToCluster(map, clusters); }, 0); } }, [isMapViewModalOpen, L, markerClusterLib]); useEffect(() => { if (!markerClusterRef.current || !L) return; // 更新标记状态 markerClusterRef.current.eachLayer(layer => { if ('productID' in layer) { const shouldBeHighlighted = layer.productID === hoveredProductId; layer.setIcon(shouldBeHighlighted ? highlightedIcon() : defaultIcon()); layer.isHovered = shouldBeHighlighted; } }); // 重新设置图标生成函数并触发重绘 markerClusterRef.current.options.iconCreateFunction = clusterIconCreator; markerClusterRef.current.refreshClusters(); markerClusterRef.current.redraw(); }, [hoveredProductId, L]); return <div id="map" style={{ width: '100%', height: '400px' }} />; }; export { MapView };
内容的提问来源于stack exchange,提问作者Ehsan Zand
相关产品推荐
相关产品推荐

