NextJS 14中分离"use server"与"use client"及Maplibre地图集群问题
问题描述
我正在学习NextJS 14,希望尽量多用服务端组件,但Maplibre地图必须加"use client"指令,暂时没找到替代方案。现有基础地图代码能正常运行:
"use client" import { useEffect } from "react"; import { Map, NavigationControl, GeolocateControl } from "maplibre-gl"; import 'maplibre-gl/dist/maplibre-gl.css'; import { Suspense } from "react"; import { Spinner } from "@/components/spinner"; const CdayMap = () => { useEffect(() => { const map = new Map({ container: 'map', center: [7, 50], zoom: 14, style: 'https://tiles.versatiles.org/assets/styles/colorful.json' }); map.addControl(new NavigationControl()); map.addControl( new GeolocateControl({ positionOptions: { enableHighAccuracy: true, }, trackUserLocation: true, }) ); }, []) return ( <Suspense fallback={<Spinner />}> <div className="flex-1 flex flex-col items-center gap-y-4 animate-fade-from-top"> <div id="map" /> </div> </Suspense> ); }; export { CdayMap };
之后我尝试集成标记与集群功能,但代码仅显示少量点位:
"use client" import { useEffect, useState } from "react"; import maplibregl, { Map, NavigationControl, GeolocateControl } from "maplibre-gl"; import 'maplibre-gl/dist/maplibre-gl.css'; import { Suspense } from "react"; import { Spinner } from "@/components/spinner"; const CdayMap = () => { const [pageIsMounted, setPageIsMounted] = useState(false) useEffect(() => { setPageIsMounted(true) const map = new Map({ container: 'map', center: [-103.59179687498357, 40.66995747013945], zoom: 3, style: 'https://tiles.versatiles.org/assets/styles/colorful.json' }); map.addControl(new NavigationControl()); map.addControl( new GeolocateControl({ positionOptions: { enableHighAccuracy: true, }, trackUserLocation: true, }) ); map.on('load', () => { map.addSource('earthquakes', { type: 'geojson', data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson', cluster: true, clusterMaxZoom: 14, clusterRadius: 50 }); map.addLayer({ id: 'clusters', type: 'circle', source: 'earthquakes', filter: ['has', 'point_count'], paint: { 'circle-color': [ 'step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1' ], 'circle-radius': [ 'step', ['get', 'point_count'], 20, 100, 30, 750, 40 ] } }); map.addLayer({ id: 'cluster-count', type: 'symbol', source: 'earthquakes', filter: ['has', 'point_count'], layout: { 'text-field': '{point_count_abbreviated}', 'text-size': 12 } }); map.addLayer({ id: 'unclustered-point', type: 'circle', source: 'earthquakes', filter: ['!', ['has', 'point_count']], paint: { 'circle-color': '#11b4da', 'circle-radius': 4, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' } }); // inspect a cluster on click map.on('click', 'clusters', (e) => { const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] }); const clusterId = features[0].properties.cluster_id; const zoom = map.getSource('earthquakes').getClusterExpansionZoom(clusterId); map.easeTo({ center: features[0].geometry.coordinates, zoom }); }); map.on('click', 'unclustered-point', (e) => { const coordinates = e.features[0].geometry.coordinates.slice(); const mag = e.features[0].properties.mag; let tsunami; if (e.features[0].properties.tsunami === 1) { tsunami = 'yes'; } else { tsunami = 'no'; } while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; } new maplibregl.Popup() .setLngLat(coordinates) .setHTML( `magnitude: ${mag}<br>Was there a tsunami?: ${tsunami}` ) .addTo(map); }); map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = ''; }); }); }, []) return ( <Suspense fallback={<Spinner />}> <div className="flex-1 flex flex-col items-center gap-y-4 animate-fade-from-top"> <div id="map" /> </div> </Suspense> ); }; export { CdayMap };
我怀疑这是因为客户端组件中无法使用async/await导致的,请问我的假设是否正确?该如何实现预期目标?
解答
你的假设不正确,NextJS的客户端组件完全支持async/await语法,问题出在其他地方。下面是具体问题分析和解决方案:
1. 核心问题:地图容器缺少尺寸样式
你的代码中id="map"的div没有设置高度和宽度,Maplibre无法正确渲染地图内容,导致集群点位显示不全或不显示。
2. 次要问题:地图实例未清理
组件卸载时没有销毁Map实例,会导致内存泄漏,也可能引发后续渲染异常。
解决方案
步骤1:给地图容器添加样式
修改返回的JSX,给map容器设置明确的尺寸:
<div id="map" className="w-full h-[80vh]" />
或者用自定义CSS设置:
#map { width: 100%; height: 80vh; }
步骤2:清理地图实例
在useEffect的返回函数中销毁地图,避免内存泄漏:
useEffect(() => { const map = new Map({/* 配置 */}); // ... 其他地图逻辑 ... // 组件卸载时清理 return () => { map.remove(); }; }, []);
步骤3:优化代码结构(可选)
- 移除无用的
pageIsMounted状态,你的代码中没有用到这个变量 - 确保
maplibregl的导入和使用一致,避免混淆
修复后的完整代码示例
"use client" import { useEffect } from "react"; import maplibregl, { Map, NavigationControl, GeolocateControl } from "maplibre-gl"; import 'maplibre-gl/dist/maplibre-gl.css'; import { Suspense } from "react"; import { Spinner } from "@/components/spinner"; const CdayMap = () => { useEffect(() => { const map = new Map({ container: 'map', center: [-103.59179687498357, 40.66995747013945], zoom: 3, style: 'https://tiles.versatiles.org/assets/styles/colorful.json' }); map.addControl(new NavigationControl()); map.addControl( new GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true, }) ); map.on('load', () => { map.addSource('earthquakes', { type: 'geojson', data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson', cluster: true, clusterMaxZoom: 14, clusterRadius: 50 }); map.addLayer({ id: 'clusters', type: 'circle', source: 'earthquakes', filter: ['has', 'point_count'], paint: { 'circle-color': [ 'step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1' ], 'circle-radius': [ 'step', ['get', 'point_count'], 20, 100, 30, 750, 40 ] } }); map.addLayer({ id: 'cluster-count', type: 'symbol', source: 'earthquakes', filter: ['has', 'point_count'], layout: { 'text-field': '{point_count_abbreviated}', 'text-size': 12 } }); map.addLayer({ id: 'unclustered-point', type: 'circle', source: 'earthquakes', filter: ['!', ['has', 'point_count']], paint: { 'circle-color': '#11b4da', 'circle-radius': 4, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' } }); map.on('click', 'clusters', (e) => { const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] }); const clusterId = features[0].properties.cluster_id; map.getSource('earthquakes').getClusterExpansionZoom(clusterId, (err, zoom) => { if (!err) { map.easeTo({ center: features[0].geometry.coordinates, zoom }); } }); }); map.on('click', 'unclustered-point', (e) => { const coordinates = e.features[0].geometry.coordinates.slice(); const mag = e.features[0].properties.mag; const tsunami = e.features[0].properties.tsunami === 1 ? 'yes' : 'no'; while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; } new maplibregl.Popup() .setLngLat(coordinates) .setHTML(`magnitude: ${mag}<br>Was there a tsunami?: ${tsunami}`) .addTo(map); }); map.on('mouseenter', 'clusters', () => map.getCanvas().style.cursor = 'pointer'); map.on('mouseleave', 'clusters', () => map.getCanvas().style.cursor = ''); }); // 清理地图实例 return () => map.remove(); }, []); return ( <Suspense fallback={<Spinner />}> <div className="flex-1 flex flex-col items-center gap-y-4 animate-fade-from-top"> <div id="map" className="w-full h-[80vh]" /> </div> </Suspense> ); }; export { CdayMap };
额外说明
- 客户端组件支持
async/await,如果需要异步加载数据,可以直接在组件内使用async function,或者在useEffect中调用异步函数 - Maplibre必须使用客户端组件,因为它依赖浏览器的DOM和地理API,无法在服务端渲染
- 如果需要从服务端获取地图数据,可以在服务端组件中获取数据,然后通过props传递给客户端的Map组件
内容的提问来源于stack exchange,提问作者agi
相关产品推荐
相关产品推荐

