[JavaScript, Maps API, ReactJS] Использование mapbox-gl в React и Next.js

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
01-Июл-2021 16:30

ВведениеВ данной статье я хочу описать известные мне способы встраивания mapbox-gl в React приложение, на примере создания простого веб приложения содержащего карту на Next.js с использованием Typescript, код компонента карты можно также использовать в любом любом приложении на ReactЭта статья входит в цикл статейУправление состоянием mapbox-gl в ReactОписание проблемыВ процессе моей работы в geoalert.io я не раз сталкивался с проблемой управления со...habr.comЯ рассмотрю несколько вариантов реализации на примере создания функционального компонента карты:
  • Имплементация с хранением инстанса карты внутри React компонента
  • Хранение инстанса карты вне React
Справка по сниппетам с кодом
​​ Для комфортного чтения данной статьи Вам необходимо иметь базовые знания React, Typescript и CSS
​​ Все сниппеты с кодом будут с использованием Typescript, использование типизации в javascript является лучшей практикой, поэтому я принципиально ее придерживаюсь там где это возможно, прошу прощения если вы ее не знакомы с ним, вот замечательный курс от egghead.io где вы сможете с ним ознакомиться
​​ Я предпочитаю импортировать React как import * as React from "react" подробнее об этом можно почитать в замечательном артикле от Kent C. Dodds
​​ Если в коде встречается // ... это необходимо читать как места с пропущенным повторяющимся кодом
Подготовка окруженияПрежде всего создадим новый проект на Next.js по шаблону Typescript
npx create-next-app --typescript my-awesome-app
Откроем папку проекта и установим так же mapbox-gl с типами для Typescript
cd my-awesome-app
npm install --save mapbox-gl && npm install -D @types/mapbox-gl
Так же нам потребуется accessToken для mapbox-gl поместим его в переменной окружения чтобы не хранить его непосредственно в коде приложения
touch .env.local
echo NEXT_PUBLIC_MAPBOX_TOKEN=<ваш_токен> >> .env.local
Так должен выглядеть ваш файл с переменной окружения дляNext.js.env.local
NEXT_PUBLIC_MAPBOX_TOKEN=<ваш_токен>
Имплементация в виде функционального компонента ReactПодготовка стилейУдалим лишние стили и обновим глобальный файл стилей
rm styles/Home.module.css
styles/global.css
html,
body,
#__next {
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
}
* {
  box-sizing: border-box;
}
Чтобы высота содержимого приложения равнялась 100% высоты окна, зададим свойства width и height равные 100% для html и body Высоту так же необходимо указать для элемента с css селектором #__next так как в Next.js приложении корневым элементом является <div id="__next">...</div>Подготовка компонента картыcomponents/mapbox-map.tsx
import * as React from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
// импортируем стили mapbox-gl чтобы карта отображалась коррекно
function MapboxMap() {
    // здесь будет хранится инстанс карты после инициализации
  const [map, setMap] = React.useState<mapboxgl.Map>();
  // React ref для хранения ссылки на DOM ноду который будет
  // использоваться как обязательный параметр `container`
  // при инициализации карты `mapbox-gl`
  // по-умолчанию будет содержать `null`
    const mapNode = React.useRef(null);
  React.useEffect(() => {
    const node = mapNode.current;
        // если объект window не найден,
        // то есть компонент рендерится на сервере
        // или dom node не инициализирована, то ничего не делаем
    if (typeof window === "undefined" || node === null) return;
    // иначе создаем инстанс карты передавая ему ссылку на DOM ноду
    // а также accessToken для mapbox
    const mapboxMap = new mapboxgl.Map({
      container: node,
            accessToken: process.env.NEXT_PUBLIC_MAPBOX_TOKEN,
            style: "mapbox://styles/mapbox/streets-v11",
      center: [-74.5, 40],
      zoom: 9,
    });
    // и сохраняем созданный объект карты в React.useState
    setMap(mapboxMap);
    // чтобы избежать утечки памяти удаляем инстанс карты
    // когда компонент будет демонтирован
    return () => {
      mapboxMap.remove();
    };
  }, []);
    return <div ref={mapNode} style={{ width: "100%", height: "100%" }} />;
}
export default MapboxMap
Описание параметров инициализации mapbox-gl можно посмотреть в документации Map | Mapbox GL JSdocs.mapbox.comДалее импортируем его в главную страницу приложения и запустим проектpages/index.tsx
import MapboxMap from "../components/mapbox-map";
function App() {
  return <MapboxMap />;
}
export default App;
npm run dev
Открыв http://localhost:3000 видим полноэкранную веб-карту
Что можно сделать лучшеВ предложенной реализации не хватает нескольких полезных фичей
  • Параметры инициализации карты - при использовании компонента карты логичным выглядит иметь возможность передать ему через props начальные параметры отображения карты
  • Доступ к инстансу карты из других компонентов - помимо самой веб-карты в приложении как правило содержатся другие компоненты для которых необходимо иметь доступ напрямую к инстансу карты
  • Нотификация о готовности карты - загрузка карты занимает некоторое время, пока пользователь ожидает открытия карты, для улучшения пользовательского опыта, можно показывать скелетон или загрузочный экран со спиннером. Для этих целей было бы удобно иметь коллбек срабатывающий после того как карта полностью загружена
