「React + Cesium 最佳实践」完整工程化方案

一、完整项目架构图

复制代码
┌─────────────────────────────────────────────────────────────────────────────┐
│                        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>

六、最佳实践清单

✅ 必须遵守的规范

  1. **实例管理**
  • \] Viewer 使用 Context 单例管理

  • \] 使用 useRef 存储不需要响应式的实例

  • \] 地图状态统一使用 Redux Toolkit 管理

  • \] 使用 useAppDispatch 和 useAppSelector 类型化 hooks

  • \] 大量数据使用 Primitive 而非 Entity

  • \] 使用 useCallback/useMemo 缓存函数和值

  1. **代码组织**
  • \] 业务逻辑封装在 custom hooks 中

  • \] 类型定义统一管理

  • \] 使用 ErrorBoundary 捕获组件错误

  • \] 提供友好的错误提示

相关推荐
神の愛1 小时前
js的深拷贝和浅拷贝?啥情况讲解下??底层堆栈空间??object.prototype.toString.call(),还有bind,的具体使用?
前端·javascript·原型模式
1314lay_10071 小时前
el-table表格数据分页切片,导致表格的多选失效
javascript·vue.js·elementui
qq_12084093712 小时前
Three.js 模型加载稳定性实战:从资源失败到可用发布的工程化方案
前端·javascript·vue.js·vue3·three.js
skywalk81632 小时前
mock数据什么意思?前端应用mock
前端
weixin199701080162 小时前
《闲鱼商品详情页前端性能优化实战》
前端·性能优化
qq_12084093712 小时前
Three.js 性能实战:大场景从 15FPS 到 60FPS 的工程化优化路径
开发语言·前端·javascript
Code-keys2 小时前
【gdb工具】 使用详细介绍
前端·chrome
guhy fighting2 小时前
使用vue-virtual-scroller导致打包报错
前端·javascript·vue.js·webpack
UXbot2 小时前
如何用 AI 生成产品原型:从需求描述到可交互界面的完整 5 步流程
前端·人工智能·ui·交互·ai编程