You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 04:54:54