站内搜索

输入关键词开始搜索

Vue 3 中封装 Cesium Viewer:初始化、HMR 与资源清理

文章目录

在 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
})

清理主线是固定的:

  1. 停止 Vue 的监听器。
  2. 阻止再次初始化。
  3. 清空暴露给组件的 Viewer 引用。
  4. 解绑业务事件并移除业务资源。
  5. 销毁 Viewer。
  6. 清空回调数组。

完整使用方式

<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 的创建和销毁具有明确边界。后面的坐标拾取、绘制、模型和地形分析都可以建立在同一个生命周期上。

参考资料

相关文章

Cesium 三维风场:用 WebGL 2 GPU 粒子描绘全球气流

拆解案例中的风场数据、GPU 粒子更新、轨迹渲染、颜色映射和 Vue 参数控制

Cesium 北斗三维网格:在城市 3D Tiles 上做 GeoSOT-3D 剖分

介绍区域体素生成、建筑表面采样、结构碰撞检测、Worker 几何构建和网格编码查询