Пример с загрузкой карты в моем приложении https://app.mapflow.ai
Улучшенный компонент картыДавайте имплементируем все эти возможности, сначала добавим props для компонента веб-карты
interface MapboxMapProps {
  initialOptions?: Omit<mapboxgl.MapboxOptions, "container">;
  onMapLoaded?(map: mapboxgl.Map): void;
  onMapRemoved?(): void;
}
function MapboxMap({ initialOptions = {}, onMapLoaded }: MapboxMapProps) {
    // ...
Используемые props
  • initialOptions - параметры инициализации карты, свойство container интерфейса MapboxOptions в данному случае не потребуется, чтобы исключить его используем утилитарный типOmit
  • onMapLoaded - коллбек вызываемый по событию полной загрузки карты
  • onMapRemoved - коллбек вызываемый при удалении инстанса карты
Свойство container интерфейса MapboxOptions в данному случае не потребуется, чтобы исключить его используем утилитарный типOmitПередадим initialOptions в аргументы инициализации веб-карты, используя spread syntax, так же установим обработчик события загрузки карты, если коллбек onMapLoaded, устанавливаем только в том случае если он был передан в props компонента, аналогично для onMapRemoved
// ...
    const mapboxMap = new mapboxgl.Map({
      container: node,
      accessToken: process.env.NEXT_PUBLIC_MAPBOX_TOKEN,
      style: "mapbox://styles/mapbox/streets-v11",
      center: [-74.5, 40],
      zoom: 9,
      ...initialOptions,
    });
    setMap(mapboxMap);
    // если onMapLoaded указан, он будет вызван единожды
    // по событию загрузка карты
    if (onMapLoaded) mapboxMap.once("load", onMapLoaded);
    return () => {
      mapboxMap.remove();
      if (onMapRemoved) onMapRemoved();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
// ...
Тут вы можете заметить специальный комментарий для линтера
// eslint-disable-next-line react-hooks/exhaustive-deps
Согласно общепринятому правилу react-hooks/exhaustive-deps мы должны были указать в списке зависимостей для React.useEffect добавленные в хук переменные [initialOptions, onMapLoaded]В данном случае важно оставить список зависимостей пустым, это позволит не пересоздавать инстанс карты повторно если initialOptions или onMapLoaded изменятся, подробнее о использовании React.useEffect можно почитать по ссылке нижеA Complete Guide to useEffectoverreacted.ioВ итоге компонент будет выглядеть такcomponents/mapbox-map.tsx
import * as React from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
interface MapboxMapProps {
  initialOptions?: Omit<mapboxgl.MapboxOptions, "container">;
  onMapLoaded?(map: mapboxgl.Map): void;
  onMapRemoved?(): void;
}
function MapboxMap({
  initialOptions = {}, onMapLoaded, onMapRemoved
}: MapboxMapProps) {
  const [map, setMap] = React.useState<mapboxgl.Map>();
  const mapNode = React.useRef(null);
  React.useEffect(() => {
    const node = mapNode.current;
    if (typeof window === "undefined" || node === null) return;
    const mapboxMap = new mapboxgl.Map({
      container: node,
      accessToken: process.env.NEXT_PUBLIC_MAPBOX_TOKEN,
      style: "mapbox://styles/mapbox/streets-v11",
      center: [-74.5, 40],
      zoom: 9,
      ...initialOptions,
    });
    setMap(mapboxMap);
    if (onMapLoaded) mapboxMap.once("load", onMapLoaded);
    return () => {
      mapboxMap.remove();
      if (onMapRemoved) onMapRemoved();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  return <div ref={mapNode} style={{ width: "100%", height: "100%" }} />;
}
export default MapboxMap;
Теперь мы можем переопределять стандартные свойства при создании карты и использовать коллбек onMapLoaded по событию ее загрузки. Так же мы можем использовать onMapLoaded чтобы сохранить ссылку на инстанс карты например в родительском компоненте. Так же мы можем использовать onMapRemoved если нам необходимо узнать что инстанс карты был удален.Воспользуемся этим, укажем координаты центра карты, а так же добавим начальный экран загрузки карты.Для начала подготовим компонент MapLoadingHolder который будет отображаться поверх карты пока она не загружена.Для экрана загрузки нам так же потребует svg иконка, я ее с Freepic, предварительно конвертировав ее в jsx формат с помощью https://svg2jsx.com/components/world-icon.tsx
function WorldIcon({ className = "" }: { className?: string }) {
  return (
    <svg
      className={className}
      xmlns="http://www.w3.org/2000/svg"
      width="48.625"
      height="48.625"
      x="0"
      y="0"
      enableBackground="new 0 0 48.625 48.625"
      version="1.1"
      viewBox="0 0 48.625 48.625"
      xmlSpace="preserve"
    >
      <path d="M35.432 10.815L35.479 11.176 34.938 11.288 34.866 12.057 35.514 12.057 36.376 11.974 36.821 11.445 36.348 11.261 36.089 10.963 35.7 10.333 35.514 9.442 34.783 9.591 34.578 9.905 34.578 10.259 34.93 10.5z"></path>
      <path d="M34.809 11.111L34.848 10.629 34.419 10.444 33.819 10.583 33.374 11.297 33.374 11.76 33.893 11.76z"></path>
      <path d="M22.459 13.158l-.132.34h-.639v.33h.152l.022.162.392-.033.245-.152.064-.307.317-.027.125-.258-.291-.06-.255.005z"></path>
      <path d="M20.812 13.757L20.787 14.08 21.25 14.041 21.298 13.717 21.02 13.498z"></path>
      <path d="M48.619 24.061a24.552 24.552 0 00-.11-2.112 24.165 24.165 0 00-1.609-6.62c-.062-.155-.119-.312-.185-.465a24.341 24.341 0 00-4.939-7.441 24.19 24.19 0 00-1.11-1.086A24.22 24.22 0 0024.312 0c-6.345 0-12.126 2.445-16.46 6.44a24.6 24.6 0 00-2.78 3.035A24.18 24.18 0 000 24.312c0 13.407 10.907 24.313 24.313 24.313 9.43 0 17.617-5.4 21.647-13.268a24.081 24.081 0 002.285-6.795c.245-1.381.379-2.801.379-4.25.001-.084-.004-.167-.005-.251zm-4.576-9.717l.141-.158c.185.359.358.724.523 1.094l-.23-.009-.434.06v-.987zm-3.513-4.242l.004-1.086c.382.405.75.822 1.102 1.254l-.438.652-1.531-.014-.096-.319.959-.487zM11.202 7.403v-.041h.487l.042-.167h.797v.348l-.229.306h-1.098l.001-.446zm.778 1.085s.487-.083.529-.083 0 .486 0 .486l-1.098.069-.209-.25.778-.222zm33.612 9.651h-1.779l-1.084-.807-1.141.111v.696h-.361l-.39-.278-1.976-.501v-1.28l-2.504.195-.776.417h-.994l-.487-.049-1.207.67v1.261l-2.467 1.78.205.76h.5l-.131.724-.352.129-.019 1.892 2.132 2.428h.928l.056-.148h1.668l.481-.445h.946l.519.52 1.41.146-.187 1.875 1.565 2.763-.824 1.575.056.742.649.647v1.784l.852 1.146v1.482h.736c-4.096 5.029-10.33 8.25-17.305 8.25C12.009 46.625 2 36.615 2 24.312c0-3.097.636-6.049 1.781-8.732v-.696l.798-.969c.277-.523.574-1.033.891-1.53l.036.405-.926 1.125a22.14 22.14 0 00-.798 1.665v1.27l.927.446v1.765l.889 1.517.723.111.093-.52-.853-1.316-.167-1.279h.5l.211 1.316 1.233 1.799-.318.581.784 1.199 1.947.482v-.315l.779.111-.074.556.612.112.945.258 1.335 1.521 1.705.129.167 1.391-1.167.816-.055 1.242-.167.76 1.688 2.113.129.724s.612.166.687.166c.074 0 1.372.983 1.372.983v3.819l.463.13-.315 1.762.779 1.039-.144 1.746 1.029 1.809 1.321 1.154 1.328.024.13-.427-.976-.822.056-.408.175-.5.037-.51-.66-.02-.333-.418.548-.527.074-.398-.612-.175.036-.37.872-.132 1.326-.637.445-.816 1.391-1.78-.316-1.392.427-.741 1.279.039.861-.682.278-2.686.955-1.213.167-.779-.871-.279-.575-.943-1.965-.02-1.558-.594-.074-1.111-.52-.909-1.409-.021-.814-1.278-.723-.353-.037.39-1.316.078-.482-.671-1.373-.279-1.131 1.307-1.78-.302-.129-2.006-1.299-.222.521-.984-.149-.565-1.707 1.141-1.074-.131-.383-.839.234-.865.592-1.091 1.363-.69 2.632-.001-.007.803.946.44-.075-1.372.682-.686 1.376-.904.094-.636 1.372-1.428 1.459-.808-.129-.106.988-.93.362.096.166.208.375-.416.092-.041-.411-.058-.417-.139v-.4l.221-.181h.487l.223.098.193.39.236-.036v-.034l.068.023.684-.105.097-.334.39.098v.362l-.362.249h.001l.053.397 1.239.382.003.015.285-.024.019-.537-.982-.447-.056-.258.815-.278.036-.78-.852-.519-.056-1.315-1.168.574h-.426l.112-1.001-1.59-.375-.658.497v1.516l-1.183.375-.474.988-.514.083v-1.264l-1.112-.154-.556-.362-.224-.819 1.989-1.164.973-.296.098.654.542-.028.042-.329.567-.081.01-.115-.244-.101-.056-.348.697-.059.421-.438.023-.032.005.002.128-.132 1.465-.185.648.55-1.699.905 2.162.51.28-.723h.945l.334-.63-.668-.167v-.797l-2.095-.928-1.446.167-.816.427.056 1.038-.853-.13-.131-.574.817-.742-1.483-.074-.426.129-.185.5.556.094-.111.556-.945.056-.148.37-1.371.038s-.038-.778-.093-.778l1.075-.019.817-.798-.446-.223-.593.576-.984-.056-.593-.816h-1.261l-1.316.983h1.206l.11.353-.313.291 1.335.037.204.482-1.503-.056-.073-.371-.945-.204-.501-.278-1.125.009A22.188 22.188 0 0124.312 2c5.642 0 10.797 2.109 14.73 5.574l-.265.474-1.029.403-.434.471.1.549.531.074.32.8.916-.369.151 1.07h-.276l-.752-.111-.834.14-.807 1.14-1.154.181-.167.988.487.115-.141.635-1.146-.23-1.051.23-.223.585.182 1.228.617.289 1.035-.006.699-.063.213-.556 1.092-1.419.719.147.708-.64.132.5 1.742 1.175-.213.286-.785-.042.302.428.483.106.566-.236-.012-.682.251-.126-.202-.214-1.162-.648-.306-.861h.966l.309.306.832.717.035.867.862.918.321-1.258.597-.326.112 1.029.583.64 1.163-.02c.225.579.427 1.168.604 1.769l-.121.112zm-32.331-7.093l.584-.278.528.126-.182.709-.57.181-.36-.738zm3.099 1.669v.459h-1.334l-.5-.139.125-.32.641-.265h.876v.265h.192zm.614.64v.445l-.334.215-.416.077v-.737h.75zm-.376-.181v-.529l.459.418-.459.111zm.209 1.07v.433l-.319.32h-.709l.111-.486.335-.029.069-.167.513-.071zm-1.766-.889h.737l-.945 1.321-.39-.209.084-.556.514-.556zm3.018.737v.432h-.709l-.194-.28v-.402h.056l.847.25zm-.655-.594l.202-.212.341.212-.273.225-.27-.225zm28.55 5.767l.07-.082c.029.126.06.252.088.38l-.158-.298z"></path>
      <path d="M3.782 14.884v.696c.243-.568.511-1.122.798-1.665l-.798.969z"></path>
    </svg>
  );
}
export default WorldIcon;
components/map-loading-holder.tsx
import WorldIcon from "../components/world-icon";
function MapLoadingHolder() {
  return (
    <div className="loading-holder">
      <WorldIcon className="icon" />
      <h1>Initializing the map</h1>
      <div className="icon-attribute">
        Icons made by{" "}
        <a href="https://www.freepik.com" title="Freepik">
          Freepik
        </a>{" "}
        from{" "}
        <a href="https://www.flaticon.com/" title="Flaticon">
          www.flaticon.com
        </a>
      </div>
    </div>
  );
}
export default MapLoadingHolder;
Теперь соберем все вместе, поместим приложение в элемент .app-container, внутри которого будут абсолютно позиционированный элемент карты помещенный в map-wrapper и компонент MapLoadingHolderДобавим так же компонент <Head>...</Head> в нем можно указать мета-теги и title для сайтаpages/index.tsx
import * as React from "react";
import Head from "next/head";
import MapboxMap from "../components/mapbox-map";
import MapLoadingHolder from "../components/map-loading-holder";
function App() {
  const [loading, setLoading] = React.useState(true);
  const handleMapLoading = () => setLoading(false);
  return (
    <>
      <Head>
        <title>Using mapbox-gl with React and Next.js</title>
      </Head>
      <div className="app-container">
        <div className="map-wrapper">
          <MapboxMap
            initialOptions={{ center: [38.0983, 55.7038] }}
            onMapLoaded={handleMapLoading}
          />
        </div>
        {loading && <MapLoadingHolder className="loading-holder" />}
      </div>
    </>
  );
}
export default App;
Внесем соответсвующие изменения в стили, добавим красивый фон для .loading-holder, также позиционируем его содержимое по центру, добавим пульсирующую анимацию для иконки, так как фон полупрозрачный, добавим цветную тень text-shadow: 0px 0px 10px rgba(152, 207, 195, 0.7); к элементу <h1>Initializing the map</h1>, подробнее об этом можно прочитать в моем посте про текст на цветном фонеdqunbpdqunbp.vercel.appstyles/global.css
html,
body,
#__next {
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
    Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
* {
  box-sizing: border-box;
}
.app-container {
  width: 100%;
  height: 100%;
  position: relative;
}
.map-wrapper,
.loading-holder {
  position: absolute;
  height: 100%;
  width: 100%;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
}
.loading-holder {
  background: -webkit-linear-gradient(
    45deg,
    rgba(152, 207, 195, 0.7),
    rgb(86, 181, 184)
  );
  background: -moz-linear-gradient(
    45deg,
    rgba(152, 207, 195, 0.7),
    rgb(86, 181, 184)
  );
  background: linear-gradient(
    45deg,
    rgba(152, 207, 195, 0.7),
    rgb(86, 181, 184),
    0.9
  );
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
}
.loading-holder .icon {
  transform: scale(2);
  fill: rgba(1, 1, 1, 0.7);
  animation: pulse 1.5s ease-in-out infinite;
}
.loading-holder h1 {
  margin-top: 4rem;
  text-shadow: 0px 0px 10px rgba(152, 207, 195, 0.7);
}
@keyframes pulse {
  0% {
    transform: scale(2);
  }
  50% {
    transform: scale(2.3);
  }
  100% {
    transform: scale(2);
  }
}
Теперь при открытии карты мы увидим симпатичный экран загрузки
Ссылки на исходный код и запущенное приложениеdqunbp/using-mapbox-gl-with-reactgithub.comUsing mapbox-gl with React and Next.jsusing-mapbox-gl-with-react.vercel.appХранение инстанса карты вне ReactПро то как хранить и использовать инстанс карты mapbox-gl вне React я расскажу в своей следующей статье
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_maps_api, #_reactjs, #_react, #_next.js, #_mapbox, #_javascript, #_maps_api, #_reactjs
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 13:48
Часовой пояс: UTC + 5