React + Mapbox 初学者教程 Mapbox 和 React

2025-06-07

React + Mapbox 初学者教程

Mapbox 和 React

Mapbox 和 React

我开发的首批全栈应用之一是一款交互式地图,可以向用户显示最近的垃圾食品购买点。前端使用 Google Maps API 和原生 JavaScript 构建。后端是一个 Node + Express 服务器,用于查询 Yelp API,查找评分低且搜索词听起来不健康的商家。

我最近决定用 React 和 Mapbox 前端重新创建那个应用程序(后端用 Go,不过那是另外一回事)。虽然我还不是 Mapbox 专家,但我还是想分享一些我学到的东西,希望能帮助其他人加快学习进度。本文假设您有 React 的使用经验,但对 Mapbox 还不熟悉。

为什么选择 Mapbox?

Mapbox 是一款功能强大、用途广泛的工具,可用于创建交互式地图和可视化地理数据。许多知名公司都在其各种用例中使用它(例如《纽约时报》、Strava 和 Weather Channel)。

为什么选择 React?

我承认,在这个应用中使用 React 有点儿过度了。Mapbox 已经提供了一系列非常简单的示例,可以作为一个很好的起点。然而,大多数现代复杂的 Web 应用都会使用某种库或框架。我选择 React 是因为它无处不在。

应用程序前提和设置

在这个应用中,我们将创建一个交互式地图,它会根据地图的中心点获取一些数据并显示结果。每次地图中心发生变化时,结果都会重新绘制在地图上。

API 超出了本文的范围,因此我们将使用随机模拟数据。

首先,创建一个新的 React 应用程序并安装mapbox-gl为依赖项:

npx create-react-app react-mapbox-example
cd react-mapbox-example
yarn add mapbox-gl

接下来,创建一个免费的 Mapbox 帐户并在此处获取 API 访问令牌。在项目根目录中,创建一个.env.local文件并将您的令牌添加到其中:

/* .env.local */
REACT_APP_MAPBOX_ACCESS_TOKEN=YOUR_TOKEN_HERE

<head>在您的文件中添加 Mapbox CSS 文件public/index.html(确保版本号与您的 中的版本号匹配,您的版本号可能不是 1.9.0。您可以在此处package.json找到最新版本。):

/* public/index.html */
<link href="https://api.mapbox.com/mapbox-gl-js/v1.9.0/mapbox-gl.css" rel="stylesheet" />

创建地图

Mapbox 有一些使用类组件的 React 示例,但我想尝试一下函数式组件。使用函数式组件时需要注意一些关键区别:

  • 您需要使用useEffect钩子和后跟空的依赖数组来初始化您的地图,这与的功能相同componentDidMount
  • 这个useRef钩子可能也很有用,因为它允许你的地图在组件的整个生命周期内持续存在,即使重新渲染也是如此。在我的例子中,我将采用这种方式。

要添加地图,src/App.js请用以下代码替换的内容:

/* src/App.js */
import React, { useRef, useEffect } from 'react';
import mapboxgl from 'mapbox-gl';

import './App.css';

mapboxgl.accessToken = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN;

const App = () => {
  const mapContainerRef = useRef(null);

  // initialize map when component mounts
  useEffect(() => {
    const map = new mapboxgl.Map({
      container: mapContainerRef.current,
      // See style options here: https://docs.mapbox.com/api/maps/#styles
      style: 'mapbox://styles/mapbox/streets-v11',
      center: [-104.9876, 39.7405],
      zoom: 12.5,
    });

    // add navigation control (the +/- zoom buttons)
    map.addControl(new mapboxgl.NavigationControl(), 'bottom-right');

    // clean up on unmount
    return () => map.remove();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  return <div className="map-container" ref={mapContainerRef} />;
};

export default App;

要设置地图样式,请将内容替换src/Apps.css为:

/* src/App.css */
.map-container {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
}

现在,当您在本地运行该应用程序时,您应该会看到全屏地图。

向地图添加数据

Mapbox 可以使用多种不同格式的数据,但在本例中,我们将把虚构数据格式化为 GeoJSON FeatureCollection。如果您想深入了解 GeoJSON,可以点击此处进行操作,但现在您真正需要知道的是,GeoJSON FeatureCollection 看起来像这样,其中"features"数组中的每个项目都是地图上的单个点:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        // there are different geometry types, but Point is best
        // for this use case of simple latitude/longitude pairs
        "type": "Point",
        "coordinates": [0, 0] // longitude, latitude
      },
      "properties": {
        // you can put almost anything here, it's kind of like
        // the "metadata" for the feature
        "name": "Some Cool Point"
      }
    }
  ]
}

