一、完整项目架构图
┌─────────────────────────────────────────────────────────────────────────────┐
│ React + Cesium 生产级架构 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ App.tsx (根组件) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │ │
│ │ │ Header │ │ Sidebar │ │ CesiumEarth │ │ │
│ │ │ 组件 │ │ 组件 │ │ 组件 │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┬───────────────┘ │ │
│ └──────────────────────────────────────────────────┼─────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────┼─────────────────┐ │
│ │ Redux Toolkit │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │ mapSlice │ │layerSlice │ │toolsSlice │ │ │ │
│ │ │ 地图状态 │ │ 图层状态 │ │ 工具状态 │ │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │ │
│ └──────────────────────────────────────────────────┼─────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────┼─────────────────┐ │
│ │ Custom Hooks │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │useCesium │ │useMapTools │ │useLayer │ │ │ │
│ │ │核心实例管理│ │ 地图工具 │ │ 图层管理 │ │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │ │
│ └──────────────────────────────────────────────────┼─────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────┼─────────────────┐ │
│ │ Utils │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │coordTransform│cesiumInit │ │performance │ │ │ │
│ │ │ 坐标转换 │ │ 初始化 │ │ 性能监控 │ │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │ │
│ └──────────────────────────────────────────────────┼─────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Cesium Viewer │ │
│ │ 单例实例 │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
二、项目初始化与配置
2.1 创建项目
# 使用 Vite 创建 React + TypeScript 项目
pnpm create vite react-cesium-pro --template react-ts
cd react-cesium-pro
# 安装核心依赖
pnpm add cesium resium @reduxjs/toolkit react-redux react-router-dom
# 安装开发依赖
pnpm add -D vite-plugin-cesium @types/node sass @types/cesium
# 安装 GIS 工具库
pnpm add turf @turf/turf
# 安装 UI 组件库(可选)
pnpm add antd @ant-design/icons
2.2 完整 Vite 配置
javascript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import cesium from 'vite-plugin-cesium'
import path from 'path'
export default defineConfig({
plugins: [react(), cesium()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'#': path.resolve(__dirname, './src/types'),
cesium: 'cesium'
}
},
define: {
CESIUM_BASE_URL: JSON.stringify('/cesium'),
__APP_VERSION__: JSON.stringify(process.env.npm_package_version)
},
server: {
port: 5173,
open: true,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/tianditu': {
target: 'https://t0.tianditu.gov.cn',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/tianditu/, '')
}
}
},
build: {
target: 'esnext',
sourcemap: process.env.NODE_ENV === 'development',
rollupOptions: {
output: {
manualChunks: {
cesium: ['cesium', 'resium'],
turf: ['@turf/turf'],
vendor: ['react', 'react-dom', 'react-redux', '@reduxjs/toolkit']
}
}
},
chunkSizeWarningLimit: 2000
},
optimizeDeps: {
include: ['cesium', 'resium', '@turf/turf']
}
})
2.3 环境变量配置
javascript
# .env.development
VITE_APP_TITLE=React Cesium 开发环境
VITE_CESIUM_TOKEN=your_cesium_ion_token
VITE_TIANDITU_TOKEN=your_tianditu_token
VITE_API_BASE_URL=http://localhost:3000/api
# .env.production
VITE_APP_TITLE=React Cesium 生产环境
VITE_CESIUM_TOKEN=your_production_token
VITE_TIANDITU_TOKEN=your_production_tianditu_token
VITE_API_BASE_URL=https://api.example.com/api
2.4 TypeScript 配置
javascript
// tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"#/*": ["src/types/*"]
},
"types": ["vite/client", "cesium"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
三、目录结构详解
javascript
src/
├── api/ # API 接口层
│ ├── modules/
│ │ ├── map.ts # 地图服务接口
│ │ └── data.ts # 数据服务接口
│ ├── request.ts # axios 封装
│ └── index.ts
│
├── assets/ # 静态资源
│ ├── icons/
│ ├── images/
│ └── styles/
│ ├── variables.scss # SCSS 变量
│ ├── mixins.scss # SCSS 混入
│ └── global.scss # 全局样式
│
├── components/ # 公共组件
│ ├── common/
│ │ ├── Loading.tsx
│ │ ├── ErrorBoundary.tsx
│ │ └── Modal.tsx
│ ├── cesium/
│ │ ├── CesiumEarth.tsx # 主地球组件
│ │ ├── LayerManager.tsx # 图层管理
│ │ ├── MapTools.tsx # 地图工具条
│ │ ├── CoordinatesBar.tsx # 坐标栏
│ │ └── ViewerProvider.tsx # Viewer 上下文提供者
│ └── business/
│ ├── SearchPanel.tsx # 搜索面板
│ └── MeasurePanel.tsx # 测量面板
│
├── hooks/ # 自定义 Hooks
│ ├── core/
│ │ ├── useCesium.ts # Cesium 核心
│ │ ├── useViewer.ts # Viewer 管理
│ │ └── useScene.ts # 场景管理
│ ├── tools/
│ │ ├── useMeasure.ts # 测量工具
│ │ ├── useScreenshot.ts # 截图工具
│ │ └── useDraw.ts # 绘图工具
│ └── data/
│ ├── useGeoJson.ts # GeoJSON 处理
│ └── use3DTiles.ts # 3D Tiles 处理
│
├── config/ # 配置文件
│ ├── cesium.config.ts # Cesium 配置
│ ├── layers.config.ts # 图层配置
│ └── map.config.ts # 地图配置
│
├── layouts/ # 布局组件
│ ├── DefaultLayout.tsx
│ └── MapLayout.tsx
│
├── pages/ # 页面组件
│ ├── Home/
│ │ ├── HomePage.tsx
│ │ └── HomePage.module.scss
│ └── About/
│ └── AboutPage.tsx
│
├── router/ # 路由配置
│ └── index.tsx
│
├── store/ # Redux Toolkit 状态管理
│ ├── slices/
│ │ ├── mapSlice.ts # 地图状态
│ │ ├── layerSlice.ts # 图层状态
│ │ ├── toolsSlice.ts # 工具状态
│ │ └── userSlice.ts # 用户状态
│ ├── hooks.ts # 类型化的 hooks
│ └── index.ts # Store 配置
│
├── types/ # TypeScript 类型定义
│ ├── cesium.d.ts # Cesium 类型扩展
│ ├── global.d.ts # 全局类型
│ └── api.d.ts # API 类型
│
├── utils/ # 工具函数
│ ├── cesium/
│ │ ├── coordTransform.ts # 坐标转换
│ │ ├── loaders.ts # 数据加载器
│ │ └── helpers.ts # 辅助函数
│ ├── format.ts # 格式化工具
│ ├── validate.ts # 验证工具
│ └── performance.ts # 性能监控
│
├── App.tsx
├── main.tsx
└── vite-env.d.ts
四、核心代码实现
4.1 Cesium 配置 config/cesium.config.ts
javascript
import * as Cesium from 'cesium'
import 'cesium/Build/Cesium/Widgets/widgets.css'
export interface CesiumConfig {
defaultAccessToken: string
baseUrl: string
viewerOptions: Cesium.Viewer.ConstructorOptions
}
// 默认 Viewer 配置
export const defaultViewerOptions: Cesium.Viewer.ConstructorOptions = {
// 基础底图
imageryProvider: false,
// 地形
terrainProvider: new Cesium.EllipsoidTerrainProvider(),
// 控件配置
animation: false,
baseLayerPicker: false,
fullscreenButton: false,
vrButton: false,
geocoder: false,
homeButton: false,
infoBox: true,
sceneModePicker: true,
selectionIndicator: true,
timeline: false,
navigationHelpButton: false,
navigationInstructionsInitiallyVisible: false,
// 场景配置
scene3DOnly: true,
sceneMode: Cesium.SceneMode.SCENE3D,
// 性能优化
requestRenderMode: true,
maximumRenderTimeChange: 0.1,
// 视觉效果(按需开启)
shadows: false,
fog: false,
atmosphere: false,
skyBox: false,
skyAtmosphere: false,
// 地形夸张
terrainExaggeration: 1.0,
// 目标帧率
targetFrameRate: 60,
// 分辨率
resolutionScale: 1.0,
// 自动控制相机
automaticallyTrackDataSourceClocks: false,
// 是否显示帧率
showRenderLoopErrors: true,
// 是否使用浏览器分辨率
useBrowserRecommendedResolution: true
}
// 默认影像图层配置
export const defaultImageryProvider = new Cesium.UrlTemplateImageryProvider({
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
subdomains: ['a', 'b', 'c'],
minimumLevel: 1,
maximumLevel: 19,
credit: '© OpenStreetMap contributors'
})
// 天地图影像配置
export const tiandituImageryProvider = (token: string) => new Cesium.UrlTemplateImageryProvider({
url: `https://t0.tianditu.gov.cn/DataServer?T=img_w&x={x}&y={y}&l={z}&tk=${token}`,
subdomains: ['t0', 't1', 't2', 't3', 't4', 't5', 't6', 't7'],
minimumLevel: 1,
maximumLevel: 18,
credit: '天地图'
})
// 初始化 Cesium 全局配置
export function initCesiumConfig() {
const token = import.meta.env.VITE_CESIUM_TOKEN
if (token) {
Cesium.Ion.defaultAccessToken = token
}
window.CESIUM_BASE_URL = '/cesium'
Cesium.Resource.defaultImage.crossOrigin = 'Anonymous'
Cesium.Resource.defaultImage.overrideMimeType = 'image/png'
if (import.meta.env.PROD) {
Cesium.buildModuleUrl.setBaseUrl('/cesium/')
}
}
4.2 Viewer Context contexts/ViewerContext.tsx
javascript
import React, { createContext, useContext, useRef, useState, ReactNode } from 'react'
import * as Cesium from 'cesium'
interface ViewerContextType {
viewer: Cesium.Viewer | null
isReady: boolean
setViewer: (viewer: Cesium.Viewer) => void
setIsReady: (ready: boolean) => void
}
const ViewerContext = createContext<ViewerContextType | null>(null)
export const useViewer = () => {
const context = useContext(ViewerContext)
if (!context) {
throw new Error('useViewer must be used within ViewerProvider')
}
return context
}
interface ViewerProviderProps {
children: ReactNode
}
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const viewerRef = useRef<Cesium.Viewer | null>(null)
const [isReady, setIsReady] = useState(false)
const setViewer = (viewer: Cesium.Viewer) => {
viewerRef.current = viewer
setIsReady(true)
}
return (
<ViewerContext.Provider
value={{
viewer: viewerRef.current,
isReady,
setViewer,
setIsReady
}}
>
{children}
</ViewerContext.Provider>
)
}
4.3 核心 Hook hooks/core/useCesium.ts
javascript
import { useEffect, useRef, useState, useCallback } from 'react'
import * as Cesium from 'cesium'
import { defaultViewerOptions, initCesiumConfig } from '@/config/cesium.config'
interface UseCesiumOptions {
containerId?: string
viewerOptions?: Cesium.Viewer.ConstructorOptions
autoInit?: boolean
}
export function useCesium(options: UseCesiumOptions = {}) {
const {
containerId = 'cesium-container',
viewerOptions = {},
autoInit = true
} = options
const viewerRef = useRef<Cesium.Viewer | null>(null)
const [isLoaded, setIsLoaded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
// 合并配置
const mergedOptions = { ...defaultViewerOptions, ...viewerOptions }
// 初始化 Viewer
const initViewer = useCallback(async (): Promise<Cesium.Viewer> => {
if (viewerRef.current) {
console.warn('Viewer already initialized')
return viewerRef.current
}
setIsLoading(true)
setError(null)
try {
initCesiumConfig()
const container = document.getElementById(containerId)
if (!container) {
throw new Error(`Container element #${containerId} not found`)
}
viewerRef.current = new Cesium.Viewer(containerId, mergedOptions)
// 添加默认影像图层
if (!mergedOptions.imageryProvider) {
const imageryProvider = new Cesium.UrlTemplateImageryProvider({
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
subdomains: ['a', 'b', 'c']
})
viewerRef.current.imageryLayers.addImageryProvider(imageryProvider)
}
// 监听加载完成
const checkLoaded = () => {
if (viewerRef.current?.scene.globe.tilesLoaded) {
setIsLoaded(true)
setIsLoading(false)
viewerRef.current?.scene.globe.tileLoadProgressEvent.removeEventListener(checkLoaded)
}
}
viewerRef.current.scene.globe.tileLoadProgressEvent.addEventListener(checkLoaded)
// 设置默认视角
setDefaultView()
console.log('✅ Cesium Viewer initialized successfully')
return viewerRef.current
} catch (err) {
const error = err as Error
setError(error)
console.error('Failed to initialize Cesium Viewer:', error)
throw error
} finally {
setIsLoading(false)
}
}, [containerId, mergedOptions])
// 设置默认视角
const setDefaultView = useCallback(() => {
if (!viewerRef.current) return
viewerRef.current.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(116.397428, 39.90923, 5000),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-30),
roll: 0
}
})
}, [])
// 销毁 Viewer
const destroyViewer = useCallback(() => {
if (viewerRef.current) {
viewerRef.current.scene.primitives.removeAll()
viewerRef.current.entities.removeAll()
viewerRef.current.dataSources.removeAll()
viewerRef.current.destroy()
viewerRef.current = null
setIsLoaded(false)
console.log('🗑️ Cesium Viewer destroyed')
}
}, [])
// 重置视图
const resetView = useCallback(() => {
if (!viewerRef.current) return
setDefaultView()
}, [setDefaultView])
// 调整大小
const resize = useCallback(() => {
if (viewerRef.current) {
viewerRef.current.resize()
}
}, [])
// 获取场景
const getScene = useCallback(() => viewerRef.current?.scene, [])
// 获取相机
const getCamera = useCallback(() => viewerRef.current?.camera, [])
// 自动初始化
useEffect(() => {
if (autoInit) {
initViewer().catch(console.error)
}
window.addEventListener('resize', resize)
return () => {
window.removeEventListener('resize', resize)
destroyViewer()
}
}, [autoInit, initViewer, resize, destroyViewer])
return {
viewer: viewerRef.current,
isLoaded,
isLoading,
error,
initViewer,
destroyViewer,
resetView,
resize,
getScene,
getCamera
}
}
4.4 Redux Store store/slices/mapSlice.ts
javascript
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import * as Cesium from 'cesium'
interface MapState {
center: {
lng: number
lat: number
height: number
}
zoom: number
pitch: number
heading: number
is3DMode: boolean
showGrid: boolean
showCompass: boolean
selectedEntityId: string | null
}
const initialState: MapState = {
center: {
lng: 116.397428,
lat: 39.90923,
height: 5000
},
zoom: 12,
pitch: -30,
heading: 0,
is3DMode: true,
showGrid: false,
showCompass: true,
selectedEntityId: null
}
export const mapSlice = createSlice({
name: 'map',
initialState,
reducers: {
setCenter: (state, action: PayloadAction<{ lng: number; lat: number; height?: number }>) => {
state.center = {
lng: action.payload.lng,
lat: action.payload.lat,
height: action.payload.height ?? state.center.height
}
},
setZoom: (state, action: PayloadAction<number>) => {
state.zoom = action.payload
},
setPitch: (state, action: PayloadAction<number>) => {
state.pitch = action.payload
},
setHeading: (state, action: PayloadAction<number>) => {
state.heading = action.payload
},
toggle3DMode: (state) => {
state.is3DMode = !state.is3DMode
},
toggleGrid: (state) => {
state.showGrid = !state.showGrid
},
selectEntity: (state, action: PayloadAction<string | null>) => {
state.selectedEntityId = action.payload
},
resetMap: (state) => {
return initialState
}
}
})
export const {
setCenter,
setZoom,
setPitch,
setHeading,
toggle3DMode,
toggleGrid,
selectEntity,
resetMap
} = mapSlice.actions
export default mapSlice.reducer
4.5 Store 配置 store/index.ts
javascript
import { configureStore } from '@reduxjs/toolkit'
import mapReducer from './slices/mapSlice'
import layerReducer from './slices/layerSlice'
import toolsReducer from './slices/toolsSlice'
import userReducer from './slices/userSlice'
export const store = configureStore({
reducer: {
map: mapReducer,
layers: layerReducer,
tools: toolsReducer,
user: userReducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['map/setViewer'],
ignoredPaths: ['map.viewer']
}
})
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
4.6 类型化 Hooks store/hooks.ts
javascript
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './index'
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
4.7 主地球组件 components/cesium/CesiumEarth.tsx
javascript
import React, { useEffect, useRef, useState } from 'react'
import { Viewer, Entity, CameraFlyTo } from 'resium'
import * as Cesium from 'cesium'
import { useAppDispatch, useAppSelector } from '@/store/hooks'
import { setCenter, setHeading, setPitch } from '@/store/slices/mapSlice'
import { ViewerProvider, useViewer } from '@/contexts/ViewerContext'
import CoordinatesBar from './CoordinatesBar'
import MapTools from './MapTools'
import { defaultViewerOptions, initCesiumConfig } from '@/config/cesium.config'
import styles from './CesiumEarth.module.scss'
interface CesiumEarthProps {
containerId?: string
showCoordinates?: boolean
showTools?: boolean
autoResetView?: boolean
}
const CesiumEarthInner: React.FC<CesiumEarthProps> = ({
containerId = 'cesium-container',
showCoordinates = true,
showTools = true,
autoResetView = true
}) => {
const { setViewer, isReady } = useViewer()
const dispatch = useAppDispatch()
const { center, heading, pitch } = useAppSelector(state => state.map)
const [loadProgress, setLoadProgress] = useState(0)
const [error, setError] = useState<Error | null>(null)
// Viewer 就绪回调
const handleViewerReady = (viewer: Cesium.Viewer) => {
initCesiumConfig()
setViewer(viewer)
// 监听相机变化
viewer.camera.changed.addEventListener(() => {
const position = viewer.camera.positionCartographic
dispatch(setCenter({
lng: Cesium.Math.toDegrees(position.longitude),
lat: Cesium.Math.toDegrees(position.latitude),
height: position.height
}))
dispatch(setHeading(Cesium.Math.toDegrees(viewer.camera.heading)))
dispatch(setPitch(Cesium.Math.toDegrees(viewer.camera.pitch)))
})
// 监听瓦片加载进度
viewer.scene.globe.tileLoadProgressEvent.addEventListener((tilesLeft: number) => {
const total = viewer.scene.globe._surface._tileLoadQueueHigh?.length || 0
if (total > 0) {
setLoadProgress(Math.round(((total - tilesLeft) / total) * 100))
}
})
console.log('✅ Resium Viewer ready')
}
// 处理错误
const handleError = (err: Error) => {
setError(err)
console.error('Viewer error:', err)
}
// 重置视角
useEffect(() => {
if (autoResetView && isReady) {
// 视角重置逻辑
}
}, [autoResetView, isReady])
return (
<div className={styles.earthContainer}>
{/* 加载状态 */}
{loadProgress > 0 && loadProgress < 100 && (
<div className={styles.loadingOverlay}>
<div className={styles.loadingContent}>
<div className={styles.loadingSpinner} />
<p>三维地球加载中... {loadProgress}%</p>
</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className={styles.errorToast}>
<span>⚠️</span>
<span>{error.message}</span>
<button onClick={() => setError(null)}>×</button>
</div>
)}
{/* Cesium Viewer */}
<Viewer
{...defaultViewerOptions}
ref={(e: any) => {
if (e?.cesiumElement && !isReady) {
handleViewerReady(e.cesiumElement)
}
}}
full
onError={handleError}
/>
{/* UI 组件 */}
{showCoordinates && <CoordinatesBar />}
{showTools && <MapTools />}
</div>
)
}
export const CesiumEarth: React.FC<CesiumEarthProps> = (props) => {
return (
<ViewerProvider>
<CesiumEarthInner {...props} />
</ViewerProvider>
)
}
export default CesiumEarth
4.8 坐标栏组件 components/cesium/CoordinatesBar.tsx
javascript
import React, { useState, useEffect } from 'react'
import * as Cesium from 'cesium'
import { useViewer } from '@/contexts/ViewerContext'
import styles from './CoordinatesBar.module.scss'
const CoordinatesBar: React.FC = () => {
const { viewer, isReady } = useViewer()
const [coordinates, setCoordinates] = useState({
lng: 0,
lat: 0,
height: 0,
x: 0,
y: 0
})
useEffect(() => {
if (!isReady || !viewer) return
const handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas)
const updatePosition = (movement: any) => {
const ellipsoid = viewer.scene.globe.ellipsoid
const cartesian = viewer.camera.pickEllipsoid(movement.endPosition, ellipsoid)
if (cartesian) {
const cartographic = Cesium.Cartographic.fromCartesian(cartesian)
const webMercator = Cesium.WebMercatorProjection.geographicToCartesian(cartographic)
setCoordinates({
lng: Cesium.Math.toDegrees(cartographic.longitude),
lat: Cesium.Math.toDegrees(cartographic.latitude),
height: cartographic.height,
x: webMercator.x,
y: webMercator.y
})
}
}
handler.setInputAction(updatePosition, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
return () => {
handler.destroy()
}
}, [viewer, isReady])
if (!isReady) return null
return (
<div className={styles.coordinatesBar}>
<div className={styles.coordGroup}>
<span className={styles.label}>📍 经度</span>
<span className={styles.value}>{coordinates.lng.toFixed(6)}°</span>
</div>
<div className={styles.divider} />
<div className={styles.coordGroup}>
<span className={styles.label}>📐 纬度</span>
<span className={styles.value}>{coordinates.lat.toFixed(6)}°</span>
</div>
<div className={styles.divider} />
<div className={styles.coordGroup}>
<span className={styles.label}>📏 海拔</span>
<span className={styles.value}>{coordinates.height.toFixed(2)} m</span>
</div>
<div className={styles.divider} />
<div className={styles.coordGroup}>
<span className={styles.label}>🗺️ 投影</span>
<span className={styles.value}>
{coordinates.x.toFixed(0)}, {coordinates.y.toFixed(0)}
</span>
</div>
</div>
)
}
export default CoordinatesBar
4.9 地图工具组件 components/cesium/MapTools.tsx
javascript
import React, { useState, useCallback } from 'react'
import * as Cesium from 'cesium'
import { useViewer } from '@/contexts/ViewerContext'
import { useAppDispatch } from '@/store/hooks'
import { toggle3DMode, resetMap } from '@/store/slices/mapSlice'
import styles from './MapTools.module.scss'
const MapTools: React.FC = () => {
const { viewer, isReady } = useViewer()
const dispatch = useAppDispatch()
const [activeTool, setActiveTool] = useState<string | null>(null)
const [measureResult, setMeasureResult] = useState<number | null>(null)
const measurePointsRef = React.useRef<Cesium.Entity[]>([])
const measureLineRef = React.useRef<Cesium.Entity | null>(null)
const measureHandlerRef = React.useRef<Cesium.ScreenSpaceEventHandler | null>(null)
// 测量距离
const startMeasure = useCallback(() => {
if (!viewer) return
const points: Cesium.Cartesian3[] = []
measureHandlerRef.current = new Cesium.ScreenSpaceEventHandler(viewer.canvas)
measureHandlerRef.current.setInputAction((click: any) => {
const cartesian = viewer.camera.pickEllipsoid(
click.position,
viewer.scene.globe.ellipsoid
)
if (!cartesian) return
points.push(cartesian)
// 添加标记点
const point = viewer.entities.add({
position: cartesian,
point: {
pixelSize: 10,
color: Cesium.Color.RED,
outlineColor: Cesium.Color.WHITE,
outlineWidth: 2
}
})
measurePointsRef.current.push(point)
// 更新线段
if (points.length >= 2) {
if (measureLineRef.current) {
viewer.entities.remove(measureLineRef.current)
}
measureLineRef.current = viewer.entities.add({
polyline: {
positions: points,
width: 3,
material: Cesium.Color.fromCssColorString('#ff6b6b'),
arcType: Cesium.ArcType.GEODESIC
}
})
// 计算总距离
let totalDistance = 0
for (let i = 1; i < points.length; i++) {
totalDistance += Cesium.Cartesian3.distance(points[i - 1], points[i])
}
setMeasureResult(totalDistance)
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
// 双击结束测量
measureHandlerRef.current.setInputAction(() => {
stopMeasure()
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK)
}, [viewer])
const stopMeasure = useCallback(() => {
if (measureHandlerRef.current) {
measureHandlerRef.current.destroy()
measureHandlerRef.current = null
}
setActiveTool(null)
}, [])
const clearMeasure = useCallback(() => {
if (!viewer) return
measurePointsRef.current.forEach(point => viewer.entities.remove(point))
if (measureLineRef.current) viewer.entities.remove(measureLineRef.current)
measurePointsRef.current = []
measureLineRef.current = null
setMeasureResult(null)
stopMeasure()
}, [viewer, stopMeasure])
const toggleMeasure = useCallback(() => {
if (activeTool === 'measure') {
clearMeasure()
} else {
clearMeasure()
setActiveTool('measure')
startMeasure()
}
}, [activeTool, clearMeasure, startMeasure])
// 截图
const takeScreenshot = useCallback(() => {
if (!viewer) return
const canvas = viewer.scene.canvas
const image = canvas.toDataURL('image/png')
const link = document.createElement('a')
link.download = `cesium-screenshot-${Date.now()}.png`
link.href = image
link.click()
}, [viewer])
// 重置视角
const resetView = useCallback(() => {
if (!viewer) return
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(116.397428, 39.90923, 5000),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-30),
roll: 0
},
duration: 1.5
})
dispatch(resetMap())
}, [viewer, dispatch])
// 切换 2D/3D
const handleToggle3D = useCallback(() => {
if (!viewer) return
if (viewer.scene.mode === Cesium.SceneMode.SCENE3D) {
viewer.scene.morphTo2D()
} else {
viewer.scene.morphTo3D()
}
dispatch(toggle3DMode())
}, [viewer, dispatch])
if (!isReady) return null
return (
<div className={styles.mapTools}>
<div className={styles.toolsGroup}>
<button
className={`${styles.toolBtn} ${activeTool === 'measure' ? styles.active : ''}`}
onClick={toggleMeasure}
title="测量距离"
>
📏
</button>
<button
className={styles.toolBtn}
onClick={takeScreenshot}
title="截图"
>
📸
</button>
<button
className={styles.toolBtn}
onClick={resetView}
title="重置视角"
>
🎯
</button>
<button
className={styles.toolBtn}
onClick={handleToggle3D}
title="切换2D/3D"
>
🗺️
</button>
</div>
{measureResult !== null && (
<div className={styles.measureResult}>
<span>📐 距离: {measureResult.toFixed(2)} 米</span>
<button className={styles.closeBtn} onClick={clearMeasure}>×</button>
</div>
)}
</div>
)
}
export default MapTools
4.10 样式文件 components/cesium/CesiumEarth.module.scss
javascript
.earthContainer {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.loadingOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.loadingContent {
text-align: center;
color: #fff;
}
.loadingSpinner {
width: 48px;
height: 48px;
margin: 0 auto 20px;
border: 3px solid rgba(66, 184, 131, 0.3);
border-top-color: #42b883;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.errorToast {
position: absolute;
top: 20px;
right: 20px;
background: rgba(220, 53, 69, 0.95);
backdrop-filter: blur(10px);
padding: 12px 20px;
border-radius: 8px;
color: white;
display: flex;
align-items: center;
gap: 12px;
z-index: 1001;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: slideInRight 0.3s ease;
button {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
4.11 坐标栏样式 components/cesium/CoordinatesBar.module.scss
javascript
.coordinatesBar {
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(10px);
padding: 8px 16px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 12px;
z-index: 100;
pointer-events: none;
border-left: 3px solid #42b883;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.coordGroup {
display: flex;
align-items: center;
gap: 6px;
}
.label {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
}
.value {
color: #ffd700;
font-weight: 500;
}
.divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
}
@media (max-width: 768px) {
.coordinatesBar {
display: none;
}
}
4.12 地图工具样式 components/cesium/MapTools.module.scss
css
.mapTools {
position: absolute;
bottom: 20px;
right: 20px;
z-index: 100;
}
.toolsGroup {
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 8px;
display: flex;
gap: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.toolBtn {
width: 40px;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-size: 20px;
cursor: pointer;
transition: all 0.2s;
color: white;
&:hover {
background: rgba(66, 184, 131, 0.5);
transform: scale(1.05);
}
&.active {
background: #42b883;
box-shadow: 0 0 8px rgba(66, 184, 131, 0.5);
}
}
.measureResult {
position: absolute;
bottom: 70px;
right: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(10px);
padding: 8px 16px;
border-radius: 8px;
color: #ffd700;
font-family: monospace;
font-size: 14px;
display: flex;
align-items: center;
gap: 12px;
white-space: nowrap;
border-right: 2px solid #ff6b6b;
}
.closeBtn {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 18px;
padding: 0 4px;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
4.13 主入口文件 App.tsx
css
import React, { useState } from 'react'
import { Provider } from 'react-redux'
import { store } from './store'
import { CesiumEarth } from './components/cesium/CesiumEarth'
import './App.scss'
function App() {
const [sidebarVisible, setSidebarVisible] = useState(true)
return (
<Provider store={store}>
<div className="app">
<header className="app-header">
<div className="logo">
<img src="/cesium-logo.svg" alt="Cesium" />
<span>React + Cesium 最佳实践</span>
</div>
<button
className="sidebar-toggle"
onClick={() => setSidebarVisible(!sidebarVisible)}
>
☰
</button>
</header>
<div className="app-main">
{sidebarVisible && (
<aside className="app-sidebar">
{/* 侧边栏内容 */}
<div className="sidebar-content">
<h3>图层管理</h3>
<p>侧边栏内容...</p>
</div>
</aside>
)}
<div className="map-container">
<CesiumEarth
showCoordinates={true}
showTools={true}
autoResetView={true}
/>
</div>
</div>
</div>
</Provider>
)
}
export default App
4.14 全局样式 App.scss
css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
}
.app {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.app-header {
height: 56px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
color: white;
z-index: 200;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.logo {
display: flex;
align-items: center;
gap: 12px;
img {
height: 32px;
}
span {
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #42b883, #35495e);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
.sidebar-toggle {
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 20px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(66, 184, 131, 0.5);
}
}
}
.app-main {
flex: 1;
display: flex;
overflow: hidden;
}
.app-sidebar {
width: 320px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(10px);
border-right: 1px solid rgba(255, 255, 255, 0.1);
z-index: 150;
overflow-y: auto;
padding: 16px;
color: white;
}
.map-container {
flex: 1;
position: relative;
}
4.15 主入口 main.tsx
css
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './assets/styles/global.scss'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
五、性能优化配置
5.1 React 特定优化
css
// 使用 React.memo 避免不必要的重渲染
import React, { memo } from 'react'
export const CoordinatesBar = memo(() => {
// 组件逻辑
})
// 使用 useCallback 缓存函数
const handleClick = useCallback(() => {
// 处理逻辑
}, [dependencies])
// 使用 useMemo 缓存计算结果
const expensiveValue = useMemo(() => {
return computeExpensiveValue(data)
}, [data])
5.2 代码分割与懒加载
css
// 路由懒加载
import { lazy, Suspense } from 'react'
const MapTools = lazy(() => import('@/components/cesium/MapTools'))
// 使用 Suspense 包裹
<Suspense fallback={<Loading />}>
<MapTools />
</Suspense>
六、最佳实践清单
✅ 必须遵守的规范
- **实例管理**
\] Viewer 使用 Context 单例管理
\] 使用 useRef 存储不需要响应式的实例
\] 地图状态统一使用 Redux Toolkit 管理
\] 使用 useAppDispatch 和 useAppSelector 类型化 hooks
\] 大量数据使用 Primitive 而非 Entity
\] 使用 useCallback/useMemo 缓存函数和值
- **代码组织**
\] 业务逻辑封装在 custom hooks 中
\] 类型定义统一管理
\] 使用 ErrorBoundary 捕获组件错误
\] 提供友好的错误提示