文章目录
在 Vue 组件里创建一个 Cesium.Viewer 并不难。真正容易出问题的是组件反复进入、退出和热更新之后,旧 Viewer、场景事件和 WebGL 上下文仍然留在页面中。
这篇文章整理一个可复用的 useCesiumViewer。它只负责四件事:等待容器出现、创建 Viewer、通知业务层 Viewer 已就绪、在作用域销毁前释放资源。
Viewer 为什么不能当成普通变量
Viewer 内部持有 Canvas、WebGL 上下文、默认控件、场景事件和渲染循环。下面这种写法只能覆盖最简单的首次挂载:
onMounted(() => {
viewer = new Cesium.Viewer(containerRef.value!)
})
onUnmounted(() => {
viewer?.destroy()
})
实际项目还要处理这些状态:
容器尚未渲染
↓
创建 Viewer
↓
业务代码注册 scene 事件和 Primitive
↓
Vue HMR 或路由切换
↓
先清理业务资源,再销毁 Viewer
如果顺序反了,业务清理函数可能访问一个已经销毁的 scene。如果漏掉清理,重新进入页面后,同一个事件可能执行两次。
用 shallowRef 保存 Viewer
Viewer 是一个庞大的可变对象,不需要被 Vue 深度代理。这里使用 shallowRef:
import { shallowRef } from 'vue'
import type { ShallowRef } from 'vue'
import * as Cesium from 'cesium'
const viewerRef: ShallowRef<Cesium.Viewer | null> = shallowRef(null)
Vue 只追踪 viewerRef.value 是否被替换,不会递归代理 Viewer 内部对象。业务代码依然直接调用 Cesium API:
viewerRef.value?.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(108.94, 34.34, 800000),
})
等容器出现后再初始化
模板引用在 setup 执行时还是 null。使用 watchEffect 可以在容器赋值后立即初始化,也能覆盖 HMR 重新执行 setup 的场景:
const stopWatch = watchEffect(() => {
if (disposed) return
const container = containerRef.value
if (!container || viewerRef.value) return
init(container)
})
init 中只创建一次 Viewer:
function init(container: HTMLElement) {
Cesium.Ion.defaultAccessToken =
options.ionToken ?? import.meta.env.VITE_CESIUM_TOKEN ?? ''
const viewer = new Cesium.Viewer(container, {
infoBox: false,
navigationHelpButton: false,
animation: options.animation ?? false,
timeline: options.timeline ?? false,
baseLayerPicker: false,
fullscreenButton: false,
homeButton: false,
geocoder: false,
shouldAnimate: options.shouldAnimate ?? true,
terrainProvider: options.terrainProvider,
})
viewerRef.value = viewer
readyCallbacks.forEach(callback => callback({ viewer, container }))
}
Token 应通过环境变量注入,不要写进文章、仓库或前端业务配置。前端 Token 本身会发送到浏览器,还需要在服务端配置允许的域名和权限范围。
用回调划分基础设施和业务代码
组合式函数不应该知道每个案例要创建哪些 Entity、Primitive 或事件监听器。它只提供两个生命周期入口:
const readyCallbacks: Array<(ctx: ViewerContext) => void> = []
const disposeCallbacks: Array<(viewer: Cesium.Viewer) => void> = []
function onReady(callback: (ctx: ViewerContext) => void) {
readyCallbacks.push(callback)
}
function onDispose(callback: (viewer: Cesium.Viewer) => void) {
disposeCallbacks.push(callback)
}
业务组件通过它们注册和清理自己的资源:
const { viewerRef, onReady, onDispose } = useCesiumViewer(containerRef)
onReady(({ viewer }) => {
const onPostRender = () => {
// 更新比例尺或性能信息
}
viewer.scene.postRender.addEventListener(onPostRender)
onDispose(() => {
viewer.scene.postRender.removeEventListener(onPostRender)
})
})
这里的 onDispose 必须在 viewer.destroy() 之前执行。否则事件解绑阶段可能读到已经销毁的 WebGL 对象。
处理 HMR 遗留的旧 Viewer
开发环境热更新不一定完整触发组件的挂载和卸载流程。同一个 DOM 容器上可能残留旧 Viewer。
可以在模块级使用 WeakMap 记录容器和 Viewer 的关系:
const containerViewerMap = new WeakMap<HTMLElement, Cesium.Viewer>()
function removeOldViewer(container: HTMLElement) {
const oldViewer = containerViewerMap.get(container)
if (!oldViewer || oldViewer.isDestroyed()) return
runDisposeCallbacks(disposeCallbacks, oldViewer)
containerViewerMap.delete(container)
window.setTimeout(() => {
if (!oldViewer.isDestroyed()) oldViewer.destroy()
}, 0)
}
延后一轮事件循环再销毁 Viewer,是为了让同一组件作用域中的比例尺、鼠标坐标和其他组合式函数先完成解绑。它解决的是清理顺序,不是延迟释放资源。
创建新 Viewer 前调用它:
function init(container: HTMLElement) {
removeOldViewer(container)
const viewer = new Cesium.Viewer(container, viewerOptions)
containerViewerMap.set(container, viewer)
viewerRef.value = viewer
}
WeakMap 不会因为记录 DOM 节点而阻止垃圾回收,适合保存这种临时关联。
在 Vue 作用域结束时统一清理
使用 onScopeDispose 同时覆盖组件卸载和 effect scope 销毁:
onScopeDispose(() => {
stopWatch()
if (disposed) return
disposed = true
const viewer = viewerRef.value
viewerRef.value = null
if (viewer && !viewer.isDestroyed()) {
runDisposeCallbacks(disposeCallbacks, viewer)
window.setTimeout(() => {
if (!viewer.isDestroyed()) viewer.destroy()
}, 0)
}
disposeCallbacks.length = 0
readyCallbacks.length = 0
})
清理主线是固定的:
- 停止 Vue 的监听器。
- 阻止再次初始化。
- 清空暴露给组件的 Viewer 引用。
- 解绑业务事件并移除业务资源。
- 销毁 Viewer。
- 清空回调数组。
完整使用方式
<template>
<div ref="containerRef" class="cesium-container" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import * as Cesium from 'cesium'
import { useCesiumViewer } from '@/composables/cesium/useCesiumViewer'
const containerRef = ref<HTMLElement | null>(null)
const { onReady, onDispose } = useCesiumViewer(containerRef)
let handler: Cesium.ScreenSpaceEventHandler | null = null
onReady(({ viewer }) => {
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
handler.setInputAction(() => {
console.log('clicked')
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
})
onDispose(() => {
handler?.destroy()
handler = null
})
</script>
<style scoped lang="scss">
.cesium-container {
width: 100%;
height: 100%;
}
</style>
这个封装没有接管业务状态。它只保证 Viewer 的创建和销毁具有明确边界。后面的坐标拾取、绘制、模型和地形分析都可以建立在同一个生命周期上。