В этом посте будет рассмотрен способ использования mapbox-gl
в React
приложении, с хранением инстанса карты во вспомогательном объекте обертке. Это позволяет обращаться к карте из любой части приложения, без необходимости передавать ссылку на карту средствами React
Под словами "ссылка на инстанс карты" или "ссылка на карту" подразумевается переменная содержащая объект карты.
Это обусловлено тем, что переменная содержащая объект на самом деле содержит ссылку на него, но не сам объект, тут можно узнать об этом подробнее https://blog.noveogroup.ru/2021/02/upravlenie-pamyatju-v-javascript/
Эта статья входит в цикл статей
Инструкцию по созданию нового проекта и имплементацию компонента карты, вы можете посмотреть в моем предыдущем посте
Классический подход
При использовании mapbox-gl
в React
приложении, возникает проблема организации доступа к нему из других компонентов приложения
Как правило ссылку на инстанс mapbox-gl
можно передавать через props либо через контекст
Допустим наше приложение состоит из полноэкранного компонента с веб картой и боковой панели, содержащей элементы для взаимодействия с картой
В последующих примерах кода некоторые детали опущены, например стили
Пример передачи ссылки через props
Код приложения в данном случае будет выглядеть примерно так
components/pass-map-with-props.tsx
import * as React from "react";
import MapboxMap from "./mapbox-map";
export const Sidebar: React.FC<{ map: mapboxgl.Map | undefined }> = ({
map,
}) => {
return <div>...some sidebar content...</div>;
};
const App: React.FC = () => {
const [map, setMap] = React.useState<mapboxgl.Map>();
return (
<div>
<MapboxMap onLoaded={setMap} />
<Sidebar map={map} />
</div>
);
};
export default App;
Когда карта полностью загрузится, ссылка на нее сохраняется внутри React.useState
, далее она передается в компонент Sidebar
через props
Пример передачи ссылки через контекст
Чтобы передать ссылку на инстанс через контекст, создадим контекст и обернем в него Sidebar
компонент, а так же хук useMapboxMap
для доступа к инстансу карты через контекст
components/pass-map-with-context.tsx
import * as React from "react";
import MapboxMap from "./mapbox-map";
const MapboxMapContext = React.createContext<mapboxgl.Map | undefined>(
undefined
);
function useMapboxMap() {
return React.useContext(MapboxMapContext);
}
const Sidebar: React.FC = () => {
const mapboxMap = useMapboxMap();
return <div>...some sidebar content...</div>;
};
const App: React.FC = () => {
const [map, setMap] = React.useState<mapboxgl.Map>();
return (
<div>
<MapboxMap onLoaded={setMap} />
<MapboxMapContext.Provider value={map}>
<Sidebar />
</MapboxMapContext.Provider>
</div>
);
};
export default App;
Теперь на инстанс карты можно сослаться откуда угодно изнутри Sidebar
используя useMapboxMap
В обоих примерах есть один общий недостаток, передача ссылки на карту в дочерние компоненты влечет за собой дополнительные неудобства при разработке, это может вызвать такие проблемы как prop drilling, а при использовании контекста отдельное внимание потребуется выделить организации иерархии компонентов. Если вы будете использовать его в ваших хуках, это может потребовать дополнительного кода для избежания проблем с exhaustive-deps. Так же могут возникать сложности при необходимости обращения к карте из внешнего хранилища состояния, например из Redux или XState.
Хранение вне React
Хотелось бы иметь возможность обращаться к карте из любого компонента, без необходимости как-то специально его туда передавать и указывать в списках зависимостей хуков
Вспомогательный объект
Для того чтобы иметь возможность сделать это потребуется вспомогательный объект-обертка в котором будет храниться ссылка на инстанс карты
lib/map-wrapper.ts
class MapWrapper {
private _map?: mapboxgl.Map;
set map(instance: mapboxgl.Map) {
this._map = instance;
}
get map() {
if (typeof this._map === "undefined")
throw new Error("Cannot access mapbox map before inilizing it");
return this._map;
}
remove() {
if (typeof this._map === "undefined")
throw new Error("Cannot remove mapbox map before inilizing");
this._map.remove();
this._map = undefined;
}
}
export const mapbox = new MapWrapper();
Создадим класс MapWrapper
используя возможности typescript
для работы с классами
Инстанс карты будет храниться в приватной переменной _map
, зададим также сеттер и геттер для этой переменной и метод для удаления инстанса карты
set
- для сохранения карты в приватную переменнуюget
- если карта не инициализирована, при попытке обратиться к ней выбрасывается исключениеremove
- если страница с картой например была закрыта, инстанс карты необходимо удалить чтобы избежать проблем с утечкой памяти, метод можно вызвать только если карта была инициализирована, в ином случае вызывается исключение
Применительно к предыдущим примерам получим следующее:
import * as React from "react";
import "mapbox-gl/dist/mapbox-gl.css";
import MapboxMap from "../components/mapbox-map";
// Импорт объекта обертки
import mapbox from "../lib/map-wrapper"
const Sidebar: React.FC = () => {
const setCenterToMoscow = () => mapbox.map.setCenter([
37.60345458984374,
55.695776911386126
])
return (
<div>
<button onClick={setCenterToMoscow}>
Set map center to Moscow
</button>
</div>
);
};
const WithOutsideMap: React.FC = () => {
const onMapCreated = React.useCallback((map: mapboxgl.Map) => {
// сохраняем инстанс карты в обертку при его создании
mapbox.map = map;
}, []);
return (
<div>
<MapboxMap onCreated={onMapCreated} />
<Sidebar />
</div>
);
};
export default WithOutsideMap;
Теперь мы можем обращаться к карте из компонента Sidebar
, без необходимости его передавать в компонент средствами React
.
Живой пример
Давайте воспроизведем пример из документации mapboxgl
с отображением текущего центра и зума карты
components/with-outside-map.tsx
import * as React from "react";
import "mapbox-gl/dist/mapbox-gl.css";
import MapboxMap from "../components/mapbox-map";
import mapbox from "../lib/map-wrapper";
const WithOutsideMap: React.FC = () => {
// текущий зум и центр карты
const [viewport, setViewport] = React.useState({
center: ["-74.5165", "40.0021"],
zoom: "9.00",
});
const { center: [lng, lat], zoom } = viewport;
const onMapCreated = React.useCallback((map: mapboxgl.Map) => {
mapbox.map = map;
// после того как карта создана, привяжем ивент, по событию "move"
// обновляющий значение viewport
mapbox.map.on("move", () => {
setViewport({
center: [
mapbox.map.getCenter().lng.toFixed(4),
mapbox.map.getCenter().lat.toFixed(4),
],
zoom: mapbox.map.getZoom().toFixed(2),
});
});
}, []);
return (
<div className="app-container">
<div className="map-wrapper">
<div className="viewport-panel">
Longitude: {lng} | Latitude: {lat} | Zoom: {zoom}
</div>
<MapboxMap
onCreated={onMapCreated}
{/* зададим начальный центр и зум для карты */}
initialOptions={{ center: [+lng, +lat], zoom: +zoom }}
/>
</div>
</div>
);
};
export default WithOutsideMap;
Центр карты будет храниться в объекте viewport
, при передвижении карты пользователем, будет срабатывать событие move
, по которому мы обновляем текущее значение viewport
.
Значения хранящиеся во viewport
используются для отображения текущих координат центра и зума карты, а так же для передачи параметров в initialOptions
компонента карты.
Объект initialOptions
используется единожды, при создании карты.
При передаче параметров в initialOptions
можно заметить +
перед переменными, это нужно для конвертации их в числа, так как мы храним цифровые значения как строки.
Ссылка на запущенное приложение
В следующей, завершающей, статье цикла я планирую рассказать об управлении состоянием React
приложения сmapbox-gl
с использованием XState
Ссылки на исходный код и запущенное приложение