GeoJSON Performance Optimization
Created on: February 4, 2025
Created on: February 4, 2025
// src/TimeSeriesMapViewer.jsx import React, { useMemo, useEffect, useState, useRef } from 'react'; import { MapContainer, TileLayer, ZoomControl, Marker, Popup, useMapEvent, } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import Supercluster from 'supercluster'; import FitBounds from './FitBounds'; import ClusterPopupContent from './ClusterPopupContent'; import MarkerPopupContent from './MarkerPopupContent'; import ReactDOM from 'react-dom/client';
import { Box, Button, Checkbox, FormControl, FormControlLabel, FormGroup, FormLabel, InputLabel, Select, MenuItem, Chip, Slider, Typography, } from '@mui/material';
import iconUrl from 'leaflet/dist/images/marker-icon.png'; import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'; import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl, });
const createClusterIcon = (count) => {
let size = 'small';
if (count >= 100) {
size = 'large';
} else if (count >= 50) {
size = 'medium';
}
return L.divIcon({
html: <div class="cluster-marker ${size}"><span>${count}</span></div>
,
className: 'cluster-icon',
iconSize: [40, 40],
});
};
function flattenProps(obj, prefix = '') { const result = {}; for (const [k, v] of Object.entries(obj)) { const newKey = prefix ? prefix + '.' + k : k; if (Array.isArray(v)) { result[newKey] = v; } else if (v && typeof v === 'object') { Object.assign(result, flattenProps(v, newKey)); } else { result[newKey] = v; } } return result; }
function TimeSeriesMapViewer({ geoJSONData, selectedTags = [] }) { console.log('TimeSeriesMapViewer props:', { geoJSONData, selectedTags });
const mapRef = useRef(); const layerRegistry = useRef(new Map());
const features = useMemo(() => { if (geoJSONData?.data?.features) { return geoJSONData.data.features; } return []; }, [geoJSONData]);
const prevFeaturesCountRef = useRef(features.length);
const flattenedFeatures = useMemo(() => { return features.map((f) => ({ ...f, _flatProps: flattenProps(f.properties || {}), })); }, [features]);
const sortedTimePoints = useMemo(() => { const timeSet = new Set(); features.forEach((f) => { const ft = f.properties?.time; if (ft) { const val = typeof ft === 'string' ? new Date(ft).getTime() : ft; timeSet.add(val); } }); return Array.from(timeSet).sort((a, b) => a - b); }, [features]);
const [currentTime, setCurrentTime] = useState( sortedTimePoints.length > 0 ? sortedTimePoints[sortedTimePoints.length - 1] : 0 ); const [isPlaying, setIsPlaying] = useState(false); const intervalRef = useRef(null);
useEffect(() => { if (sortedTimePoints.length > 0) { const last = sortedTimePoints[sortedTimePoints.length - 1]; setCurrentTime(last); } }, [sortedTimePoints]);
useEffect(() => { if (isPlaying) { const id = setInterval(() => { const nextT = sortedTimePoints.find((t) => t > currentTime); if (nextT) { setCurrentTime(nextT); } else { setIsPlaying(false); } }, 1000); intervalRef.current = id; return () => clearInterval(id); } else { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } } }, [isPlaying, currentTime, sortedTimePoints]);
const handlePlayPause = () => { if (isPlaying) { setIsPlaying(false); } else { if (sortedTimePoints.length > 0 && currentTime === sortedTimePoints[sortedTimePoints.length - 1]) { setCurrentTime(sortedTimePoints[0]); } setIsPlaying(true); } };
const [geometryFilter, setGeometryFilter] = useState([]); const allGeomTypes = useMemo(() => { const st = new Set(); features.forEach((f) => { if (f.geometry?.type) { st.add(f.geometry.type); } }); return Array.from(st); }, [features]);
useEffect(() => { setGeometryFilter(allGeomTypes); }, [allGeomTypes]);
const handleToggleGeomType = (type) => { setGeometryFilter((prev) => { if (prev.includes(type)) { return prev.filter((t) => t !== type); } return [...prev, type]; }); };
const [propertyFilters, setPropertyFilters] = useState({}); const [activeKey, setActiveKey] = useState(''); const [activeValues, setActiveValues] = useState([]);
useEffect(() => { if (!activeKey) { setActiveValues([]); } else { setActiveValues(propertyFilters[activeKey] || []); } }, [activeKey, propertyFilters]);
const allPropertyValuesMap = useMemo(() => { const map = {}; flattenedFeatures.forEach((f) => { const flt = f._flatProps; for (const [k, v] of Object.entries(flt)) { if (!map[k]) map[k] = new Set(); if (Array.isArray(v)) { v.forEach((it) => map[k].add(it)); } else { map[k].add(v); } } }); const obj = {}; for (const k in map) { obj[k] = Array.from(map[k]); } return obj; }, [flattenedFeatures]);
const handleToggleValue = (val) => { if (!activeKey) return; setActiveValues((prev) => { let newArr; if (prev.includes(val)) { newArr = prev.filter((x) => x !== val); } else { newArr = [...prev, val]; } setPropertyFilters((old) => { const copy = { ...old }; if (newArr.length === 0) { delete copy[activeKey]; } else { copy[activeKey] = newArr; } return copy; }); return newArr; }); };
const handleSelectAllValues = (checked) => { if (!activeKey) return; const allVals = allPropertyValuesMap[activeKey] || []; const newArr = checked ? allVals : []; setActiveValues(newArr); setPropertyFilters((old) => { const copy = { ...old }; if (newArr.length === 0) { delete copy[activeKey]; } else { copy[activeKey] = newArr; } return copy; }); };
const handleSelectKey = (e) => { setActiveKey(e.target.value); };
useEffect(() => { if (features.length !== prevFeaturesCountRef.current) { setPropertyFilters({}); setActiveKey(''); setActiveValues([]); } prevFeaturesCountRef.current = features.length; }, [features]);
const finalFilteredFeatures = useMemo(() => { return flattenedFeatures.filter((f) => { if (selectedTags.length > 0) { const tags = f.properties?.tags; if (tags) { const arr = Array.isArray(tags) ? tags : [tags]; if (!arr.some((tg) => selectedTags.includes(tg))) { return false; } } } if (geometryFilter.length === 0) { return false; } if (!geometryFilter.includes(f.geometry?.type)) { return false; } for (const [k, vals] of Object.entries(propertyFilters)) { if (!vals || vals.length === 0) continue; const propVal = f._flatProps[k]; if (Array.isArray(propVal)) { if (!propVal.some((item) => vals.includes(item))) { return false; } } else { if (!vals.includes(propVal)) { return false; } } } const ft = f.properties?.time; if (ft) { const val = typeof ft === 'string' ? new Date(ft).getTime() : ft; if (val > currentTime) { return false; } } return true; }); }, [flattenedFeatures, selectedTags, geometryFilter, propertyFilters, currentTime]);
const pointFeatures = useMemo(() => { return finalFilteredFeatures.filter( (f) => f.geometry?.type === 'Point' || f.geometry?.type === 'MultiPoint' ); }, [finalFilteredFeatures]);
const otherFeatures = useMemo(() => { return finalFilteredFeatures.filter( (f) => !(f.geometry?.type === 'Point' || f.geometry?.type === 'MultiPoint') ); }, [finalFilteredFeatures]);
const [clusters, setClusters] = useState([]); const [bounds, setBounds] = useState(null); const [zoom, setZoom] = useState(5);
const superclusterInstance = useMemo(() => { return new Supercluster({ radius: 75, maxZoom: 20 }).load( pointFeatures.map((f) => ({ type: 'Feature', properties: { ...f.properties }, geometry: { type: 'Point', coordinates: f.geometry.coordinates }, })) ); }, [pointFeatures]);
const MapEvents = () => { useMapEvent('moveend', (e) => { const m = e.target; const b = m.getBounds(); setBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]); setZoom(m.getZoom()); }); return null; };
useEffect(() => { if (bounds) { const c = superclusterInstance.getClusters(bounds, zoom); setClusters(c); } }, [superclusterInstance, bounds, zoom]);
useEffect(() => {
if (!mapRef.current) return;
layerRegistry.current.forEach((cleanup, layer) => {
cleanup();
layer.remove();
});
layerRegistry.current.clear();
if (!otherFeatures.length) return;
const layerGroup = L.geoJSON(otherFeatures, {
style: { color: '#3388ff', weight: 2 },
onEachFeature: (feature, layer) => {
const container = L.DomUtil.create('div');
const root = ReactDOM.createRoot(container);
root.render(
<MarkerPopupContent
key={${feature.id || Date.now()}-${Math.random()}
}
properties={feature.properties || {}}
/>
);
const popup = L.popup({
autoClose: true,
closeOnClick: true,
className: 'isolated-popup',
}).setContent(container);
layer.bindPopup(popup);
const cleanup = () => {
root.unmount();
container.remove();
layer.unbindPopup();
};
layerRegistry.current.set(layer, cleanup);
},
}).addTo(mapRef.current);
return () => {
if (mapRef.current && mapRef.current.hasLayer(layerGroup)) {
mapRef.current.removeLayer(layerGroup);
}
};
}, [otherFeatures]);
const renderMarkers = () => {
return clusters.map((c) => {
const [lng, lat] = c.geometry.coordinates;
const { cluster: isCluster, point_count } = c.properties;
if (isCluster) {
let leaves = [];
try {
leaves = superclusterInstance.getLeaves(c.properties.cluster_id, 1000, 0);
} catch (err) {
console.error('Error calling getLeaves:', err);
}
return (
<Marker
key={cluster-${c.properties.cluster_id}
}
position={[lat, lng]}
icon={createClusterIcon(point_count)}
>
<Popup>
<ClusterPopupContent
leaves={leaves}
count={point_count}
maxMarkers={10}
maxHeight={300}
/>
</Popup>
</Marker>
);
} else {
return (
<Marker
key={marker-${lng}-${lat}-${c.properties.name || 'unknown'}
}
position={[lat, lng]}
>
<Popup>
<MarkerPopupContent
properties={c.properties}
coords={[lat, lng]}
hideCoordinate={false}
/>
</Popup>
</Marker>
);
}
});
};
const filteredGeoJSON = useMemo( () => ({ type: 'FeatureCollection', features: finalFilteredFeatures, }), [finalFilteredFeatures] );
const noData = features.length === 0;
useEffect(() => { return () => { layerRegistry.current.forEach((cleanup) => cleanup()); layerRegistry.current.clear(); }; }, []);
const applyGeometryAndPropertyFilters = (baseArr, geomFilter, propFilter) => { if (!geomFilter || geomFilter.length === 0) return []; return baseArr.filter((f) => { if (selectedTags.length > 0) { const tags = f.properties?.tags; if (tags) { const arr = Array.isArray(tags) ? tags : [tags]; if (!arr.some((tg) => selectedTags.includes(tg))) { return false; } } } if (!geomFilter.includes(f.geometry?.type)) { return false; } for (const [k, vals] of Object.entries(propFilter)) { if (!vals || vals.length === 0) continue; const propVal = f._flatProps[k]; if (Array.isArray(propVal)) { if (!propVal.some((x) => vals.includes(x))) { return false; } } else { if (!vals.includes(propVal)) { return false; } } } return true; }); };
const isGeomTypeDisabled = (type) => { const newGeomFilter = geometryFilter.includes(type) ? geometryFilter : [...geometryFilter, type]; const testArr = applyGeometryAndPropertyFilters(flattenedFeatures, newGeomFilter, propertyFilters); return testArr.length === 0; };
const isPropValueDisabled = (key, val) => { const newPF = { ...propertyFilters }; const oldVals = newPF[key] || []; if (!oldVals.includes(val)) { newPF[key] = [...oldVals, val]; } const testArr = applyGeometryAndPropertyFilters(flattenedFeatures, geometryFilter, newPF); return testArr.length === 0; };
const renderFilterChips = () => {
return Object.entries(propertyFilters).map(([k, vals]) => {
const joined = vals.join(',');
const display = joined.length > 20 ? joined.slice(0, 20) + '...' : joined;
return (
<Chip
key={k}
label={${k}: ${display}
}
variant="outlined"
size="small"
onDelete={() => {
setPropertyFilters((old) => {
const copy = { ...old };
delete copy[k];
return copy;
});
if (activeKey === k) {
setActiveKey('');
setActiveValues([]);
}
}}
sx={{ mr: 1, mb: 1 }}
/>
);
});
};
return ( <div style={{ width: '100%', height: '100%', position: 'relative' }}> {noData ? ( <div style={{ width: '100%', height: '100%', background: '#fafafa', display: 'flex', alignItems: 'center', justifyContent: 'center', }} > <Typography style={{ textAlign: 'center', fontFamily: 'Arial' }}> No valid GeoJSON data </Typography> </div> ) : ( <MapContainer center={[39.9, 116.4]} zoom={5} style={{ width: '100%', height: '100%' }} zoomControl={false} ref={(m) => { mapRef.current = m; }} > <ZoomControl position="bottomright" /> <TileLayer attribution="© OpenStreetMap contributors" crossOrigin="anonymous" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> <MapEvents /> {clusters.length > 0 && renderMarkers()} <FitBounds geoJSONData={filteredGeoJSON} maxZoom={15} /> </MapContainer> )}
{!noData && (
<Box
sx={{
position: 'absolute',
top: 10,
left: 10,
width: 230,
maxHeight: 480,
overflow: 'auto',
backgroundColor: 'rgba(255,255,255,0.9)',
p: 2,
borderRadius: 2,
zIndex: 999,
}}
>
<Box sx={{ mb: 2, display: 'flex', flexWrap: 'wrap' }}>
{renderFilterChips()}
</Box>
<FormControl component="fieldset" variant="standard" sx={{ mb: 2 }}>
<FormLabel component="legend" sx={{ fontSize: '0.9rem' }}>
Geometry Types
</FormLabel>
<FormGroup>
{allGeomTypes.map((type) => {
const disabled = isGeomTypeDisabled(type);
return (
<FormControlLabel
key={type}
control={
<Checkbox
checked={geometryFilter.includes(type)}
onChange={() => handleToggleGeomType(type)}
disabled={disabled}
/>
}
label={type}
/>
);
})}
</FormGroup>
</FormControl>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel id="select-property-key-label">Property Key</InputLabel>
<Select
labelId="select-property-key-label"
label="Property Key"
value={activeKey}
onChange={handleSelectKey}
MenuProps={{
PaperProps: { style: { zIndex: 10000 } },
disablePortal: false,
}}
>
<MenuItem value="">(none)</MenuItem>
{Object.keys(allPropertyValuesMap).map((k) => (
<MenuItem key={k} value={k}>
{k}
</MenuItem>
))}
</Select>
</FormControl>
{activeKey && allPropertyValuesMap[activeKey] && (() => {
const allVals = allPropertyValuesMap[activeKey];
const allDisabled = allVals.every((val) => isPropValueDisabled(activeKey, val));
return (
<FormControl component="fieldset" variant="standard">
<FormLabel component="legend" sx={{ fontSize: '0.9rem' }}>
Values for [{activeKey}]
</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Checkbox
checked={
activeValues.length > 0 &&
activeValues.length === allVals.length
}
indeterminate={
activeValues.length > 0 &&
activeValues.length < allVals.length
}
onChange={(e) => handleSelectAllValues(e.target.checked)}
disabled={allDisabled}
/>
}
label="(Select All)"
/>
{allVals.map((val) => {
const disabled = isPropValueDisabled(activeKey, val);
return (
<FormControlLabel
key={String(val)}
control={
<Checkbox
checked={activeValues.includes(val)}
onChange={() => handleToggleValue(val)}
disabled={disabled}
/>
}
label={String(val)}
/>
);
})}
</FormGroup>
</FormControl>
);
})()}
</Box>
)}
{sortedTimePoints.length > 0 && (
<Box
sx={{
position: 'absolute',
top: 10,
right: 10,
width: 220,
backgroundColor: 'rgba(255,255,255,0.9)',
p: 2,
borderRadius: 2,
zIndex: 999,
}}
>
<Typography variant="h6" gutterBottom>
Time-Series
</Typography>
<Button variant="contained" onClick={handlePlayPause} sx={{ mb: 1 }}>
{isPlaying ? 'Pause' : 'Play'}
</Button>
<Typography variant="body2" sx={{ mb: 1 }}>
Current: {new Date(currentTime).toLocaleString()}
</Typography>
<Slider
value={currentTime}
onChange={(e, val) => {
setCurrentTime(val);
setIsPlaying(false);
}}
min={sortedTimePoints[0]}
max={sortedTimePoints[sortedTimePoints.length - 1]}
step={1000}
valueLabelDisplay="auto"
marks={sortedTimePoints.map((t) => ({
value: t,
label: '',
}))}
sx={{
'& .MuiSlider-mark': {
backgroundColor: 'white',
height: 6,
width: 6,
borderRadius: '50%',
},
'& .MuiSlider-markLabel': {
display: 'none',
},
}}
/>
</Box>
)}
</div>
); }
export default TimeSeriesMapViewer; 怎么提升一下大文件render的performance
Enhancing performance
I’m working through improving React Leaflet performance by reducing re-renders and using react memo. I'm also considering smarter ways to handle large datasets and refine supercluster creation.
Optimizing rendering
I'm exploring various strategies, like defining a data bounding box and using web workers, to tackle large file and polygon rendering in a React Leaflet map.
Assessing performance
I'm gathering info on optimizing rendering by simplifying polygons, using tile-based methods, minimizing re-renders, and exploring sorting features by time for more efficient handling.
Reasoned about optimizing map rendering performance for 17 seconds
Improving performance for large GeoJSON files usually involves reducing unnecessary renders and costly computations. Below are several strategies you can combine:
React.memo
or useMemo
more aggressivelyuseMemo
for computing derived data (e.g. flattenedFeatures
, sortedTimePoints
, superclusterInstance
, etc.), which is good. However, if your component (or child components) re-renders frequently, you should ensure that your memoization dependencies are as narrow as possible.React.memo
.useCallback
for callbacks that do not need to change often.Points are relatively cheap to cluster, but polygons and lines can be very large. If you have non-point geometries:
Vector Tile Approaches
Simplification
simplify
) before rendering.Canvas Rendering
L.canvas()
) to handle large geometry layers more efficiently.setState
. Each call can trigger re-renders:
setClusters
every moveend
. Possibly wrap that logic in a useCallback
with a small throttle.Supercluster
instance.
useMemo(() => new Supercluster(...)
. If pointFeatures
changes a lot due to time filtering, that’s unavoidable. But if only part of the dataset changes, you might re-initialize unnecessarily.Supercluster
creation and property/time filtering off the main thread if your data is truly large.currentTime
with a binary search.These strategies, in combination, typically yield substantial performance improvements for large maps.
In short, focus on reducing repeated computations (via memoization, indexing, or throttling), avoid rendering everything at once (lazy load or tile-based approach), and keep heavy-lifting (clustering, big geometry) off the main thread or in specialized data structures.
说中文
Reasoned about提升大文件渲染性能 for a second
如果你的 GeoJSON 数据量很大,需要提高地图渲染性能,通常可以从以下几个方面入手:
useMemo
/ React.memo
useMemo
中,并正确设置依赖项,确保只有在必要时才会重新计算。React.memo
包裹,减少父组件更新时子组件的无谓更新。useCallback
)useCallback
包裹事件处理函数,确保在依赖不变时,回调不会变成新引用。moveend
事件中,可以先用一个短的延迟(比如 200ms)再触发 setBounds
和聚类计算。如果用户还在不断拖动,就合并成一次最终更新。{属性值 => 对应要素列表}
的映射关系,这样当你只过滤某个属性值时,能快速获取子集,而不是全量再扫一遍。filter
逐个判断。Supercluster
这样的聚类操作放到 Web Worker 中,主线程只负责把 bounds/zoom/filters 之类的信息发给 Worker,Worker 计算完成后再把结果(clusters)回传给主线程。如果数据不仅包含点,还有大量的多边形或线段,SVG 渲染在 Leaflet 中可能比较吃力。
simplify
或其它几何简化库,对数据先做适度简化后再加载到地图。L.canvas())
或者第三方的 react-leaflet-canvas-layer 来渲染几何。减少多次 setState
尽可能不要重复创建 Supercluster
实例
useMemo
中实例化 new Supercluster(...)
时,要确保依赖数组不要轻易变动,否则依赖一变就会重新构建聚类索引。缩减属性数据
通过以上手段,可以大幅减轻前端一次性处理、渲染超大文件的压力,让地图在用户交互时更加流畅。
使用矢量瓦片试一下
Creating vector tiles
I’m setting up instructions for using vector tiles in Leaflet. This includes leveraging geojson-vt for server-side tile creation and a snippet with react-leaflet for client-side use.
Reasoned about vector tile approach for 13 seconds
在前端处理大体量的多边形/线要素时,直接加载整个大文件的 GeoJSON 并用 L.geoJSON
或 SVG 渲染往往会比较吃力。**矢量瓦片(Vector Tiles)**可以将数据分块切片,在用户只浏览一小块地图时就只请求对应瓦片,极大提升性能。下面给出一个常见的使用思路和示例,帮助你在 React + Leaflet 项目中接入矢量瓦片。
GeoJSON → 矢量瓦片
.pbf
瓦片文件。https://yourserver/tiles/{z}/{x}/{y}.pbf
。前端(React + Leaflet)加载矢量瓦片
根据需要叠加底图
选择合适的样式和交互
下面给出一个最简单的示例,假设:
https://myserver.com/tiles/{z}/{x}/{y}.pbf
的矢量瓦片服务。npm install leaflet.vectorgrid react-leaflet-vectorgrid
注意:
leaflet.vectorgrid
实际上会在react-leaflet-vectorgrid
中作为依赖,你只需要确保它能被正确引入即可。
// VectorTileLayer.jsx import React from 'react'; import { VectorGrid } from 'react-leaflet-vectorgrid'; function VectorTileLayer() { // 这里定义你想要的渲染样式 const vectorTileStyling = { // 这里的"layerName"要对应你在生成瓦片时的图层名称 layerName: { fill: true, weight: 2, fillColor: '#3887be', color: '#3887be', fillOpacity: 0.5, opacity: 1, }, }; return ( <VectorGrid.Protobuf url="https://myserver.com/tiles/{z}/{x}/{y}.pbf" vectorTileLayerStyles={vectorTileStyling} maxNativeZoom={14} maxZoom={20} subdomains={['a','b','c']} // 你可以根据需求开启/关闭 interactive interactive={true} // 监听点击事件 onClick={(e) => { console.log('Clicked vector tile feature:', e.layer.properties); }} /> ); } export default VectorTileLayer;
url
:指向你服务器上矢量瓦片的路径,使用 {z}/{x}/{y}
这种占位符。vectorTileLayerStyles
:定义不同图层名对应的样式,也可以写成一个函数根据属性值动态决定样式。maxNativeZoom
:表示你实际切片的最大层级,比如你只切到了14级,就写14。maxZoom
可以允许继续放大但只是地图放大,并不会请求更细的瓦片。interactive
:如果需要点击高亮、Tooltip 等交互,可以开启。假设你在 TimeSeriesMapViewer.jsx
或你自己的地图组件中,把这个矢量瓦片图层加进去即可:
import React from 'react'; import { MapContainer, TileLayer } from 'react-leaflet'; import VectorTileLayer from './VectorTileLayer'; function MyMap() { return ( <MapContainer center={[39.9, 116.4]} zoom={5} style={{ width: '100%', height: '100%' }} > {/* 通常先放一个普通的栅格底图 */} <TileLayer attribution="© OpenStreetMap contributors" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> {/* 然后叠加矢量瓦片图层 */} <VectorTileLayer /> </MapContainer> ); } export default MyMap;
这样就可以从你的服务器加载矢量瓦片并进行渲染了。
如果你想自己在 Node.js 环境快速弄一下 .pbf
瓦片,可以做如下示例(伪代码):
// server.js const express = require('express'); const geojsonvt = require('geojson-vt'); const vtpbf = require('vt-pbf'); const fs = require('fs'); const path = require('path'); const app = express(); // 假设 largeData.json 是一个超大的 GeoJSON const geojsonData = JSON.parse(fs.readFileSync(path.join(__dirname, 'largeData.json'), 'utf8')); // 用 geojson-vt 预处理: const tileIndex = geojsonvt(geojsonData, { maxZoom: 14, // 生成的瓦片最大层级 tolerance: 3, extent: 4096, debug: 0, }); // 提供一个路由来返回 {z}/{x}/{y} 瓦片 app.get('/tiles/:z/:x/:y.pbf', (req, res) => { const { z, x, y } = req.params; const tile = tileIndex.getTile(+z, +x, +y); if (!tile) { // 没有数据则返回空瓦片 return res.status(204).send(); } // geojson-vt 返回的是一个瓦片结构,用 vt-pbf 转成 pbf buffer const pbfData = vtpbf.fromGeojsonVt({ 'layerName': tile }); res.setHeader('Content-Type', 'application/x-protobuf'); res.send(Buffer.from(pbfData)); }); app.listen(8080, () => { console.log('Server running on http://localhost:8080'); });
http://localhost:8080/tiles/6/53/26.pbf
之类的 URL,服务器就会动态生成对应瓦片并返回。.pbf
文件全部生成,放到静态服务器上。.pbf
瓦片。这样就能大幅减少一次性加载并渲染整个大文件的压力,让你的大体量数据在浏览器中也能相对流畅地查看和交互。
我现在所有js文件都平铺在src里,是不是不太好
思考文件结构
我正在探讨一个大项目的 React 文件结构。即使是小项目也会建议将文件分不同文件夹中,避免所有 .js 文件混在一起。
Reasoned about file organization in React projects for 8 seconds
在 React 项目里把所有 .js
文件都扔在 src
根目录下虽然能跑,但随着项目越来越大、文件越来越多,会导致可维护性和可读性变差。一般来说,更推荐按照“功能”或“职责”对代码进行分层或分组。以下是一些常见的结构组织思路,供参考:
最常见的一种做法是按照文件用途进行分类,比如:
src/
┣ components/ // 放各种可复用的UI组件
┣ pages/ // 放页面级别的组件(路由对应的页面)
┣ hooks/ // 自定义Hook
┣ context/ // React Context 相关
┣ services/ // 数据请求、API封装等
┣ utils/ // 工具函数、通用方法
┣ styles/ // 全局样式或主题
┗ index.js
useFetch
、useMap
、useDebounce
等),可以统一放在一个目录。api.js
、authService.js
之类。这样分类的好处是高内聚,文件职责清晰,查看和维护都比较方便。
如果你的项目规模更大、业务逻辑更丰富,也可以把各个业务域拆分到各自文件夹。比如:
src/
┣ features/
│ ┣ Map/
│ │ ┣ components/
│ │ ┣ hooks/
│ │ ┣ services/
│ │ ┗ index.js
│ ┣ Auth/
│ │ ┣ components/
│ │ ┣ services/
│ │ ┗ ...
│ ┗ Dashboard/
│ ┣ components/
│ ┣ services/
│ ┗ ...
┣ shared/ // 存放跨业务模块的通用组件、hooks、utils
┗ index.js
这种做法更倾向“按业务模块”划分,适合大型或复杂应用,找文件时也能第一眼就能定位到具体功能的文件夹。
有时候会在 components/
目录下再进行细分,比如按照Atomic Design或组件类型进行分层:
components/
┣ atoms/ // 最小UI单元,如 Button、Input 等
┣ molecules/ // 由 atoms 组合而成的稍大型组件
┣ organisms/ // 由多个 molecules/atoms 组合,形成比较完整的UI模块
┣ templates/ // 页面模板结构(可选)
┗ ...
不过这种是更偏 UI/设计驱动的思路,并不一定必须。
xxx.test.js
),或单独放在 __tests__
目录下。src/routes.js
或者在 pages/
里写 AppRouter.jsx
。config/
或 constants/
放全局常量或环境配置。所以,“所有 .js
文件都平铺在 src/
”确实不太好管理。你可以根据以上的思路,把它们按照功能或业务拆分一下,这样后续维护和扩展都会更轻松。