ArcGIS JS 基础教程(48):SceneView watch 状态监听
零、写在前面
📌 本系列教程完整目录 :ArcGIS JS 系列基础教程(100个项目常用热门功能)
💡 在线示例 :完整可运行的 HTML 示例,无需任何环境配置,可直接在浏览器中打开体验
🗂️ 专栏导航 :收藏 + 关注,专栏文章第一时间送达
❤️ 一键三连:点赞 + 评论 + 收藏
一、功能介绍
在三维应用中,「知道视图什么时候变了、变成了什么样」是几乎所有交互功能的基础:拖动地图后要重新统计可见要素、旋转视角后要同步更新指北针、镜头停下后要加载当前范围的数据......这些需求本质上都是监听视图状态的变化。
ArcGIS JS 提供了两套监听机制:
① 传统 view.watch()(Accessor 监听器)
来自 Accessor 基类,view.watch("属性名", 回调) 每次只能盯住一个属性 ,回调收到 (newValue, oldValue, propertyName, target)。它返回一个 WatchHandle,调用 handle.remove() 即可取消监听。
② reactiveUtils(5.0 推荐)
reactiveUtils 提供 watch() / when() / on() / once() / whenOnce() 五个函数。它的核心是自动依赖收集 :你写一个 getValue 表达式,里面访问到的所有属性都会被自动跟踪,任意一个变化就触发回调。因此一个句柄可以同时盯住多个属性。
二者的关键差异:
| 对比项 | view.watch() |
reactiveUtils.watch() |
|---|---|---|
| 跟踪属性数 | 一次一个 | 一次多个(表达式里访问到的都算) |
| 表达式灵活性 | 只能写属性名字符串 | 任意 JS 表达式(可计算、可条件判断) |
| 初始化回调 | 不支持 | 支持 initial: true |
| 只触发一次 | 需手动 remove | 支持 once: true 自动移除 |
| 一次性等待 | 不支持 | once() / whenOnce() 返回 Promise,可 await |
| 取消方式 | handle.remove() |
handle.remove() |
💡 三维要点 :
SceneView最常监听的属性是camera(位置/朝向)、scale、zoom、center、extent、stationary(是否静止)、updating(是否渲染中)。三维场景中camera还包含heading(方位角)、tilt(倾斜角),这是二维视图没有的。
参考:reactiveUtils 官方 API | 监听变化指南
二、功能实现
2.1 传统方式:一个属性一个句柄
javascript
const handles = [];
handles.push(view.watch("camera", updatePanel));
handles.push(view.watch("scale", updatePanel));
handles.push(view.watch("stationary", updatePanel));
// 取消监听:逐个 remove,避免内存泄漏
handles.forEach(h => h.remove());
handles.length = 0;
2.2 推荐方式:reactiveUtils 一个句柄盯多个属性
javascript
const reactiveUtils = await $arcgis.import("@arcgis/core/core/reactiveUtils.js");
const handle = reactiveUtils.watch(
// getValue:表达式里访问到的属性都会被自动跟踪
() => [view.camera?.heading, view.camera?.tilt, view.scale, view.stationary],
// callback:值变化时执行
() => updatePanel(),
{ initial: true } // 初始化时立即回调一次
);
handle.remove(); // 取消监听
options 常用参数:initial(初始化立即回调,默认 false)、sync(同步回调,默认 false 即微任务异步)、once(触发一次后自动移除句柄)、equals(自定义相等判断,避免无谓回调)。
2.3 一次性等待:when / whenOnce
「等视图停下来再做一次」是三维里极常见的需求,用 when + once:true 最省事:
javascript
// when:条件变为真时触发,once:true 触发后自动释放句柄
reactiveUtils.when(
() => view.stationary === true,
() => console.log("视图静止,开始加载数据"),
{ once: true }
);
// whenOnce:返回 Promise,可直接 await
await reactiveUtils.whenOnce(() => view.stationary === true);
⚠️ 注意布尔属性要用显式比较 (
=== true/=== false)。when()判断的是「真值」,如果只写() => view.stationary,那么false → false不会触发;而stationary恰好默认就是true,直接写会导致首次拖动后行为不符合预期。
三、功能应用
| 场景 | 监听属性 | 说明 |
|---|---|---|
| 视口内要素统计 | stationary |
镜头停下后再查询,避免拖动过程中频繁请求 |
| 指北针 / 俯仰角同步 | camera.heading / camera.tilt |
实时把相机朝向同步到自定义 UI |
| 缩放级别联动 | zoom / scale |
不同缩放级别显示不同详细程度的图层 |
| 首次加载完成回调 | updating |
updating 由 true 变 false 即本轮渲染结束 |
| 相机参数记录与回放 | camera |
记录关键帧,实现三维漫游录制 |
| 图层可见性联动 | layer.visible |
图层显隐变化时刷新图例或统计面板 |
四、核心代码
📦 完整代码 已保存至
sample/lesson48_sceneview_watch.html,可直接在浏览器打开。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>第48课:SceneView watch 状态监听</title>
<link rel="stylesheet" href="https://js.arcgis.com/5.0/esri/themes/light/main.css">
<script type="module" src="https://js.arcgis.com/5.0/"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; }
#mapContainer { width: 100vw; height: 100vh; }
.page-title {
position: absolute; top: 20px; left: 50%; transform: translateX(-50%);
background: rgba(255,255,255,0.95); padding: 10px 24px; border-radius: 6px;
font-size: 18px; font-weight: bold; z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.panel {
position: absolute; top: 80px; right: 20px; width: 300px;
background: rgba(255,255,255,0.95); padding: 16px; border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15); z-index: 100;
}
.panel h3 { margin: 0 0 8px 0; font-size: 14px; }
.info-row {
display: flex; justify-content: space-between;
font-size: 12px; padding: 4px 0; border-bottom: 1px dashed #eee;
}
.info-row span:last-child { color: #1890ff; font-weight: bold; }
.btn-row { display: flex; gap: 8px; margin-top: 8px; }
.btn-row button {
flex: 1; padding: 6px 0; font-size: 12px;
border: 1px solid #d9d9d9; border-radius: 4px;
background: white; cursor: pointer;
}
.btn-row button:hover { border-color: #1890ff; color: #1890ff; }
.btn-row button.active { background: #1890ff; color: white; border-color: #1890ff; }
.status {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.7); color: white; padding: 8px 20px;
border-radius: 20px; font-size: 13px; z-index: 100; pointer-events: none;
}
</style>
</head>
<body>
<h1 class="page-title">第48课:SceneView watch 状态监听</h1>
<div class="panel">
<h3>📡 实时状态</h3>
<div class="info-row"><span>经度</span><span id="vLng">--</span></div>
<div class="info-row"><span>纬度</span><span id="vLat">--</span></div>
<div class="info-row"><span>相机高度</span><span id="vZ">--</span></div>
<div class="info-row"><span>方位角 heading</span><span id="vHeading">--</span></div>
<div class="info-row"><span>倾斜角 tilt</span><span id="vTilt">--</span></div>
<div class="info-row"><span>比例尺 1:</span><span id="vScale">--</span></div>
<div class="info-row"><span>静止 stationary</span><span id="vStationary">--</span></div>
<div class="info-row"><span>回调次数</span><span id="vCount">0</span></div>
<h3 style="margin-top: 12px;">① 传统 view.watch()</h3>
<div class="btn-row">
<button id="btnLegacyStart">启动监听</button>
<button id="btnLegacyStop">停止监听</button>
</div>
<h3 style="margin-top: 12px;">② reactiveUtils.watch()</h3>
<div class="btn-row">
<button id="btnRuStart">启动监听</button>
<button id="btnRuStop">停止监听</button>
</div>
<h3 style="margin-top: 12px;">③ when / whenOnce</h3>
<div class="btn-row">
<button id="btnWhen">when 静止后提示</button>
<button id="btnWhenOnce">whenOnce 等待静止</button>
</div>
<h3 style="margin-top: 12px;">④ 触发视图变化</h3>
<div class="btn-row">
<button id="btnShanghai">飞到上海</button>
<button id="btnBeijing">飞回北京</button>
</div>
</div>
<div class="status" id="st">SceneView watch | 先启动监听,再拖动/旋转地图观察状态变化</div>
<div id="mapContainer"></div>
<script type="module">
const Map = await $arcgis.import("@arcgis/core/Map.js");
const SceneView = await $arcgis.import("@arcgis/core/views/SceneView.js");
const GraphicsLayer = await $arcgis.import("@arcgis/core/layers/GraphicsLayer.js");
const Graphic = await $arcgis.import("@arcgis/core/Graphic.js");
const Point = await $arcgis.import("@arcgis/core/geometry/Point.js");
const ElevationLayer = await $arcgis.import("@arcgis/core/layers/ElevationLayer.js");
const reactiveUtils = await $arcgis.import("@arcgis/core/core/reactiveUtils.js");
const getTianditu = await $arcgis.import("https://openlayers.vip/examples/resources/tianditu.js");
const tl = getTianditu.default({ type: "vec_w" });
const map = new Map({
basemap: { baseLayers: [tl.base, tl.anno] },
ground: {
surface: {
elevationLayers: [new ElevationLayer({
url: "https://www.geosceneonline.cn/image/rest/services/OpenData/ChinaTerrain3D/ImageServer/"
})]
}
}
});
const view = new SceneView({
container: "mapContainer",
map: map,
camera: { position: { longitude: 116.397, latitude: 39.908, z: 1500 }, heading: 0, tilt: 60 }
});
// 少量参照点,便于观察视图变化时坐标的联动
const gl = new GraphicsLayer({ title: "参照点", elevationInfo: { mode: "relative-to-ground" } });
[[116.397, 39.908, "北京"], [121.473, 31.230, "上海"]].forEach(([lng, lat, name]) => {
gl.add(new Graphic({
geometry: new Point({ longitude: lng, latitude: lat, z: 200 }),
symbol: {
type: "point-3d",
symbolLayers: [{ type: "object", resource: { primitive: "sphere" }, width: 120, height: 120, depth: 120, material: { color: [255, 80, 80] } }]
},
attributes: { NAME: name }
}));
});
map.add(gl);
await view.when();
// ===== 面板刷新 =====
let callbackCount = 0;
function setStatus(msg) { document.getElementById("st").textContent = msg; }
function updatePanel() {
const cam = view.camera;
if (!cam) return;
callbackCount++;
document.getElementById("vLng").textContent = cam.position.longitude.toFixed(5);
document.getElementById("vLat").textContent = cam.position.latitude.toFixed(5);
document.getElementById("vZ").textContent = cam.position.z.toFixed(0) + " m";
document.getElementById("vHeading").textContent = cam.heading.toFixed(1) + "°";
document.getElementById("vTilt").textContent = cam.tilt.toFixed(1) + "°";
document.getElementById("vScale").textContent = Math.round(view.scale).toLocaleString();
document.getElementById("vStationary").textContent = view.stationary ? "是" : "运动中";
document.getElementById("vCount").textContent = callbackCount;
}
// ===== ① 传统 watch:每个属性一个句柄 =====
const legacyHandles = [];
function startLegacy() {
if (legacyHandles.length) { setStatus("传统监听已在运行"); return; }
legacyHandles.push(view.watch("camera", updatePanel));
legacyHandles.push(view.watch("scale", updatePanel));
legacyHandles.push(view.watch("stationary", updatePanel));
document.getElementById("btnLegacyStart").classList.add("active");
setStatus("传统 watch 已启动:camera / scale / stationary 共 3 个句柄");
}
function stopLegacy() {
legacyHandles.forEach(h => h.remove());
legacyHandles.length = 0;
document.getElementById("btnLegacyStart").classList.remove("active");
setStatus("传统 watch 已停止,句柄已释放");
}
// ===== ② reactiveUtils:一个句柄跟踪多个属性 =====
let ruHandle = null;
function startReactive() {
if (ruHandle) { setStatus("reactiveUtils 监听已在运行"); return; }
ruHandle = reactiveUtils.watch(
() => [view.camera?.heading, view.camera?.tilt, view.scale, view.stationary],
() => updatePanel(),
{ initial: true }
);
document.getElementById("btnRuStart").classList.add("active");
setStatus("reactiveUtils.watch 已启动:1 个句柄跟踪 4 个属性(initial:true 立即回调一次)");
}
function stopReactive() {
if (!ruHandle) { setStatus("reactiveUtils 监听未启动"); return; }
ruHandle.remove();
ruHandle = null;
document.getElementById("btnRuStart").classList.remove("active");
setStatus("reactiveUtils 句柄已 remove(),监听解除");
}
// ===== ③ when / whenOnce =====
function runWhen() {
// once:true → 触发一次后自动移除句柄,适合「停下来再做一次」的场景
reactiveUtils.when(
() => view.stationary === true,
() => setStatus("when:视图已静止,执行一次(句柄自动释放)"),
{ once: true }
);
setStatus("when 已注册:请在地图中拖动/旋转,松手后触发一次");
}
async function runWhenOnce() {
setStatus("whenOnce:等待视图静止(Promise)...");
await reactiveUtils.whenOnce(() => view.stationary === true);
setStatus("whenOnce:Promise 已 resolve,视图静止");
}
// ===== ④ 触发视图变化 =====
function flyTo(lng, lat, name) {
view.goTo({
position: new Point({ longitude: lng, latitude: lat, z: 1500 }),
heading: 0,
tilt: 60
}, { duration: 2000 })
.catch((e) => console.warn("goTo 失败:", e?.message));
setStatus("正在飞往" + name + ",观察面板数值联动更新");
}
document.getElementById("btnLegacyStart").onclick = startLegacy;
document.getElementById("btnLegacyStop").onclick = stopLegacy;
document.getElementById("btnRuStart").onclick = startReactive;
document.getElementById("btnRuStop").onclick = stopReactive;
document.getElementById("btnWhen").onclick = runWhen;
document.getElementById("btnWhenOnce").onclick = runWhenOnce;
document.getElementById("btnShanghai").onclick = () => flyTo(121.473, 31.230, "上海");
document.getElementById("btnBeijing").onclick = () => flyTo(116.397, 39.908, "北京");
updatePanel();
setStatus("场景就绪 | 启动监听后拖动或旋转地图,观察状态实时联动");
</script>
</body>
</html>
五、在线示例
🔗 在线体验 :https://southjor.github.io/arcgis-examples/lessons/lesson48.html

操作说明:
- 点击「① 启动监听」,然后拖动或旋转地图,观察面板中经纬度、heading、tilt、比例尺实时联动。
- 点击「① 停止监听」后再拖地图,面板不再更新,直观理解
handle.remove()的作用。- 切换到「② reactiveUtils」,注意
initial: true会在启动时立刻回调一次(顶栏提示与回调次数变化)。- 点击「③ when 静止后提示」,再拖动地图,松手后触发一次且句柄自动释放。
- 用「④ 飞到上海 / 飞回北京」触发
goTo动画,观察整个飞行过程中监听回调被连续触发。
六、关键 API 说明
| API | 说明 |
|---|---|
view.watch(prop, cb) |
传统监听,一次一个属性,返回 WatchHandle |
WatchHandle.remove() |
取消监听,释放引用 |
reactiveUtils.watch(getValue, cb, options?) |
自动依赖收集,一个句柄盯多个属性,返回 ResourceHandle |
reactiveUtils.when(getValue, cb, options?) |
表达式变为真值时触发,常用于「等条件满足」 |
reactiveUtils.once(getValue, signal?) |
返回 Promise,值变化时 resolve 一次 |
reactiveUtils.whenOnce(getValue, signal?) |
返回 Promise,条件变为真值时 resolve |
ResourceHandle.remove() |
取消监听;remove() 幂等,可重复调用 |
options.initial |
初始化时立即回调一次(默认 false) |
options.once |
触发一次后自动移除句柄(默认 false) |
options.sync |
同步回调(默认 false,即微任务异步批处理) |
view.camera |
相机,含 position / heading / tilt |
view.stationary |
视图是否静止(交互结束为 true) |
view.updating |
是否正在渲染(由 true→false 表示本轮渲染完成) |
参考链接:
七、系列导航
⬅️ 上一篇 :ArcGIS JS 基础教程(47):内置组件集成
➡️ 下一篇 :ArcGIS JS 基础教程(49):场景组件1
💡 小贴士 :监听最容易踩的坑有两个,忘记
remove()导致内存泄漏 (尤其是单页应用反复创建监听时),以及对布尔属性用真值判断 (when(() => view.stationary)在默认 true 时不会按预期触发,务必写=== true)。另外,when()/watch()默认在微任务中异步回调,会把一帧内的多次变化合并成一次执行,这是它性能更好的原因;只有在必须同步读取新值的极少数场景才需要sync: true。还有一个与监听无关的坑:三维view.goTo的position必须传Point实例或[x,y,z]坐标数组 ,不能传{ longitude, latitude, z }这种平面对象------后者不会被解析成目标点,动画会静默失败(.catch(() => {})还会把错误吞掉,难以排查),本示例的flyTo已改用new Point(...)并显式打印失败原因。