官网地址:点我
一、效果预览
二、项目初始化
2.1 创建 React 项目
# 使用 Vite 创建 React + TypeScript 项目 pnpm create vite react-cesium-starter --template react-ts cd react-cesium-starter # 或者使用 CRA(不推荐,较慢) # npx create-react-app react-cesium-starter --template typescript2.2 安装核心依赖
# Cesium 核心库 pnpm add cesium # Resium - React 绑定库(声明式 Cesium 组件) pnpm add resium # 工具库 pnpm add turf turf-helpers # 类型定义 pnpm add -d @types/cesium2.3 配置文件
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'), 'cesium': 'cesium' } }, define: { CESIUM_BASE_URL: JSON.stringify('/cesium') }, server: { port: 5174, open: true }, build: { rollupOptions: { output: { manualChunks: { cesium: ['cesium'] } } } } }).env.development
env VITE_CESIUM_TOKEN=your_cesium_ion_token VITE_TIANDITU_TOKEN=your_tianditu_token三、目录结构
src/ ├── components/ │ ├── CesiumEarth.tsx # 主地球组件 │ ├── ControlPanel.tsx # 控制面板 │ ├── CoordinatesBar.tsx # 坐标显示栏 │ └── MarkerPopup.tsx # 标记弹窗 ├── hooks/ │ ├── useCesiumViewer.ts # Viewer 管理 Hook │ ├── useCesiumCamera.ts # 相机控制 Hook │ └── useEntityClick.ts # 实体点击 Hook ├── contexts/ │ └── CesiumContext.tsx # Viewer 全局上下文 ├── utils/ │ ├── cesiumConfig.ts # Cesium 配置 │ └── coordinateUtils.ts # 坐标工具 ├── types/ │ └── cesium.types.ts # 类型定义 ├── App.tsx └── main.tsx四、核心代码实现
4.1 Cesium 配置utils/cesiumConfig.ts
import * as Cesium from 'cesium' import 'cesium/Build/Cesium/Widgets/widgets.css' export function initCesium() { // 设置 Ion Token Cesium.Ion.defaultAccessToken = import.meta.env.VITE_CESIUM_TOKEN // 全局配置 Cesium.Resource.defaultImage.crossOrigin = 'Anonymous' } export const defaultViewerProps = { // 基础底图(使用无 Ion 的免费图层) imageryProvider: new Cesium.UrlTemplateImageryProvider({ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', subdomains: ['a', 'b', 'c'], minimumLevel: 1, maximumLevel: 19 }), // 地形 terrainProvider: Cesium.createWorldTerrain(), // UI 配置 animation: false, baseLayerPicker: true, fullscreenButton: true, geocoder: true, homeButton: true, infoBox: true, sceneModePicker: true, selectionIndicator: true, timeline: false, navigationHelpButton: false, // 性能优化 requestRenderMode: true, maximumRenderTimeChange: 0.1, scene3DOnly: true } export const defaultCameraPosition = { destination: Cesium.Cartesian3.fromDegrees(116.397428, 39.90923, 5000), orientation: { heading: Cesium.Math.toRadians(0), pitch: Cesium.Math.toRadians(-30), roll: 0 } }4.2 Viewer 上下文contexts/CesiumContext.tsx
import React, { createContext, useContext, useRef, ReactNode } from 'react' import { Viewer } from 'resium' import * as Cesium from 'cesium' import { defaultViewerProps, initCesium } from '@/utils/cesiumConfig' interface CesiumContextType { viewerRef: React.MutableRefObject<Cesium.Viewer | null> isReady: boolean setIsReady: (ready: boolean) => void } const CesiumContext = createContext<CesiumContextType | null>(null) export const useCesium = () => { const context = useContext(CesiumContext) if (!context) { throw new Error('useCesium must be used within CesiumProvider') } return context } interface CesiumProviderProps { children: ReactNode } export const CesiumProvider: React.FC<CesiumProviderProps> = ({ children }) => { const viewerRef = useRef<Cesium.Viewer | null>(null) const [isReady, setIsReady] = React.useState(false) // 初始化 Cesium React.useEffect(() => { initCesium() }, []) return ( <CesiumContext.Provider value={{ viewerRef, isReady, setIsReady }}> {children} </CesiumContext.Provider> ) } // Viewer 就绪后的包装组件 export const CesiumViewerWrapper: React.FC<{ children?: ReactNode }> = ({ children }) => { const { viewerRef, setIsReady } = useCesium() const handleViewerReady = (viewer: Cesium.Viewer) => { viewerRef.current = viewer setIsReady(true) console.log('✅ Resium Viewer 已就绪') } return ( <Viewer {...defaultViewerProps} ref={(e: any) => { if (e?.cesiumElement && !viewerRef.current) { handleViewerReady(e.cesiumElement) } }} full > {children} </Viewer> ) }4.3 相机控制 Hookhooks/useCesiumCamera.ts
import { useCesium } from '@/contexts/CesiumContext' import * as Cesium from 'cesium' import { useCallback } from 'react' export const useCesiumCamera = () => { const { viewerRef, isReady } = useCesium() // 飞往指定位置 const flyTo = useCallback(( lng: number, lat: number, height: number = 5000, duration: number = 1.5 ) => { if (!isReady || !viewerRef.current) return viewerRef.current.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(lng, lat, height), orientation: { heading: Cesium.Math.toRadians(0), pitch: Cesium.Math.toRadians(-30), roll: 0 }, duration }) }, [isReady, viewerRef]) // 获取当前相机位置 const getCurrentPosition = useCallback(() => { if (!isReady || !viewerRef.current) return null const camera = viewerRef.current.camera const position = camera.positionCartographic const height = viewerRef.current.scene.globe.getHeight(position) || 0 return { lng: Cesium.Math.toDegrees(position.longitude), lat: Cesium.Math.toDegrees(position.latitude), altitude: position.height, groundHeight: height } }, [isReady, viewerRef]) // 重置视角(默认天安门) const resetView = useCallback(() => { flyTo(116.397428, 39.90923, 5000) }, [flyTo]) return { flyTo, getCurrentPosition, resetView } }4.4 实体点击 Hookhooks/useEntityClick.ts
import { useEffect, useState } from 'react' import { useCesium } from '@/contexts/CesiumContext' import * as Cesium from 'cesium' interface ClickedEntityInfo { id: string name?: string position: { lng: number lat: number height: number } properties?: Record<string, any> } export const useEntityClick = () => { const { viewerRef, isReady } = useCesium() const [clickedEntity, setClickedEntity] = useState<ClickedEntityInfo | null>(null) const [showPopup, setShowPopup] = useState(false) useEffect(() => { if (!isReady || !viewerRef.current) return const handler = new Cesium.ScreenSpaceEventHandler(viewerRef.current.scene.canvas) handler.setInputAction((movement: any) => { const pick = viewerRef.current?.scene.pick(movement.position) if (Cesium.defined(pick) && pick.id) { const entity = pick.id const position = entity.position?.getValue(viewerRef.current!.clock.currentTime) if (position) { const cartographic = Cesium.Cartographic.fromCartesian(position) setClickedEntity({ id: entity.id, name: entity.name, position: { lng: Cesium.Math.toDegrees(cartographic.longitude), lat: Cesium.Math.toDegrees(cartographic.latitude), height: cartographic.height }, properties: entity.properties?.getValue() }) setShowPopup(true) // 3秒后自动关闭 setTimeout(() => setShowPopup(false), 3000) } } else { setShowPopup(false) } }, Cesium.ScreenSpaceEventType.LEFT_CLICK) return () => { handler.destroy() } }, [isReady, viewerRef]) return { clickedEntity, showPopup, setShowPopup } }4.5 主地球组件components/CesiumEarth.tsx
import React, { useState } from 'react' import { Entity, PointGraphics, BillboardGraphics, PolylineGraphics, CameraFlyTo } from 'resium' import { Cartesian3, Color } from 'cesium' import { CesiumProvider, CesiumViewerWrapper } from '@/contexts/CesiumContext' import { useCesiumCamera } from '@/hooks/useCesiumCamera' import { useEntityClick } from '@/hooks/useEntityClick' import ControlPanel from './ControlPanel' import CoordinatesBar from './CoordinatesBar' import MarkerPopup from './MarkerPopup' // 标记点数据 interface Marker { id: string name: string lng: number lat: number height?: number icon?: string } const markersData: Marker[] = [ { id: '1', name: '北京天安门', lng: 116.397428, lat: 39.90923, height: 50 }, { id: '2', name: '上海东方明珠', lng: 121.499, lat: 31.239, height: 468 }, { id: '3', name: '广州塔', lng: 113.324, lat: 23.109, height: 600 }, { id: '4', name: '西安钟楼', lng: 108.94, lat: 34.26, height: 36 } ] const CesiumEarthContent: React.FC = () => { const { flyTo, resetView } = useCesiumCamera() const { clickedEntity, showPopup } = useEntityClick() const [selectedMarker, setSelectedMarker] = useState<Marker | null>(null) // 处理标记点击 const handleMarkerClick = (marker: Marker) => { setSelectedMarker(marker) flyTo(marker.lng, marker.lat, marker.height || 1000) } return ( <div style={{ position: 'relative', width: '100vw', height: '100vh' }}> <CesiumViewerWrapper> {/* 绘制标记点 */} {markersData.map((marker) => ( <Entity key={marker.id} name={marker.name} position={Cartesian3.fromDegrees(marker.lng, marker.lat, marker.height || 0)} onClick={() => handleMarkerClick(marker)} > <BillboardGraphics image="/marker-icon.png" // 自定义图标 verticalOrigin={0} height={32} width={32} scale={1.2} color={Color.WHITE} /> <PointGraphics pixelSize={10} color={Color.fromCssColorString('#42b883')} outlineColor={Color.WHITE} outlineWidth={2} /> </Entity> ))} {/* 示例:绘制一条线(北京到上海) */} <PolylineGraphics positions={Cartesian3.fromDegreesArray([ 116.397428, 39.90923, // 北京 121.499, 31.239 // 上海 ])} width={3} material={Color.fromCssColorString('#ff6b6b')} arcType={3} // ArcType.GEODESIC /> {/* 示例:飞行动画 */} {selectedMarker && ( <CameraFlyTo destination={Cartesian3.fromDegrees( selectedMarker.lng, selectedMarker.lat, selectedMarker.height || 1000 )} duration={1.5} orientation={{ heading: 0, pitch: -0.5, roll: 0 }} /> )} </CesiumViewerWrapper> {/* UI 覆盖层 */} <ControlPanel onFlyTo={flyTo} onReset={resetView} markers={markersData} onMarkerSelect={handleMarkerClick} /> <CoordinatesBar /> {showPopup && clickedEntity && ( <MarkerPopup entity={clickedEntity} onClose={() => {}} /> )} </div> ) } const CesiumEarth: React.FC = () => { return ( <CesiumProvider> <CesiumEarthContent /> </CesiumProvider> ) } export default CesiumEarth4.6 控制面板components/ControlPanel.tsx
import React, { useState } from 'react' import { useScreenshot } from '@/hooks/useScreenshot' import './ControlPanel.css' interface Marker { id: string name: string lng: number lat: number } interface ControlPanelProps { onFlyTo: (lng: number, lat: number, height?: number) => void onReset: () => void markers: Marker[] onMarkerSelect: (marker: Marker) => void } const ControlPanel: React.FC<ControlPanelProps> = ({ onFlyTo, onReset, markers, onMarkerSelect }) => { const [searchValue, setSearchValue] = useState('') const { takeScreenshot } = useScreenshot() const handleSearch = () => { // 简单的坐标解析(支持 "116.397,39.909" 或 "北京" 格式) if (searchValue.includes(',')) { const [lng, lat] = searchValue.split(',').map(Number) if (!isNaN(lng) && !isNaN(lat)) { onFlyTo(lng, lat, 5000) } } else { // 根据名称搜索标记点 const marker = markers.find(m => m.name.includes(searchValue)) if (marker) { onMarkerSelect(marker) } } } return ( <div className="control-panel"> <div className="panel-header"> <h3>🌍 地图工具</h3> </div> {/* 搜索框 */} <div className="search-section"> <input type="text" placeholder="搜索地点或坐标 (lng,lat)" value={searchValue} onChange={(e) => setSearchValue(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && handleSearch()} /> <button onClick={handleSearch}>🔍 搜索</button> </div> {/* 快捷地点 */} <div className="markers-list"> <h4>📍 热门地点</h4> {markers.map(marker => ( <button key={marker.id} onClick={() => onMarkerSelect(marker)} className="marker-btn" > {marker.name} </button> ))} </div> {/* 工具按钮 */} <div className="tools-section"> <button onClick={onReset} className="tool-btn"> 🎯 重置视角 </button> <button onClick={takeScreenshot} className="tool-btn"> 📸 截图 </button> <button className="tool-btn"> 📏 测量距离 </button> </div> </div> ) } export default ControlPanel4.7 截图 Hookhooks/useScreenshot.ts
import { useCesium } from '@/contexts/CesiumContext' export const useScreenshot = () => { const { viewerRef, isReady } = useCesium() const takeScreenshot = (download: boolean = true) => { if (!isReady || !viewerRef.current) { console.warn('Viewer 未就绪,无法截图') return null } const canvas = viewerRef.current.scene.canvas const imageData = canvas.toDataURL('image/png') if (download) { const link = document.createElement('a') link.download = `cesium-screenshot-${Date.now()}.png` link.href = imageData link.click() } return imageData } const getBase64Image = (): Promise<string | null> => { return new Promise((resolve) => { if (!isReady || !viewerRef.current) { resolve(null) return } const canvas = viewerRef.current.scene.canvas resolve(canvas.toDataURL('image/png')) }) } return { takeScreenshot, getBase64Image } }4.8 坐标栏组件components/CoordinatesBar.tsx
import React, { useEffect, useState } from 'react' import { useCesium } from '@/contexts/CesiumContext' import * as Cesium from 'cesium' const CoordinatesBar: React.FC = () => { const { viewerRef, isReady } = useCesium() const [coordinates, setCoordinates] = useState({ lng: 0, lat: 0, height: 0 }) useEffect(() => { if (!isReady || !viewerRef.current) return // 监听鼠标移动 const handler = new Cesium.ScreenSpaceEventHandler(viewerRef.current.scene.canvas) handler.setInputAction((movement: any) => { const ellipsoid = viewerRef.current!.scene.globe.ellipsoid const cartesian = viewerRef.current!.camera.pickEllipsoid(movement.endPosition, ellipsoid) if (cartesian) { const cartographic = Cesium.Cartographic.fromCartesian(cartesian) setCoordinates({ lng: Cesium.Math.toDegrees(cartographic.longitude), lat: Cesium.Math.toDegrees(cartographic.latitude), height: cartographic.height }) } }, Cesium.ScreenSpaceEventType.MOUSE_MOVE) return () => { handler.destroy() } }, [isReady, viewerRef]) return ( <div className="coordinates-bar"> <span>📍 经度: {coordinates.lng.toFixed(6)}°</span> <span>📐 纬度: {coordinates.lat.toFixed(6)}°</span> <span>📏 海拔: {coordinates.height.toFixed(2)}m</span> </div> ) } export default CoordinatesBar4.9 样式文件App.css
* { margin: 0; padding: 0; box-sizing: border-box; } /* 控制面板样式 */ .control-panel { position: absolute; top: 20px; right: 20px; width: 260px; background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(12px); border-radius: 12px; padding: 16px; color: white; z-index: 100; border: 1px solid rgba(255, 255, 255, 0.1); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .panel-header h3 { margin-bottom: 16px; font-size: 16px; color: #42b883; } .search-section { display: flex; gap: 8px; margin-bottom: 16px; } .search-section input { flex: 1; padding: 8px 12px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 6px; color: white; font-size: 13px; } .search-section input::placeholder { color: rgba(255, 255, 255, 0.5); } .search-section button { padding: 8px 12px; background: #42b883; border: none; border-radius: 6px; color: white; cursor: pointer; transition: all 0.2s; } .search-section button:hover { background: #359f6b; } .markers-list h4 { font-size: 13px; margin-bottom: 8px; color: rgba(255, 255, 255, 0.8); } .marker-btn { display: block; width: 100%; padding: 8px; margin-bottom: 6px; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px; color: white; cursor: pointer; text-align: left; transition: all 0.2s; font-size: 13px; } .marker-btn:hover { background: rgba(66, 184, 131, 0.3); border-color: #42b883; } .tools-section { margin-top: 16px; display: flex; gap: 8px; } .tool-btn { flex: 1; padding: 8px; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px; color: white; cursor: pointer; transition: all 0.2s; font-size: 13px; } .tool-btn:hover { background: rgba(66, 184, 131, 0.5); } /* 坐标栏样式 */ .coordinates-bar { position: absolute; bottom: 20px; left: 20px; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(10px); padding: 8px 16px; border-radius: 8px; color: #fff; font-family: 'Courier New', monospace; font-size: 13px; display: flex; gap: 20px; z-index: 99; pointer-events: none; border-left: 3px solid #42b883; } /* 弹窗样式 */ .marker-popup { position: absolute; bottom: 80px; left: 20px; background: rgba(0, 0, 0, 0.9); backdrop-filter: blur(12px); padding: 12px 16px; border-radius: 8px; color: white; z-index: 100; min-width: 200px; border: 1px solid rgba(66, 184, 131, 0.5); animation: fadeInUp 0.2s ease; } .marker-popup h4 { margin-bottom: 8px; color: #42b883; } .marker-popup p { font-size: 12px; margin: 4px 0; font-family: monospace; } @keyframes fadeInUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }4.10 主入口App.tsx
import React from 'react' import CesiumEarth from './components/CesiumEarth' import './App.css' function App() { return ( <div className="App"> <CesiumEarth /> </div> ) } export default App五、Resium vs 原生 Cesium 对比
| 特性 | 原生 Cesium | Resium |
|---|---|---|
| 代码风格 | 命令式(new Viewer, entity.add) | 声明式(JSX 组件) |
| 状态管理 | 手动管理生命周期 | React 状态自动同步 |
| 学习曲线 | 需要理解 Cesium API | 熟悉 React 即可上手 |
| 组件复用 | 封装函数/类 | 标准 React 组件 |
| 调试体验 | Chrome DevTools | React DevTools + Cesium 调试 |
| 性能 | 直接操作 | 轻微封装开销(可忽略) |
| 适用场景 | 复杂自定义渲染 | 标准 GIS 业务开发 |
六、常见问题与解决方案
问题1:Resium 组件不渲染
原因:Viewer 未就绪时子组件就被渲染
解决方案:
// 使用条件渲染 {viewerReady && ( <Entity position={...} /> )}问题2:点击 Entity 无响应
解决方案:
// 确保开启了拾取 <Viewer {...defaultViewerProps}> <Entity onClick={() => console.log('clicked')} enablePickup={true} // 关键! /> </Viewer>问题3:TypeScript 类型报错
解决方案:
// 安装类型定义 pnpm add -d @types/cesium // 或者在 tsconfig.json 中配置 { "compilerOptions": { "types": ["cesium", "resium"] } }七、运行验证
# 安装依赖 pnpm install # 启动开发服务器 pnpm dev # 访问 http://localhost:5174验证清单:
三维地球正常显示
4个标记点正确显示
点击标记点相机飞行到对应位置
鼠标移动时左下角坐标实时更新
控制面板功能正常(搜索/重置/截图)
北京到上海的红色连线可见
八、React + Resium 架构图
┌─────────────────────────────────────────────────────────────┐ │ React Application │ ├─────────────────────────────────────────────────────────────┤ │ ┌─────────────────────────────────────────────────────┐ │ │ │ App.tsx │ │ │ └─────────────────────┬───────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ CesiumProvider (Context) │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │ │ CesiumViewerWrapper │ │ │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ │ │ Resium <Viewer> │ │ │ │ │ │ │ │ ┌────────────────────────────────┐ │ │ │ │ │ │ │ │ │ <Entity> <Polyline> <Camera> │ │ │ │ │ │ │ │ │ └────────────────────────────────┘ │ │ │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ │ ┌──────────────┼──────────────┐ │ │ ▼ ▼ ▼ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ControlPanel│ │Coordinates │ │MarkerPopup │ │ │ │ │ │ Bar │ │ │ │ │ └────────────┘ └────────────┘ └────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Custom Hooks │ │ │ │ useCesiumCamera useEntityClick useScreenshot │ │ │ └─────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────┘九、性能优化建议
使用
React.memo包裹频繁渲染的组件export default React.memo(CoordinatesBar)Entity 数据量大时使用虚拟滚动
// 只渲染视口内的 Entity <Entity position={...} show={isInViewport} />避免在 render 中创建新对象
// ❌ 不好 <Entity position={Cartesian3.fromDegrees(116, 39)} /> // ✅ 好 const position = useMemo(() => Cartesian3.fromDegrees(116, 39), []) <Entity position={position} />