我们将创建一个名为 的文件src/api/fetchFakeData.js。在这个文件中,我们可能会进行真正的 API 调用来获取一组新的结果。相反,我们将返回一个基于地图中心点随机生成的 20 个坐标的列表。

/* src/api/fetchFakeData.js */
/**
 * A complete Coordinate Pair consisting of a latitude and longitude
 * @typedef {Object} CoordinatePair
 * @property {number} longitude - longitude coordinate
 * @property {number} latitude - latitude coordinate
 */

/**
 * Generates a GeoJSON FeatureCollection of random points based on
 * the center coordinates passed in.
 * @param {CoordinatePair} centerCoordinates - the {@link CoordinatePair} for the map center
 * @return {results} GeoJSON FeatureCollection
 */
const fetchFakeData = centerCoordinates => {
  const newFeaturesList = [];
  for (let i = 0; i < 20; i++) {
    const id = i;
    const { longitude, latitude } = getRandomCoordinate(centerCoordinates);
    newFeaturesList.push({
      type: 'Feature',
      geometry: {
        type: 'Point',
        coordinates: [longitude, latitude],
      },
      properties: {
        id,
        name: `Random Point #${id}`,
        description: `description for Random Point #${id}`,
      },
    });
  }

  return Promise.resolve({
    type: 'FeatureCollection',
    features: newFeaturesList,
  });
};

/**
 * Generates a random point within 0.025 radius of map center coordinates.
 * @param {CoordinatePair} centerCoordinates - the {@link CoordinatePair} for the map center
 * @return {CoordinatePair} randomly generated coordinate pair
 */
const getRandomCoordinate = ({ longitude: centerLon, latitude: centerLat }) => {
  const r = 0.025 * Math.sqrt(Math.random());
  const theta = Math.random() * 2 * Math.PI;
  const latitude = centerLat + r * Math.cos(theta);
  const longitude = centerLon + r * Math.sin(theta);
  return { longitude, latitude };
};

export default fetchFakeData;

标记

我第一次尝试在地图上显示数据时,遍历了 API 的结果,并将每个结果作为标记附加到地图上。剧透:这不是最好的主意。如果您不想了解标记以及为什么我选择不在此特定地图中使用它们,请直接跳到“图层”部分。

首先,我创建了一个 Marker 组件:

/* src/components/Marker.js */
import React from 'react';

const Marker = ({ id }) => <div id={`marker-${id}`} className="marker" />;

export default Marker;

...使用 svg 样式:

/* src/App.css */
.marker {
  background-image: url('svg/marker.svg');
  background-size: cover;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  cursor: pointer;
}

接下来,我在地图上添加了标记。回到src/App.js,我导入了Marker组件,以及ReactDOM来自“react-dom”的组件。在初始化地图(在 useEffect 钩子内部)之后,我立即添加了一个事件监听器,该监听器会在地图移动时获取新的虚假数据,并将每个要素作为标记附加到地图上:

map.on('moveend', async () => {
  // get center coordinates
  const { lng, lat } = map.getCenter();
  // fetch new data
  const results = await fetchFakeData({ longitude: lng, latitude: lat });
  // iterate through the feature collection and append marker to the map for each feature
  results.features.forEach(result => {
    const { id, geometry } = result;
    // create marker node
    const markerNode = document.createElement('div');
    ReactDOM.render(<Marker id={id} />, markerNode);
    // add marker to map
    new mapboxgl.Marker(markerNode)
      .setLngLat(geometry.coordinates)
      .addTo(map);
  });
});

太棒了,现在我移动地图时,就能看到标记了。然而,随着我继续平移,效果是累积的——我在地图上添加了更多标记,覆盖了之前的标记。:(

要移除标记,你必须调用.remove()标记实例的方法,这意味着你需要将每个标记保存到状态机上的某个数组中,以便稍后访问和循环。这对我来说已经感觉有点混乱了,所以我放弃了标记,转而开始探索图层。

图层

图层本质上是具有相同样式的数据集合。Mapbox 支持多种不同的数据类型,称为“源”,这些数据类型可以输入到图层中。

回到src/App.js,在初始化地图(在 useEffect 钩子内)后,我们将添加一个事件监听器,等待地图加载,然后添加我们的数据源和图层。

/* src/App.js */
map.on('load', () => {
  // add the data source for new a feature collection with no features
  map.addSource('random-points-data', {
    type: 'geojson',
    data: {
      type: 'FeatureCollection',
      features: [],
    },
  });
  // now add the layer, and reference the data source above by name
  map.addLayer({
    id: 'random-points-layer',
    source: 'random-points-data',
    type: 'symbol',
    layout: {
      // full list of icons here: https://labs.mapbox.com/maki-icons
      'icon-image': 'bakery-15', // this will put little croissants on our map
      'icon-padding': 0,
      'icon-allow-overlap': true,
    },
  });
});

此时,您仍然不应该在地图上看到数据。与标记类似,我们需要添加一个事件监听器,用于在移动结束时获取带有更新中心坐标的虚假数据。不过,这次我们不用循环遍历所有要素并将其添加到地图,而是直接使用新的 FeatureCollection 更新数据源。为此,我们必须导入该fetchFakeData函数,并在“加载时”监听器之后添加另一个监听器:

/* src/App.js */
map.on('moveend', async () => {
  // get new center coordinates
  const { lng, lat } = map.getCenter();
  // fetch new data
  const results = await fetchFakeData(lng, lat);
  // update "random-points-data" source with new data
  // all layers that consume the "random-points-data" data source will be updated automatically
  map.getSource('random-points-data').setData(results);
});

现在,当您在地图上平移时,您会看到周围散布着小羊角面包图标。

标记与图层总结

标记更适合静态数据或易于手动管理的小数据点,例如用户的当前位置。虽然使用自定义 SVG 或 CSS 图片更容易设置标记的样式,但标记数量过多时管理起来会比较困难,而且交互也比较困难。

使用图层可以更轻松地管理更大的动态数据集。虽然设置样式略有困难(我个人认为),但交互起来却更容易。您可以向地图添加事件监听器,通过其唯一 ID 来定位特定图层,从而轻松访问和操作这些图层中的要素,而无需手动管理数据。

添加悬停弹出窗口

为了使地图更具交互性,我们可以添加一个弹出框,当用户点击某个要素时,它会显示更多详细信息。首先,我将创建一个新Popup组件:

/* src/components/Popup.js */
import React from 'react';

const Popup = ({ feature }) => {
  const { id, name, description } = feature.properties;

  return (
    <div id={`popup-${id}`}>
      <h3>{name}</h3>
      {description}
    </div>
  );
};

export default Popup;

回到src/App.js,我们需要从 导入该组件Popup我希望这个弹出窗口在组件的整个生命周期内都持续存在,就像地图一样,所以我会在 之后立即添加一个,如下所示:ReactDOM"react-dom"ApppopUpRefmapContainerRef

/* src/App.js */
// offset puts the popup 15px above the feature
const popUpRef = useRef(new mapboxgl.Popup({ offset: 15 }));

为了设置弹出窗口的内容并使其实际显示,我们将向地图层添加“click”事件监听器:

/* src/App.js */
// add popup when user clicks a point
map.on('click', 'random-points-layer', e => {
  if (e.features.length) {
    const feature = e.features[0];
    // create popup node
    const popupNode = document.createElement('div');
    ReactDOM.render(<Popup feature={feature} />, popupNode);
    // set popup on map
    popUpRef.current.setLngLat(feature.geometry.coordinates).setDOMContent(popupNode).addTo(map);
  }
});

现在,当您点击某个功能时,应该会看到弹窗。我们还可以在用户鼠标悬停在可点击功能上时,将光标更改为指针,然后在鼠标离开时恢复默认状态。以下是我为实现此视觉提示而添加的监听器:

/* App.js */
// change cursor to pointer when user hovers over a clickable feature
map.on('mouseenter', 'random-points-layer', e => {
  if (e.features.length) {
    map.getCanvas().style.cursor = 'pointer';
  }
});

// reset cursor to default when user is no longer hovering over a clickable feature
map.on('mouseleave', 'random-points-layer', () => {
  map.getCanvas().style.cursor = '';
});

后续步骤

如你所见,Mapbox 的可定制性非常高,很容易让你陷入各种难以捉摸的困境,难以对项目进行精细调整,所以我们就此打住。但如果你愿意挑战自我,还有很多工作可以做,让这样的地图更加易用。

例如,你会注意到放大或缩小地图会触发“moveend”监听器并生成新的点。这很不合理。更好的解决方案可能是使用“moveend”监听器来更新组件状态的坐标,然后创建一个新的useEffect钩子,该钩子仅在中心坐标发生变化时运行,获取新数据并将“random-points-data”源设置为新数据。毕竟,在挂载时初始化地图的钩子之外访问和操作地图的能力,useEffect很大程度上影响了我决定将地图存储在引用中。

希望这对其他人也能有所帮助,就像我第一次开始深入研究 Mapbox 时一样!

太长不看;

这是代码。

文章来源:https://dev.to/laney/react-mapbox-beginner-tutorial-2e35
PREV
凌晨 5 点的 {Hack}
NEXT
🦕🦀用 Rust 编写 WebAssembly 并在 Deno 中运行它!